← back to Zendesk Chat Analyzer

pull.py

154 lines

#!/usr/bin/env python3
"""Pull Zendesk Chat history via the REST API and emit public/data.json.
Token is read server-side from secrets-manager/.env and NEVER written to output."""
import json, os, re, urllib.request, urllib.parse, sys, time

ENV = os.path.expanduser("~/Projects/secrets-manager/.env")
OUT = os.path.join(os.path.dirname(__file__), "public", "data.json")
BASE = "https://www.zopim.com/api/v2/incremental/chats"
START = int(os.environ.get("START", "1760000000"))   # wide window to capture full history
MAX_PAGES = int(os.environ.get("MAX_PAGES", "80"))

# Theme classifier — regex per theme, matched against visitor text
THEMES = {
    "discontinued/availability": r"discontinu|no longer|out of (stock|production)|still (available|make|carry|sell|produc|in production)|been discontinued|still (get|order|sell)|hard to find|can.?t find|in stock|backorder|availab",
    "order status/shipping": r"where.?s my order|trackin|has.?n.?t (ship|arriv|come)|not (yet )?(received|arrived|delivered)|order status|shipping (info|status|update)|when will .* (ship|arrive|deliver)|still waiting|lead time|how long",
    "samples": r"\bsample|swatch|memo\b|cutting|piece of",
    "trade/pro account": r"trade account|designer discount|to the trade|pro(fessional)? account|interior designer|net price|trade price|resale|reseller",
    "pricing/quote": r"how much|price|cost per|quote|\$|per (roll|yard|yd)|pricing|afford",
    "installation/how-to": r"install|how (do|to) (i )?(apply|hang|paste)|paste the wall|square (feet|footage)|how many rolls|coverage|repeat|match",
    "complaint/issue": r"wrong|damag|defect|broken|torn|missing|refund|return|cancel|terrible|awful|disappoint|bad lot|no (one|response|reply|answer)|rude|charged twice",
}
# Vendor/brand mentions (wallcovering + fabric houses DW carries / gets asked about)
BRANDS = ["Kravet","Thibaut","Schumacher","Lee Jofa","Cole & Son","Phillip Jeffries","Phillipe Romano",
          "Waverly","Brunschwig","Clarke & Clarke","Romo","Scalamandre","Osborne","Zoffany","Designers Guild",
          "Ralph Lauren","York","Hollywood","Avant Garde","GP & J Baker","Groundworks","Pierre Frey","Arte",
          "Maya Romanoff","Koroseal","Wolf Gordon","Gaston","Farrow","Sanderson","Morris","Rebel Walls","Astek"]


def token():
    if os.environ.get("ZENDESK_CHAT_ACCESS_TOKEN"):
        return os.environ["ZENDESK_CHAT_ACCESS_TOKEN"].strip()
    try:
        for line in open(ENV):
            if line.startswith("ZENDESK_CHAT_ACCESS_TOKEN="):
                return line.split("=", 1)[1].strip().strip('"').strip("'")
    except Exception:
        pass
    raise SystemExit("no token")


def fetch(url, tok):
    req = urllib.request.Request(url, headers={"Authorization": "Bearer " + tok})
    with urllib.request.urlopen(req, timeout=45) as r:
        return json.load(r)


def clean_title(t):
    if not t:
        return ""
    # strip the boilerplate site suffix from product page titles
    t = re.split(r"[–\-|]\s*Designer Wallcoverings", t)[0]
    t = re.sub(r"\s+", " ", t).strip()
    return t


def classify(text):
    t = (text or "").lower()
    return [name for name, rx in THEMES.items() if re.search(rx, t)]


def find_brands(text):
    t = (text or "").lower()
    return sorted({b for b in BRANDS if b.lower() in t})


