Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
Gradio UI (robust gegen kurzzeitigen Browser-Verbindungsverlust):
|
| 3 |
- Startet einen Hintergrund-Job und speichert Status/Progress/Ergebnisse per job_id.
|
|
@@ -97,7 +100,6 @@ CURL_DATA_RE = re.compile(
|
|
| 97 |
re.DOTALL
|
| 98 |
)
|
| 99 |
HDR_XTOKEN_RE = re.compile(r"""-H\s+(?P<q>['"])X-Token-Id:\s*(?P<val>[^'"]+)(?P=q)""", re.IGNORECASE)
|
| 100 |
-
HDR_AUTH_RE = re.compile(r"""-H\s+(?P<q>['"])Authorization:\s*Bearer\s+(?P<val>[^'"]+)(?P=q)""", re.IGNORECASE)
|
| 101 |
|
| 102 |
def parse_curl(curl_text: str) -> Tuple[str, Dict[str, Any]]:
|
| 103 |
"""
|
|
@@ -110,7 +112,6 @@ def parse_curl(curl_text: str) -> Tuple[str, Dict[str, Any]]:
|
|
| 110 |
if m:
|
| 111 |
token_id = m.group("val").strip()
|
| 112 |
|
| 113 |
-
# Some users might pass token in env; if absent, try to look for it inline as 'X-Token-Id: ...'
|
| 114 |
if not token_id:
|
| 115 |
hdr_inline = re.search(r"X-Token-Id:\s*([a-zA-Z0-9\-\._]+)", curl_text, re.IGNORECASE)
|
| 116 |
if hdr_inline:
|
|
@@ -120,7 +121,6 @@ def parse_curl(curl_text: str) -> Tuple[str, Dict[str, Any]]:
|
|
| 120 |
if md:
|
| 121 |
body_str = md.group("body").strip()
|
| 122 |
else:
|
| 123 |
-
# fallback for -d '...'
|
| 124 |
md2 = re.search(r"-d\s+(?P<q>['\"])(?P<body>.*?)(?P=q)", curl_text, re.DOTALL)
|
| 125 |
if md2:
|
| 126 |
body_str = md2.group("body").strip()
|
|
@@ -128,16 +128,13 @@ def parse_curl(curl_text: str) -> Tuple[str, Dict[str, Any]]:
|
|
| 128 |
if not body_str:
|
| 129 |
raise ValueError("Konnte den JSON Body aus dem curl nicht finden (erwarte --data-raw '...').")
|
| 130 |
|
| 131 |
-
# The curl example uses single quotes around valid JSON → parse directly
|
| 132 |
try:
|
| 133 |
payload = json.loads(body_str)
|
| 134 |
-
except json.JSONDecodeError
|
| 135 |
-
# attempt to unescape smart single quotes or stray CRs
|
| 136 |
candidate = body_str.replace("\r\n", "\n").replace("\r", "\n")
|
| 137 |
payload = json.loads(candidate)
|
| 138 |
|
| 139 |
if not token_id:
|
| 140 |
-
# allow token in the payload via "X-Token-Id" or env var
|
| 141 |
env_token = os.getenv("X_TOKEN_ID", "").strip()
|
| 142 |
if env_token:
|
| 143 |
token_id = env_token
|
|
@@ -228,9 +225,11 @@ def lead_suggest(token_id: str, filters: Dict[str, Any], icp_text: str, exclude_
|
|
| 228 |
time.sleep(min(2.4, 0.6 * attempt))
|
| 229 |
|
| 230 |
def _ci_get(d: Dict[str, Any], key: str) -> Any:
|
|
|
|
|
|
|
| 231 |
if key in d and str(d[key]).strip() != "":
|
| 232 |
return d[key]
|
| 233 |
-
k = next((k for k in d.keys() if k.lower() == key.lower() and str(d[k]).strip() != ""), None)
|
| 234 |
return d.get(k) if k else None
|
| 235 |
|
| 236 |
def _normalize_draft_result(raw: Any) -> Dict[str, Any]:
|
|
@@ -261,7 +260,7 @@ def _normalize_draft_result(raw: Any) -> Dict[str, Any]:
|
|
| 261 |
if isinstance(src, dict):
|
| 262 |
for k in keys:
|
| 263 |
v = _ci_get(src, k)
|
| 264 |
-
if v is not None:
|
| 265 |
return v
|
| 266 |
return ""
|
| 267 |
|
|
@@ -318,7 +317,7 @@ def email_generate(token_id: str, variables: Dict[str, Any], items: List[Dict[st
|
|
| 318 |
attempt += 1
|
| 319 |
time.sleep(min(2.4, 0.6 * attempt))
|
| 320 |
|
| 321 |
-
# ======== DROP-IN:
|
| 322 |
|
| 323 |
ALLOWED_FIELDS = {
|
| 324 |
"firstname",
|
|
@@ -344,7 +343,6 @@ ALLOWED_FIELDS = {
|
|
| 344 |
|
| 345 |
def filter_wholix_contact_fields(obj: dict) -> dict:
|
| 346 |
"""
|
| 347 |
-
1:1 wie JS filterWholixContactFields:
|
| 348 |
- nur erlaubte Felder
|
| 349 |
- email immer getrimmt
|
| 350 |
- Strings getrimmt; leere Werte raus
|
|
@@ -366,24 +364,25 @@ def filter_wholix_contact_fields(obj: dict) -> dict:
|
|
| 366 |
|
| 367 |
def normalize_wholix_dropdown(val):
|
| 368 |
"""
|
| 369 |
-
1:1 wie JS normalizeWholixDropdown:
|
| 370 |
akzeptiert {keys,values}, Array oder String
|
| 371 |
→ normalisiert zu {keys:[...], values:[...]} oder None
|
| 372 |
"""
|
| 373 |
if isinstance(val, dict) and ("keys" in val or "values" in val):
|
| 374 |
-
ks = [str(x) for x in (val.get("keys") or []) if str(x).strip()]
|
| 375 |
-
vs = [str(x) for x in (val.get("values") or []) if str(x).strip()]
|
| 376 |
if not vs and ks:
|
| 377 |
vs = ks[:]
|
| 378 |
return {"keys": ks, "values": vs} if (ks or vs) else None
|
| 379 |
if isinstance(val, list):
|
| 380 |
-
ks = [str(x) for x in val if str(x).strip()]
|
| 381 |
return {"keys": ks, "values": ks} if ks else None
|
| 382 |
if isinstance(val, str) and val.strip():
|
| 383 |
s = val.strip()
|
| 384 |
return {"keys": [s], "values": [s]}
|
| 385 |
return None
|
| 386 |
|
|
|
|
|
|
|
| 387 |
def _first_non_empty(*vals):
|
| 388 |
for v in vals:
|
| 389 |
if isinstance(v, str) and v.strip():
|
|
@@ -399,14 +398,73 @@ def _from_ci(d: dict, *keys, default=None):
|
|
| 399 |
if k in d and str(d[k]).strip() != "":
|
| 400 |
return d[k]
|
| 401 |
for dk in d.keys():
|
| 402 |
-
if dk.lower() == k.lower() and str(d[dk]).strip() != "":
|
| 403 |
return d[dk]
|
| 404 |
return default
|
| 405 |
|
| 406 |
def _join_nonempty(parts, sep=" "):
|
| 407 |
return sep.join([str(x).strip() for x in parts if str(x or "").strip()])
|
| 408 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
p = (lead or {}).get("person") or {}
|
| 411 |
c = (lead or {}).get("company") or {}
|
| 412 |
m = (lead or {}).get("messages") or {}
|
|
@@ -419,40 +477,29 @@ def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
|
|
| 419 |
e.name = "ValidationError"
|
| 420 |
raise e
|
| 421 |
|
| 422 |
-
# --- Departments
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
departments_txt = ", ".join([str(x).strip() for x in depts_raw if str(x).strip()]) or None
|
| 426 |
-
depts_list_for_tags = [str(x).strip() for x in depts_raw if str(x).strip()]
|
| 427 |
-
else:
|
| 428 |
-
departments_txt = str(depts_raw).strip() if depts_raw not in (None, "", []) else None
|
| 429 |
-
depts_list_for_tags = [departments_txt] if departments_txt else []
|
| 430 |
|
| 431 |
# --- Company URL with wide fallbacks ---
|
| 432 |
company_url = _first_non_empty(
|
| 433 |
-
c.get("url"),
|
| 434 |
-
c.get("
|
| 435 |
-
|
| 436 |
-
c.get("homepage_url"),
|
| 437 |
-
c.get("website_url"),
|
| 438 |
-
c.get("url_normalized"),
|
| 439 |
-
ctx.get("url"),
|
| 440 |
-
(lead or {}).get("homepage_url"),
|
| 441 |
)
|
| 442 |
|
| 443 |
-
# --- Message
|
| 444 |
draft = draft or {}
|
| 445 |
draft_email = draft.get("email") if isinstance(draft, dict) else {}
|
| 446 |
-
# tolerate shapes like {'email': {'subject','body','to'}} + followups at root
|
| 447 |
msg_subject = _first_non_empty(
|
| 448 |
_from_ci(draft_email, "subject", "email_subject"),
|
| 449 |
_from_ci(draft, "subject", "email_subject", "Betreff"),
|
| 450 |
-
_from_ci(m, "message_mail_subject"),
|
| 451 |
)
|
| 452 |
msg_body = _first_non_empty(
|
| 453 |
_from_ci(draft_email, "body", "text", "content"),
|
| 454 |
_from_ci(draft, "body", "Text", "content", "email_body"),
|
| 455 |
-
_from_ci(m, "message_mail"),
|
| 456 |
)
|
| 457 |
followup1 = _first_non_empty(
|
| 458 |
_from_ci(draft, "followup1", "FollowUp1", "LinkedIn", "linkedin", "li"),
|
|
@@ -463,30 +510,35 @@ def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
|
|
| 463 |
_from_ci(m, "followup2", "message_followup2"),
|
| 464 |
)
|
| 465 |
|
| 466 |
-
# --- Address
|
| 467 |
street = _first_non_empty(
|
| 468 |
_join_nonempty([c.get("street_name"), c.get("street_number")]),
|
| 469 |
-
c.get("address"),
|
| 470 |
-
c.get("
|
| 471 |
-
c.get("address_line1"),
|
| 472 |
-
c.get("street"),
|
| 473 |
-
c.get("street_address"),
|
| 474 |
)
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
)
|
| 481 |
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
|
|
|
|
|
|
|
|
|
| 487 |
)
|
| 488 |
|
| 489 |
-
# --- exclude_hash
|
| 490 |
exclude_hash = _first_non_empty(
|
| 491 |
lead.get("exclude_hash"),
|
| 492 |
c.get("exclude_hash"),
|
|
@@ -494,11 +546,8 @@ def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
|
|
| 494 |
lead.get("combined_id"),
|
| 495 |
)
|
| 496 |
|
| 497 |
-
# ---
|
| 498 |
-
tag_items =
|
| 499 |
-
for d in depts_list_for_tags:
|
| 500 |
-
if d and d not in tag_items:
|
| 501 |
-
tag_items.append(d)
|
| 502 |
tags_dropdown = {"keys": tag_items, "values": tag_items} if tag_items else None
|
| 503 |
|
| 504 |
payload = {
|
|
@@ -506,19 +555,19 @@ def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
|
|
| 506 |
"firstname": p.get("first_name") or None,
|
| 507 |
"lastname": p.get("last_name") or None,
|
| 508 |
"email": email,
|
| 509 |
-
"adress": street or None,
|
| 510 |
"city": city or None,
|
| 511 |
"postcode": postcode or None,
|
| 512 |
"phonenumber": p.get("phone") or None,
|
| 513 |
-
"job_title":
|
| 514 |
-
"departments": departments_txt,
|
| 515 |
-
"linkedin_url":
|
| 516 |
|
| 517 |
# Company
|
| 518 |
-
"company_name": c.get("name")
|
| 519 |
"company_url": company_url or None,
|
| 520 |
|
| 521 |
-
# Message
|
| 522 |
"message_mail": msg_body or None,
|
| 523 |
"message_mail_subject": msg_subject or None,
|
| 524 |
"message_followup1": followup1 or None,
|
|
@@ -534,7 +583,7 @@ def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
|
|
| 534 |
|
| 535 |
normalized = filter_wholix_contact_fields(payload)
|
| 536 |
|
| 537 |
-
# Normalize dropdowns
|
| 538 |
if "status_field" in normalized:
|
| 539 |
fixed = normalize_wholix_dropdown(normalized["status_field"])
|
| 540 |
if fixed: normalized["status_field"] = fixed
|
|
@@ -547,10 +596,12 @@ def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
|
|
| 547 |
|
| 548 |
return normalized
|
| 549 |
|
| 550 |
-
|
| 551 |
def wholix_store_contact(token: str, record: dict, module: str = "Contacts") -> dict:
|
| 552 |
"""
|
| 553 |
-
|
|
|
|
|
|
|
|
|
|
| 554 |
"""
|
| 555 |
email = str((record or {}).get("email") or "").strip()
|
| 556 |
if not email:
|
|
@@ -623,8 +674,6 @@ def wholix_store_contact(token: str, record: dict, module: str = "Contacts") ->
|
|
| 623 |
stripped.pop("tags", None)
|
| 624 |
body3 = {"module": module, "action": "store", "data": [stripped]}
|
| 625 |
return req(url, method="POST", headers=headers, json_body=body3, timeout=60)
|
| 626 |
-
# ======== END DROP-IN =====================================================
|
| 627 |
-
|
| 628 |
|
| 629 |
# ====================== Background-Jobs (robust UI) =======================
|
| 630 |
|
|
@@ -671,12 +720,14 @@ def _job_finish(job_id: str, error: Optional[str] = None):
|
|
| 671 |
|
| 672 |
def run_pipeline_bg(job_id: str, curl_text: str, n_leads: int):
|
| 673 |
"""
|
| 674 |
-
|
|
|
|
|
|
|
|
|
|
| 675 |
"""
|
| 676 |
results: List[Dict[str, Any]] = []
|
| 677 |
|
| 678 |
def log(msg: str):
|
| 679 |
-
# kleine Bequemlichkeit, auto-emit mit aktuellem Fortschritt
|
| 680 |
st = JOBS.get(job_id, {})
|
| 681 |
prog = st.get("progress", 0.0)
|
| 682 |
_job_emit(job_id, msg=msg, progress=prog, rows=results)
|
|
@@ -700,7 +751,16 @@ def run_pipeline_bg(job_id: str, curl_text: str, n_leads: int):
|
|
| 700 |
signature = payload.get("Signatur") or ""
|
| 701 |
cta = payload.get("CTA") or ""
|
| 702 |
homepage_url = payload.get("icp_homepage_url") or ""
|
| 703 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 704 |
|
| 705 |
total_steps = max(1, n_leads) * 4 + 2 # login + excludes + (lead + email + store)*N
|
| 706 |
step = 0
|
|
@@ -756,7 +816,7 @@ def run_pipeline_bg(job_id: str, curl_text: str, n_leads: int):
|
|
| 756 |
_job_emit(job_id, msg=f"❌ Email-Generate-Fehler: {e}", progress=step/total_steps, rows=results)
|
| 757 |
continue
|
| 758 |
|
| 759 |
-
# 5) Store
|
| 760 |
step += 1
|
| 761 |
_job_emit(job_id, msg=" → Speichere Kontakt + Nachricht in Wholix …", progress=step/total_steps, rows=results)
|
| 762 |
try:
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
"""
|
| 5 |
Gradio UI (robust gegen kurzzeitigen Browser-Verbindungsverlust):
|
| 6 |
- Startet einen Hintergrund-Job und speichert Status/Progress/Ergebnisse per job_id.
|
|
|
|
| 100 |
re.DOTALL
|
| 101 |
)
|
| 102 |
HDR_XTOKEN_RE = re.compile(r"""-H\s+(?P<q>['"])X-Token-Id:\s*(?P<val>[^'"]+)(?P=q)""", re.IGNORECASE)
|
|
|
|
| 103 |
|
| 104 |
def parse_curl(curl_text: str) -> Tuple[str, Dict[str, Any]]:
|
| 105 |
"""
|
|
|
|
| 112 |
if m:
|
| 113 |
token_id = m.group("val").strip()
|
| 114 |
|
|
|
|
| 115 |
if not token_id:
|
| 116 |
hdr_inline = re.search(r"X-Token-Id:\s*([a-zA-Z0-9\-\._]+)", curl_text, re.IGNORECASE)
|
| 117 |
if hdr_inline:
|
|
|
|
| 121 |
if md:
|
| 122 |
body_str = md.group("body").strip()
|
| 123 |
else:
|
|
|
|
| 124 |
md2 = re.search(r"-d\s+(?P<q>['\"])(?P<body>.*?)(?P=q)", curl_text, re.DOTALL)
|
| 125 |
if md2:
|
| 126 |
body_str = md2.group("body").strip()
|
|
|
|
| 128 |
if not body_str:
|
| 129 |
raise ValueError("Konnte den JSON Body aus dem curl nicht finden (erwarte --data-raw '...').")
|
| 130 |
|
|
|
|
| 131 |
try:
|
| 132 |
payload = json.loads(body_str)
|
| 133 |
+
except json.JSONDecodeError:
|
|
|
|
| 134 |
candidate = body_str.replace("\r\n", "\n").replace("\r", "\n")
|
| 135 |
payload = json.loads(candidate)
|
| 136 |
|
| 137 |
if not token_id:
|
|
|
|
| 138 |
env_token = os.getenv("X_TOKEN_ID", "").strip()
|
| 139 |
if env_token:
|
| 140 |
token_id = env_token
|
|
|
|
| 225 |
time.sleep(min(2.4, 0.6 * attempt))
|
| 226 |
|
| 227 |
def _ci_get(d: Dict[str, Any], key: str) -> Any:
|
| 228 |
+
if not isinstance(d, dict):
|
| 229 |
+
return None
|
| 230 |
if key in d and str(d[key]).strip() != "":
|
| 231 |
return d[key]
|
| 232 |
+
k = next((k for k in d.keys() if isinstance(k, str) and k.lower() == key.lower() and str(d[k]).strip() != ""), None)
|
| 233 |
return d.get(k) if k else None
|
| 234 |
|
| 235 |
def _normalize_draft_result(raw: Any) -> Dict[str, Any]:
|
|
|
|
| 260 |
if isinstance(src, dict):
|
| 261 |
for k in keys:
|
| 262 |
v = _ci_get(src, k)
|
| 263 |
+
if v is not None and str(v).strip() != "":
|
| 264 |
return v
|
| 265 |
return ""
|
| 266 |
|
|
|
|
| 317 |
attempt += 1
|
| 318 |
time.sleep(min(2.4, 0.6 * attempt))
|
| 319 |
|
| 320 |
+
# ======== DROP-IN: Wholix-Mapping & Store (FIXED) ========================
|
| 321 |
|
| 322 |
ALLOWED_FIELDS = {
|
| 323 |
"firstname",
|
|
|
|
| 343 |
|
| 344 |
def filter_wholix_contact_fields(obj: dict) -> dict:
|
| 345 |
"""
|
|
|
|
| 346 |
- nur erlaubte Felder
|
| 347 |
- email immer getrimmt
|
| 348 |
- Strings getrimmt; leere Werte raus
|
|
|
|
| 364 |
|
| 365 |
def normalize_wholix_dropdown(val):
|
| 366 |
"""
|
|
|
|
| 367 |
akzeptiert {keys,values}, Array oder String
|
| 368 |
→ normalisiert zu {keys:[...], values:[...]} oder None
|
| 369 |
"""
|
| 370 |
if isinstance(val, dict) and ("keys" in val or "values" in val):
|
| 371 |
+
ks = [str(x).strip() for x in (val.get("keys") or []) if str(x).strip()]
|
| 372 |
+
vs = [str(x).strip() for x in (val.get("values") or []) if str(x).strip()]
|
| 373 |
if not vs and ks:
|
| 374 |
vs = ks[:]
|
| 375 |
return {"keys": ks, "values": vs} if (ks or vs) else None
|
| 376 |
if isinstance(val, list):
|
| 377 |
+
ks = [str(x).strip() for x in val if str(x).strip()]
|
| 378 |
return {"keys": ks, "values": ks} if ks else None
|
| 379 |
if isinstance(val, str) and val.strip():
|
| 380 |
s = val.strip()
|
| 381 |
return {"keys": [s], "values": [s]}
|
| 382 |
return None
|
| 383 |
|
| 384 |
+
# ---------- helpers for robust mapping ----------
|
| 385 |
+
|
| 386 |
def _first_non_empty(*vals):
|
| 387 |
for v in vals:
|
| 388 |
if isinstance(v, str) and v.strip():
|
|
|
|
| 398 |
if k in d and str(d[k]).strip() != "":
|
| 399 |
return d[k]
|
| 400 |
for dk in d.keys():
|
| 401 |
+
if isinstance(dk, str) and dk.lower() == k.lower() and str(d[dk]).strip() != "":
|
| 402 |
return d[dk]
|
| 403 |
return default
|
| 404 |
|
| 405 |
def _join_nonempty(parts, sep=" "):
|
| 406 |
return sep.join([str(x).strip() for x in parts if str(x or "").strip()])
|
| 407 |
|
| 408 |
+
def _parse_maybe_json_list(value):
|
| 409 |
+
"""
|
| 410 |
+
Accept list, JSON-string list, or bracketed string → return list[str]
|
| 411 |
+
"""
|
| 412 |
+
if value is None:
|
| 413 |
+
return []
|
| 414 |
+
if isinstance(value, list):
|
| 415 |
+
return [str(x).strip() for x in value if str(x).strip()]
|
| 416 |
+
s = str(value).strip()
|
| 417 |
+
if not s:
|
| 418 |
+
return []
|
| 419 |
+
# try JSON
|
| 420 |
+
if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
|
| 421 |
+
try:
|
| 422 |
+
arr = json.loads(s.replace("(", "[").replace(")", "]"))
|
| 423 |
+
if isinstance(arr, list):
|
| 424 |
+
return [str(x).strip() for x in arr if str(x).strip()]
|
| 425 |
+
except Exception:
|
| 426 |
+
# crude fallback
|
| 427 |
+
s2 = s.strip("[]()")
|
| 428 |
+
parts = [p.strip().strip("'").strip('"') for p in s2.split(",")]
|
| 429 |
+
return [p for p in parts if p]
|
| 430 |
+
# plain string, maybe delimited
|
| 431 |
+
if "," in s:
|
| 432 |
+
return [p.strip() for p in s.split(",") if p.strip()]
|
| 433 |
+
return [s]
|
| 434 |
+
|
| 435 |
+
def _normalize_tag_items(tag_text):
|
| 436 |
+
"""
|
| 437 |
+
Accepts: "AI", "[AI]", "AI, Sales", '["AI","Sales"]' → returns list[str]
|
| 438 |
+
"""
|
| 439 |
+
if isinstance(tag_text, list):
|
| 440 |
+
return [str(x).strip() for x in tag_text if str(x).strip()]
|
| 441 |
+
if tag_text is None:
|
| 442 |
+
return []
|
| 443 |
+
s = str(tag_text).strip()
|
| 444 |
+
if not s:
|
| 445 |
+
return []
|
| 446 |
+
try:
|
| 447 |
+
if s.startswith("[") and s.endswith("]"):
|
| 448 |
+
arr = json.loads(s)
|
| 449 |
+
if isinstance(arr, list):
|
| 450 |
+
return [str(x).strip() for x in arr if str(x).strip()]
|
| 451 |
+
except Exception:
|
| 452 |
+
pass
|
| 453 |
+
for sep in [",", "|", ";"]:
|
| 454 |
+
if sep in s:
|
| 455 |
+
return [p.strip() for p in s.split(sep) if p.strip()]
|
| 456 |
+
return [s.strip("[]")]
|
| 457 |
+
|
| 458 |
+
# ---------- the fixed mapper ----------
|
| 459 |
+
|
| 460 |
def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
|
| 461 |
+
"""
|
| 462 |
+
FIXED:
|
| 463 |
+
- nutzt jetzt 'draft' für message_mail/subject/followups
|
| 464 |
+
- bereinigt departments (keine ["..."] Reste)
|
| 465 |
+
- breite Fallbacks für job_title / linkedin_url / adress / city / postcode / company_url / exclude_hash
|
| 466 |
+
- Tags: nur Benutzer-Tags, KEINE Departments mehr
|
| 467 |
+
"""
|
| 468 |
p = (lead or {}).get("person") or {}
|
| 469 |
c = (lead or {}).get("company") or {}
|
| 470 |
m = (lead or {}).get("messages") or {}
|
|
|
|
| 477 |
e.name = "ValidationError"
|
| 478 |
raise e
|
| 479 |
|
| 480 |
+
# --- Departments (TEXT) — clean up list-like strings ---
|
| 481 |
+
depts_list = _parse_maybe_json_list(p.get("departments"))
|
| 482 |
+
departments_txt = ", ".join(depts_list) if depts_list else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
|
| 484 |
# --- Company URL with wide fallbacks ---
|
| 485 |
company_url = _first_non_empty(
|
| 486 |
+
c.get("url"), c.get("website"), c.get("domain"),
|
| 487 |
+
c.get("homepage_url"), c.get("website_url"), c.get("url_normalized"),
|
| 488 |
+
ctx.get("url"), (lead or {}).get("homepage_url"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
)
|
| 490 |
|
| 491 |
+
# --- Message from generated draft ---
|
| 492 |
draft = draft or {}
|
| 493 |
draft_email = draft.get("email") if isinstance(draft, dict) else {}
|
|
|
|
| 494 |
msg_subject = _first_non_empty(
|
| 495 |
_from_ci(draft_email, "subject", "email_subject"),
|
| 496 |
_from_ci(draft, "subject", "email_subject", "Betreff"),
|
| 497 |
+
_from_ci(m, "message_mail_subject"),
|
| 498 |
)
|
| 499 |
msg_body = _first_non_empty(
|
| 500 |
_from_ci(draft_email, "body", "text", "content"),
|
| 501 |
_from_ci(draft, "body", "Text", "content", "email_body"),
|
| 502 |
+
_from_ci(m, "message_mail"),
|
| 503 |
)
|
| 504 |
followup1 = _first_non_empty(
|
| 505 |
_from_ci(draft, "followup1", "FollowUp1", "LinkedIn", "linkedin", "li"),
|
|
|
|
| 510 |
_from_ci(m, "followup2", "message_followup2"),
|
| 511 |
)
|
| 512 |
|
| 513 |
+
# --- Address / City / Postcode fallbacks ---
|
| 514 |
street = _first_non_empty(
|
| 515 |
_join_nonempty([c.get("street_name"), c.get("street_number")]),
|
| 516 |
+
c.get("address"), c.get("address1"), c.get("address_line1"),
|
| 517 |
+
c.get("street"), c.get("street_address"),
|
|
|
|
|
|
|
|
|
|
| 518 |
)
|
| 519 |
+
city = _first_non_empty(c.get("city"), c.get("town"), c.get("locality"))
|
| 520 |
+
postcode = _first_non_empty(c.get("zip_code"), c.get("postal_code"), c.get("postcode"), c.get("zip"))
|
| 521 |
+
|
| 522 |
+
# --- Job title with fallbacks ---
|
| 523 |
+
job_title = _first_non_empty(
|
| 524 |
+
p.get("job_title"),
|
| 525 |
+
p.get("job_title_de_DE"),
|
| 526 |
+
p.get("title"),
|
| 527 |
+
p.get("position"),
|
| 528 |
+
_from_ci(p, "role"),
|
| 529 |
)
|
| 530 |
|
| 531 |
+
# --- LinkedIn URL with fallbacks ---
|
| 532 |
+
linkedin_url = _first_non_empty(
|
| 533 |
+
p.get("linkedin_url"),
|
| 534 |
+
p.get("linkedin"),
|
| 535 |
+
p.get("linkedin_profile"),
|
| 536 |
+
p.get("linkedinUrl"),
|
| 537 |
+
p.get("li"),
|
| 538 |
+
p.get("li_url"),
|
| 539 |
)
|
| 540 |
|
| 541 |
+
# --- exclude_hash fallbacks ---
|
| 542 |
exclude_hash = _first_non_empty(
|
| 543 |
lead.get("exclude_hash"),
|
| 544 |
c.get("exclude_hash"),
|
|
|
|
| 546 |
lead.get("combined_id"),
|
| 547 |
)
|
| 548 |
|
| 549 |
+
# --- Tags: ONLY what user provided (no departments mirroring) ---
|
| 550 |
+
tag_items = _normalize_tag_items(tag_text)
|
|
|
|
|
|
|
|
|
|
| 551 |
tags_dropdown = {"keys": tag_items, "values": tag_items} if tag_items else None
|
| 552 |
|
| 553 |
payload = {
|
|
|
|
| 555 |
"firstname": p.get("first_name") or None,
|
| 556 |
"lastname": p.get("last_name") or None,
|
| 557 |
"email": email,
|
| 558 |
+
"adress": street or None, # (sic)
|
| 559 |
"city": city or None,
|
| 560 |
"postcode": postcode or None,
|
| 561 |
"phonenumber": p.get("phone") or None,
|
| 562 |
+
"job_title": job_title or None,
|
| 563 |
+
"departments": departments_txt,
|
| 564 |
+
"linkedin_url": linkedin_url or None,
|
| 565 |
|
| 566 |
# Company
|
| 567 |
+
"company_name": _first_non_empty(c.get("name"), c.get("company_name")),
|
| 568 |
"company_url": company_url or None,
|
| 569 |
|
| 570 |
+
# Message
|
| 571 |
"message_mail": msg_body or None,
|
| 572 |
"message_mail_subject": msg_subject or None,
|
| 573 |
"message_followup1": followup1 or None,
|
|
|
|
| 583 |
|
| 584 |
normalized = filter_wholix_contact_fields(payload)
|
| 585 |
|
| 586 |
+
# Normalize dropdowns
|
| 587 |
if "status_field" in normalized:
|
| 588 |
fixed = normalize_wholix_dropdown(normalized["status_field"])
|
| 589 |
if fixed: normalized["status_field"] = fixed
|
|
|
|
| 596 |
|
| 597 |
return normalized
|
| 598 |
|
|
|
|
| 599 |
def wholix_store_contact(token: str, record: dict, module: str = "Contacts") -> dict:
|
| 600 |
"""
|
| 601 |
+
Wholix-Store mit Dropdown-Fallbacks:
|
| 602 |
+
1) Normales {keys,values}
|
| 603 |
+
2) Legacy {value}
|
| 604 |
+
3) Ohne problematische Felder
|
| 605 |
"""
|
| 606 |
email = str((record or {}).get("email") or "").strip()
|
| 607 |
if not email:
|
|
|
|
| 674 |
stripped.pop("tags", None)
|
| 675 |
body3 = {"module": module, "action": "store", "data": [stripped]}
|
| 676 |
return req(url, method="POST", headers=headers, json_body=body3, timeout=60)
|
|
|
|
|
|
|
| 677 |
|
| 678 |
# ====================== Background-Jobs (robust UI) =======================
|
| 679 |
|
|
|
|
| 720 |
|
| 721 |
def run_pipeline_bg(job_id: str, curl_text: str, n_leads: int):
|
| 722 |
"""
|
| 723 |
+
Background-Pipeline:
|
| 724 |
+
1) Wholix-Login
|
| 725 |
+
2) Excludes laden
|
| 726 |
+
3..N) Lead holen → Nachricht generieren → in Wholix speichern
|
| 727 |
"""
|
| 728 |
results: List[Dict[str, Any]] = []
|
| 729 |
|
| 730 |
def log(msg: str):
|
|
|
|
| 731 |
st = JOBS.get(job_id, {})
|
| 732 |
prog = st.get("progress", 0.0)
|
| 733 |
_job_emit(job_id, msg=msg, progress=prog, rows=results)
|
|
|
|
| 751 |
signature = payload.get("Signatur") or ""
|
| 752 |
cta = payload.get("CTA") or ""
|
| 753 |
homepage_url = payload.get("icp_homepage_url") or ""
|
| 754 |
+
# Tags: in beliebigen Formen erlauben (AI | [AI] | "AI, Sales" | ["AI","Sales"])
|
| 755 |
+
raw_tag = payload.get("Wholic_tag") or payload.get("Wholix_tag") or "AI"
|
| 756 |
+
tag_text = raw_tag # Mapper parst das robust
|
| 757 |
+
|
| 758 |
+
# optional limit aus Payload
|
| 759 |
+
try:
|
| 760 |
+
n_leads = int(payload.get("limit", n_leads))
|
| 761 |
+
except Exception:
|
| 762 |
+
pass
|
| 763 |
+
n_leads = max(1, n_leads)
|
| 764 |
|
| 765 |
total_steps = max(1, n_leads) * 4 + 2 # login + excludes + (lead + email + store)*N
|
| 766 |
step = 0
|
|
|
|
| 816 |
_job_emit(job_id, msg=f"❌ Email-Generate-Fehler: {e}", progress=step/total_steps, rows=results)
|
| 817 |
continue
|
| 818 |
|
| 819 |
+
# 5) Store (MAPPER FIXED)
|
| 820 |
step += 1
|
| 821 |
_job_emit(job_id, msg=" → Speichere Kontakt + Nachricht in Wholix …", progress=step/total_steps, rows=results)
|
| 822 |
try:
|