Primer commit
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""sync_brand_to_branch_contacts.py
|
||||
|
||||
Sincroniza desde Marca hacia las sucursales correspondientes los contactos del
|
||||
bucket "contacts_in_brand_not_in_any_branch" (contactos que viven solo en Marca
|
||||
y no aparecen en ninguna sucursal).
|
||||
|
||||
Reglas estrictas:
|
||||
|
||||
1. Solo se procesa el contacto si tiene el campo TIENDA poblado Y esa TIENDA
|
||||
se puede resolver a un location_id real via el CSV verificador. Si no, se
|
||||
skipea con razon explicita.
|
||||
2. Antes de crear, se hace double-check en la sucursal destino con la misma
|
||||
estrategia: telefono -> email -> nombre (el match por nombre solo aplica si
|
||||
el contacto no tiene phone ni email).
|
||||
3. Si match en sucursal: skip (ya existe alli).
|
||||
4. Si no: crea el contacto en la sucursal con todos los datos basicos + custom
|
||||
fields mapeados por nombre del schema de Marca al schema de la sucursal.
|
||||
|
||||
Modos:
|
||||
- dry-run (default): no escribe nada en GHL, solo planea.
|
||||
- --apply: ejecuta las escrituras, registra cambios en script_audit y replica
|
||||
el contacto en SQLite local para mantener el snapshot sincronizado.
|
||||
|
||||
Uso CLI:
|
||||
python scripts/sync_brand_to_branch_contacts.py
|
||||
python scripts/sync_brand_to_branch_contacts.py --apply --yes
|
||||
python scripts/sync_brand_to_branch_contacts.py --only-contact <id>
|
||||
python scripts/sync_brand_to_branch_contacts.py --json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if ROOT_DIR not in sys.path:
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if SCRIPTS_DIR not in sys.path:
|
||||
sys.path.insert(0, SCRIPTS_DIR)
|
||||
|
||||
import script_audit # noqa: E402
|
||||
import sync_engine # noqa: E402
|
||||
from audit_brand_vs_branches_totals import ( # noqa: E402
|
||||
load_verifier,
|
||||
run_audit,
|
||||
)
|
||||
from sync_missing_opps_to_brand import ( # noqa: E402
|
||||
BRAND_LOCATION_ID,
|
||||
DB_PATH,
|
||||
build_contact_payload,
|
||||
find_brand_contact,
|
||||
index_brand_contacts,
|
||||
load_schemas_id_to_name,
|
||||
load_schemas_name_to_id,
|
||||
normalize_email,
|
||||
normalize_name,
|
||||
normalize_phone,
|
||||
safe_print,
|
||||
upsert_contact_in_db,
|
||||
)
|
||||
|
||||
SCRIPT_NAME = os.path.basename(__file__)
|
||||
|
||||
|
||||
def _load_branch_contact_index(conn, location_id):
|
||||
rows = conn.execute(
|
||||
"SELECT id, first_name, last_name, phone, email FROM contacts WHERE location_id=?",
|
||||
(location_id,),
|
||||
).fetchall()
|
||||
contacts = [dict(r) for r in rows]
|
||||
idx_phone, idx_email, idx_name = index_brand_contacts(contacts)
|
||||
return contacts, idx_phone, idx_email, idx_name
|
||||
|
||||
|
||||
def _load_brand_contact_full(conn, contact_id):
|
||||
row = conn.execute(
|
||||
"SELECT * FROM contacts WHERE location_id=? AND id=?",
|
||||
(BRAND_LOCATION_ID, contact_id),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _preview_payload(payload):
|
||||
cf_count = len(payload.get("customFields", []))
|
||||
p = {k: v for k, v in payload.items() if k != "customFields"}
|
||||
if cf_count:
|
||||
p["customFields_count"] = cf_count
|
||||
return p
|
||||
|
||||
|
||||
def run_sync(contact_ids=None, dry_run=True, log=None, run_id=None, max_parallel=None):
|
||||
"""Ejecuta la sincronizacion Marca -> Sucursal. Devuelve dict serializable.
|
||||
|
||||
Args:
|
||||
contact_ids: lista opcional de contact_ids de Marca a procesar.
|
||||
dry_run: True (default) = no escribe en GHL.
|
||||
log: funcion opcional log(line).
|
||||
run_id: id de script_audit cuando apply.
|
||||
max_parallel: numero maximo de creates en paralelo. None = auto (igual al
|
||||
numero de creates planificados, capado a 20).
|
||||
|
||||
Flujo:
|
||||
1. Pre-carga TODAS las sucursales no-demo (snapshot global con indices).
|
||||
2. Loop secuencial de validacion: TIENDA, match en destino, match GLOBAL.
|
||||
Genera resultados parciales (skips) y una lista de creates planificados.
|
||||
3. Si apply: ejecuta los creates en paralelo con ThreadPoolExecutor.
|
||||
Cada worker hace SOLO la llamada HTTP a GHL; el upsert en SQLite y el
|
||||
record_change se hacen en el thread principal tras consumir el future
|
||||
(evita races en SQLite y mantiene el log ordenado por completitud).
|
||||
"""
|
||||
if log is None:
|
||||
log = safe_print
|
||||
log_lock = threading.Lock()
|
||||
|
||||
def tlog(msg):
|
||||
with log_lock:
|
||||
log(msg)
|
||||
|
||||
if not os.path.exists(DB_PATH):
|
||||
raise FileNotFoundError(f"No existe {DB_PATH}. Corre una sincronizacion global primero.")
|
||||
|
||||
log(f"[{datetime.now().strftime('%H:%M:%S')}] === sync_brand_to_branch_contacts ===")
|
||||
log(f"Modo: {'DRY-RUN (no escribe)' if dry_run else 'APPLY (escribe en GHL)'}")
|
||||
|
||||
log("Calculando bucket contacts_in_brand_not_in_any_branch desde audit...")
|
||||
audit_data = run_audit(limit_missing=None)
|
||||
missing = audit_data["missing"]["contacts_in_brand_not_in_any_branch"]
|
||||
targets = missing["items"]
|
||||
if contact_ids:
|
||||
wanted = set(contact_ids)
|
||||
targets = [t for t in targets if t["id"] in wanted]
|
||||
log(f"Contactos candidatos: {len(targets)} (total en bucket: {missing['total']})")
|
||||
|
||||
if not targets:
|
||||
return {
|
||||
"dry_run": dry_run,
|
||||
"summary": _empty_summary(),
|
||||
"items": [],
|
||||
}
|
||||
|
||||
tokens = sync_engine.get_tokens_map()
|
||||
_verifier_by_loc, _verifier_by_tienda = load_verifier()
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
try:
|
||||
brand_schema_id_to_name = load_schemas_id_to_name(conn, BRAND_LOCATION_ID, "contact")
|
||||
|
||||
# ---- Pre-carga GLOBAL: todas las sucursales no-demo con sus indices ----
|
||||
log("Pre-cargando snapshot de TODAS las sucursales para double-check global...")
|
||||
per_branch_summary = audit_data.get("per_branch") or []
|
||||
# per_branch_summary ya excluye Marca y demos (audit_brand_vs_branches_totals lo filtra).
|
||||
branch_data = {} # loc_id -> {name, contacts, idx_phone, idx_email, idx_name}
|
||||
branch_schema_cache = {} # loc_id -> name->id schema
|
||||
global_contacts_aug = [] # contactos de todas las sucursales con _loc anotado
|
||||
|
||||
for b_info in per_branch_summary:
|
||||
loc = b_info["location_id"]
|
||||
_bc, ip, ie, inom = _load_branch_contact_index(conn, loc)
|
||||
branch_data[loc] = {
|
||||
"name": b_info["name"],
|
||||
"contacts": _bc,
|
||||
"idx_phone": ip,
|
||||
"idx_email": ie,
|
||||
"idx_name": inom,
|
||||
}
|
||||
for c in _bc:
|
||||
aug = dict(c)
|
||||
aug["_loc"] = loc
|
||||
aug["_loc_name"] = b_info["name"]
|
||||
global_contacts_aug.append(aug)
|
||||
|
||||
global_idx_phone, global_idx_email, global_idx_name = index_brand_contacts(global_contacts_aug)
|
||||
log(f"Snapshot global: {len(branch_data)} sucursales, {len(global_contacts_aug)} contactos indexados.")
|
||||
|
||||
summary = _empty_summary()
|
||||
summary["candidates"] = len(targets)
|
||||
results = []
|
||||
creates_planned = [] # cada item: dict con item, payload, target_token, expected_loc, etc.
|
||||
|
||||
# ---- Fase 1: validacion secuencial ----
|
||||
for idx, target in enumerate(targets, 1):
|
||||
brand_contact_id = target["id"]
|
||||
display_name = target.get("name") or "(sin nombre)"
|
||||
tienda_value = target.get("tienda")
|
||||
expected_loc = target.get("expected_location_id")
|
||||
expected_name = target.get("expected_branch_name")
|
||||
|
||||
log(f"[{idx}/{len(targets)}] {display_name} ({brand_contact_id}) tienda='{tienda_value}'")
|
||||
|
||||
item = {
|
||||
"brand_contact_id": brand_contact_id,
|
||||
"name": display_name,
|
||||
"phone": target.get("phone"),
|
||||
"email": target.get("email"),
|
||||
"tienda": tienda_value,
|
||||
"target_location_id": expected_loc,
|
||||
"target_branch_name": expected_name,
|
||||
"actions": [],
|
||||
"status": "pending",
|
||||
"error": None,
|
||||
}
|
||||
|
||||
try:
|
||||
if not tienda_value:
|
||||
item["status"] = "skipped_no_tienda"
|
||||
item["actions"].append({"action": "skip_no_tienda"})
|
||||
summary["skipped_no_tienda"] += 1
|
||||
log(" Skip: contacto no tiene campo TIENDA poblado.")
|
||||
results.append(item)
|
||||
continue
|
||||
|
||||
if not expected_loc:
|
||||
item["status"] = "skipped_unknown_tienda"
|
||||
item["actions"].append({"action": "skip_unknown_tienda", "tienda": tienda_value})
|
||||
summary["skipped_unknown_tienda"] += 1
|
||||
log(f" Skip: TIENDA='{tienda_value}' no matchea ninguna fila del verificador.")
|
||||
results.append(item)
|
||||
continue
|
||||
|
||||
target_token = tokens.get(expected_loc)
|
||||
if not target_token:
|
||||
raise RuntimeError(f"No hay token para la location {expected_loc} ({expected_name})")
|
||||
|
||||
brand_contact = _load_brand_contact_full(conn, brand_contact_id)
|
||||
if not brand_contact:
|
||||
raise RuntimeError(f"No se encontro el contacto {brand_contact_id} en Marca SQLite")
|
||||
|
||||
# Double-check #1: sucursal destino
|
||||
bdata = branch_data.get(expected_loc)
|
||||
if not bdata:
|
||||
# Sucursal destino no esta cacheada (puede pasar si fue demo o filtrada).
|
||||
raise RuntimeError(f"Sucursal destino {expected_loc} no esta en el snapshot")
|
||||
|
||||
match, strategy, collision = find_brand_contact(
|
||||
brand_contact, bdata["idx_phone"], bdata["idx_email"], bdata["idx_name"]
|
||||
)
|
||||
if collision and not match:
|
||||
# Mismo teléfono que un contacto de la sucursal destino, pero
|
||||
# nombres divergentes. NO crear ciegamente: probablemente
|
||||
# son personas distintas que comparten número.
|
||||
log(
|
||||
f" COLISION TELEFONO en sucursal destino {expected_name}: "
|
||||
f"contacto local {collision.get('id')} comparte numero pero "
|
||||
f"el nombre diverge. Skip para revision manual."
|
||||
)
|
||||
item["status"] = "skipped_phone_collision"
|
||||
item["actions"].append({
|
||||
"action": "skip_phone_collision_in_branch",
|
||||
"branch_contact_id": collision.get("id"),
|
||||
"branch_location_id": expected_loc,
|
||||
"branch_name": expected_name,
|
||||
})
|
||||
summary.setdefault("phone_collisions_unresolved", 0)
|
||||
summary["phone_collisions_unresolved"] += 1
|
||||
if run_id:
|
||||
try:
|
||||
script_audit.record_change(
|
||||
run_id, expected_loc, "contact", brand_contact_id,
|
||||
"", "skipped_phone_collision", None,
|
||||
{
|
||||
"source_brand_contact_id": brand_contact_id,
|
||||
"colliding_branch_contact_id": collision.get("id"),
|
||||
"phone": brand_contact.get("phone"),
|
||||
},
|
||||
)
|
||||
except Exception as audit_exc:
|
||||
log(f" WARN: no se pudo registrar la colision en script_audit: {audit_exc}")
|
||||
results.append(item)
|
||||
continue
|
||||
if match:
|
||||
log(f" YA EXISTE en sucursal destino {expected_name} por {strategy}: {match['id']}. Skip.")
|
||||
item["status"] = "skipped_already_in_branch"
|
||||
item["actions"].append({
|
||||
"action": "skip_already_in_branch",
|
||||
"strategy": strategy,
|
||||
"branch_contact_id": match["id"],
|
||||
})
|
||||
summary["skipped_already_in_branch"] += 1
|
||||
results.append(item)
|
||||
continue
|
||||
|
||||
# Double-check #2: cualquier OTRA sucursal (global)
|
||||
global_match, global_strategy, _global_collision = find_brand_contact(
|
||||
brand_contact, global_idx_phone, global_idx_email, global_idx_name
|
||||
)
|
||||
if global_match and global_match.get("_loc") and global_match["_loc"] != expected_loc:
|
||||
other_loc = global_match["_loc"]
|
||||
other_name = global_match.get("_loc_name") or other_loc
|
||||
log(f" YA EXISTE en OTRA sucursal: {other_name} ({other_loc}) por {global_strategy}: {global_match['id']}. Skip.")
|
||||
item["status"] = "skipped_in_other_branch"
|
||||
item["actions"].append({
|
||||
"action": "skip_in_other_branch",
|
||||
"strategy": global_strategy,
|
||||
"branch_contact_id": global_match["id"],
|
||||
"located_in_location_id": other_loc,
|
||||
"located_in_branch_name": other_name,
|
||||
})
|
||||
summary["skipped_in_other_branch"] += 1
|
||||
results.append(item)
|
||||
continue
|
||||
|
||||
# Construir payload con mapping de CFs Marca -> Sucursal destino
|
||||
if expected_loc not in branch_schema_cache:
|
||||
branch_schema_cache[expected_loc] = load_schemas_name_to_id(conn, expected_loc, "contact")
|
||||
dst_schema = branch_schema_cache[expected_loc]
|
||||
if not dst_schema:
|
||||
log(f" WARN: schema de contactos vacio para {expected_name}. Los custom fields no se mapearan.")
|
||||
|
||||
payload = build_contact_payload(
|
||||
brand_contact,
|
||||
brand_schema_id_to_name,
|
||||
dst_schema,
|
||||
target_location_id=expected_loc,
|
||||
)
|
||||
log(f" Plan: CREAR en sucursal {expected_name} (cf_count={len(payload.get('customFields', []))})")
|
||||
item["actions"].append({
|
||||
"action": "create_contact_in_branch",
|
||||
"target_location_id": expected_loc,
|
||||
"target_branch_name": expected_name,
|
||||
"payload_preview": _preview_payload(payload),
|
||||
})
|
||||
item["status"] = "created" # tentativo; si apply falla, se cambia a error
|
||||
results.append(item)
|
||||
|
||||
if dry_run:
|
||||
summary["contacts_created"] += 1
|
||||
else:
|
||||
# Encolar para fase 2 (paralela)
|
||||
creates_planned.append({
|
||||
"item": item,
|
||||
"payload": payload,
|
||||
"target_token": target_token,
|
||||
"expected_loc": expected_loc,
|
||||
"expected_name": expected_name,
|
||||
"brand_contact": brand_contact,
|
||||
"brand_contact_id": brand_contact_id,
|
||||
"bdata": bdata,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
summary["errors"] += 1
|
||||
item["status"] = "error"
|
||||
item["error"] = str(e)
|
||||
log(f" ERROR: {e}")
|
||||
if item not in results:
|
||||
results.append(item)
|
||||
|
||||
# ---- Fase 2: paralelizar creates ----
|
||||
if not dry_run and creates_planned:
|
||||
workers = min(max_parallel or len(creates_planned), len(creates_planned), 20)
|
||||
log(f"\nAplicando {len(creates_planned)} creates en paralelo (max_workers={workers})...")
|
||||
|
||||
def _do_create(plan):
|
||||
# Solo la llamada HTTP. NO toca SQLite ni indices en memoria.
|
||||
return sync_engine.ghl_client.create_contact(plan["target_token"], plan["payload"])
|
||||
|
||||
t0 = datetime.now()
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
future_to_plan = {executor.submit(_do_create, plan): plan for plan in creates_planned}
|
||||
for future in as_completed(future_to_plan):
|
||||
plan = future_to_plan[future]
|
||||
item = plan["item"]
|
||||
try:
|
||||
res = future.result()
|
||||
new_contact_id = (res.get("contact") or {}).get("id") or res.get("id")
|
||||
if not new_contact_id:
|
||||
raise RuntimeError(f"GHL no devolvio id de contacto creado. Respuesta: {res}")
|
||||
summary["contacts_created"] += 1
|
||||
item["actions"][-1]["result"] = {"branch_contact_id": new_contact_id}
|
||||
tlog(f" [OK] {item['name']} -> {plan['expected_name']}: {new_contact_id}")
|
||||
|
||||
# Upsert SQLite e indices en thread principal (post-future).
|
||||
try:
|
||||
upsert_contact_in_db(conn, new_contact_id, plan["payload"], plan["expected_loc"])
|
||||
except Exception as db_exc:
|
||||
tlog(f" WARN: no se pudo upsert contacto en SQLite: {db_exc}")
|
||||
# Refresh autoritativo desde Bucéfalo.
|
||||
try:
|
||||
ref = sync_engine.refresh_contact_in_db(plan["target_token"], new_contact_id, plan["expected_loc"])
|
||||
if not ref.get("ok"):
|
||||
tlog(f" WARN: refresh_contact_in_db fallo: {ref.get('error')}")
|
||||
summary.setdefault("local_refresh_errors", 0)
|
||||
summary["local_refresh_errors"] += 1
|
||||
except Exception as ref_exc:
|
||||
tlog(f" WARN: refresh_contact_in_db excepcion: {ref_exc}")
|
||||
|
||||
# Indexar en memoria para que duplicados intra-batch se detecten.
|
||||
new_c = {
|
||||
"id": new_contact_id,
|
||||
"first_name": plan["brand_contact"].get("first_name"),
|
||||
"last_name": plan["brand_contact"].get("last_name"),
|
||||
"phone": plan["brand_contact"].get("phone"),
|
||||
"email": plan["brand_contact"].get("email"),
|
||||
"_loc": plan["expected_loc"],
|
||||
"_loc_name": plan["expected_name"],
|
||||
}
|
||||
p = normalize_phone(new_c.get("phone"))
|
||||
e = normalize_email(new_c.get("email"))
|
||||
n = normalize_name(new_c.get("first_name"), new_c.get("last_name"))
|
||||
if p:
|
||||
plan["bdata"]["idx_phone"].setdefault(p, new_c)
|
||||
global_idx_phone.setdefault(p, new_c)
|
||||
if e:
|
||||
plan["bdata"]["idx_email"].setdefault(e, new_c)
|
||||
global_idx_email.setdefault(e, new_c)
|
||||
if n and not p and not e:
|
||||
plan["bdata"]["idx_name"].setdefault(n, new_c)
|
||||
global_idx_name.setdefault(n, new_c)
|
||||
|
||||
if run_id:
|
||||
cid = script_audit.record_change(
|
||||
run_id, plan["expected_loc"], "contact", new_contact_id,
|
||||
"", "created", None,
|
||||
{"phone": new_c["phone"], "email": new_c["email"],
|
||||
"name": f"{new_c['first_name']} {new_c['last_name']}".strip(),
|
||||
"source": "brand", "source_contact_id": plan["brand_contact_id"]},
|
||||
)
|
||||
if cid:
|
||||
script_audit.mark_change(cid, "applied")
|
||||
except Exception as e:
|
||||
summary["errors"] += 1
|
||||
item["status"] = "error"
|
||||
item["error"] = str(e)
|
||||
tlog(f" [ERROR] {item['name']} -> {plan['expected_name']}: {e}")
|
||||
elapsed = (datetime.now() - t0).total_seconds()
|
||||
log(f"Creates paralelos terminados en {elapsed:.1f}s ({len(creates_planned)} requests).")
|
||||
|
||||
# ---- Resumen ----
|
||||
log(f"\n=== RESUMEN ===")
|
||||
log(f" Candidatos : {summary['candidates']}")
|
||||
log(f" Contactos {'a crear' if dry_run else 'creados'} : {summary['contacts_created']}")
|
||||
log(f" Ya existian en destino : {summary['skipped_already_in_branch']}")
|
||||
log(f" Ya existian en OTRA suc : {summary['skipped_in_other_branch']}")
|
||||
log(f" Skip sin TIENDA : {summary['skipped_no_tienda']}")
|
||||
log(f" Skip TIENDA desconocida : {summary['skipped_unknown_tienda']}")
|
||||
if summary.get("phone_collisions_unresolved"):
|
||||
log(f" Colisiones telefono sin match (revision manual): {summary['phone_collisions_unresolved']}")
|
||||
log(f" Errores : {summary['errors']}")
|
||||
|
||||
return {
|
||||
"dry_run": dry_run,
|
||||
"summary": summary,
|
||||
"items": results,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _empty_summary():
|
||||
return {
|
||||
"candidates": 0,
|
||||
"contacts_created": 0,
|
||||
"skipped_already_in_branch": 0,
|
||||
"skipped_in_other_branch": 0,
|
||||
"skipped_no_tienda": 0,
|
||||
"skipped_unknown_tienda": 0,
|
||||
"phone_collisions_unresolved": 0,
|
||||
"errors": 0,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--apply", action="store_true", help="Ejecuta las escrituras en GHL. Default dry-run.")
|
||||
parser.add_argument("--yes", action="store_true", help="Skip confirmacion interactiva.")
|
||||
parser.add_argument("--only-contact", action="append", default=[], help="Procesa solo el contact_id dado (repetible).")
|
||||
parser.add_argument("--run-id", type=str, default=None, help="Id de script_audit. Solo aplica con --apply.")
|
||||
parser.add_argument("--json", action="store_true", help="Imprime el resultado como JSON.")
|
||||
args = parser.parse_args()
|
||||
|
||||
dry_run = not args.apply
|
||||
if not dry_run and not args.yes:
|
||||
confirm = input("Esto escribira en GHL. Continuar? (y/N): ").strip().lower()
|
||||
if confirm not in ("y", "yes", "s", "si", "sí"):
|
||||
print("Cancelado.")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
result = run_sync(
|
||||
contact_ids=args.only_contact or None,
|
||||
dry_run=dry_run,
|
||||
log=safe_print,
|
||||
run_id=args.run_id,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
safe_print(f"ERROR: {e}")
|
||||
sys.exit(2)
|
||||
except RuntimeError as e:
|
||||
safe_print(f"ERROR: {e}")
|
||||
sys.exit(3)
|
||||
|
||||
if args.json:
|
||||
safe_print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user