NEG = r"wrong|damag|defect|broken|torn|missing|refund|return|cancel|terrible|awful|horrible|disappoint|frustrat|angry|upset|unhappy|not happy|problem|issue|never (received|got|arrived)|still waiting|ridiculous|unacceptable|complain|poor|bad (lot|quality|experience)|worst|rude|ignored|no (one|response|reply|answer)"
POS = r"love|great|beautiful|perfect|thank you|thanks|excellent|wonderful|happy|gorgeous|amazing|fantastic|pleased|appreciate|awesome|excited|can.?t wait|lovely|stunning"
URG = r"\basap\b|urgent|immediately|right away|deadline|need (it |them )?by|running out|time.?sensitive|today|tomorrow|rush|as soon as|quickly|expedite|in a hurry|last minute"


def sentiment(text):
    t = (text or "").lower()
    if not t.strip():
        return ""
    neg = len(re.findall(NEG, t)); pos = len(re.findall(POS, t))
    if neg > pos:
        return "negative"
    if pos > neg:
        return "positive"
    return "neutral"


def urgency(text):
    return "high" if re.search(URG, (text or "").lower()) else "normal"


def main():
    tok = token()
    url = BASE + "?" + urllib.parse.urlencode({"fields": "chats(*)", "start_time": START})
    rows, pages = [], 0
    while url and pages < MAX_PAGES:
        d = fetch(url, tok)
        pages += 1
        chats = d.get("chats", [])
        for c in chats:
            s = c.get("session") or {}
            hist = c.get("history") or []
            msgs = [h.get("msg", "") for h in hist if isinstance(h, dict) and h.get("msg")]
            # visitor msgs: sender_type == 'visitor' (API lowercases it)
            vmsgs = [h.get("msg", "") for h in hist
                     if isinstance(h, dict) and h.get("msg") and str(h.get("sender_type", "")).lower() == "visitor"]
            if not vmsgs and c.get("comment"):
                vmsgs = [c.get("comment")]   # offline messages carry text in comment
            titles = [clean_title(w.get("title")) for w in (c.get("webpath") or []) if w.get("title")]
            vtext = " ".join(vmsgs)
            themes = classify(vtext)
            brands = find_brands(vtext + " " + " ".join(titles) + " " + " ".join(c.get("tags") or []))
            rows.append({
                "themes": themes,
                "brands": brands,
                "sentiment": sentiment(vtext),
                "urgency": urgency(vtext),
                "id": c.get("id"),
                "ts": c.get("timestamp"),
                "type": c.get("type"),
                "country_code": s.get("country_code") or "??",
                "country": s.get("country_name") or "Unknown",
                "city": s.get("city") or "",
                "region": s.get("region") or "",
                "platform": s.get("platform") or "",
                "browser": s.get("browser") or "",
                "agents": c.get("agent_names") or [],
                "duration": c.get("duration") or 0,
                "rating": c.get("rating") or "",
                "comment": c.get("comment") or "",
                "tags": c.get("tags") or [],
                "search_terms": c.get("referrer_search_terms") or "",
                "missed": bool(c.get("missed")),
                "proactive": bool(c.get("proactive")),
                "msg_count": len(msgs),
                "visitor_msgs": vmsgs if vmsgs else msgs,  # fallback to all if role missing
                "titles": titles,
                "ticket_id": c.get("zendesk_ticket_id") or "",
            })
        end = d.get("end_time")
        nxt = d.get("next_page")
        if not chats or not end:
            break
        url = nxt if nxt else BASE + "?" + urllib.parse.urlencode({"fields": "chats(*)", "start_time": end})

    payload = {
        "generated_at": int(time.time()),
        "count": len(rows),
        "pages": pages,
        "chats": rows,
    }
    os.makedirs(os.path.dirname(OUT), exist_ok=True)
    json.dump(payload, open(OUT, "w"), separators=(",", ":"))
    print("wrote %d chats (%d pages) -> %s" % (len(rows), pages, OUT))


if __name__ == "__main__":
    main()