File size: 6,916 Bytes
fb0ec21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
04e663e
 
 
fb0ec21
64beda3
 
 
 
 
 
fb0ec21
 
 
 
 
 
 
 
 
 
 
04e663e
 
fb0ec21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
"""Apify enrichment client β€” runs the owner's own actors and fetches results.

Actors (defaults are the account's own published actors, configurable via env):
  - sitcod3.lab/fdn-google-map-master-scraper   (Google Maps β†’ companies)
  - sitcod3.lab/linkedin-company-email-scraper  (LinkedIn β†’ employees + emails)

Both sync (run-sync-get-dataset-items, capped) and async (run + poll) modes
are supported. Output items are schema-flexible: the normalizer probes common
Google Maps / LinkedIn export field names case-insensitively.
"""
import asyncio
import logging
import time
from typing import Any, Dict, List, Optional

import httpx

from ..config import settings

logger = logging.getLogger("abm.apify")

API = "https://api.apify.com/v2"


class ApifyClient:
    def __init__(self) -> None:
        self._http: Optional[httpx.AsyncClient] = None

    @property
    def configured(self) -> bool:
        return settings.apify_configured

    def client(self) -> httpx.AsyncClient:
        if self._http is None:
            self._http = httpx.AsyncClient(
                base_url=API, params={"token": settings.apify_token},
                timeout=httpx.Timeout(60.0),
            )
        return self._http

    async def close(self) -> None:
        if self._http is not None:
            await self._http.aclose()
            self._http = None

    async def ping(self) -> bool:
        if not self.configured:
            return False
        try:
            res = await self.client().get("/users/me")
            return res.status_code == 200
        except Exception:
            return False

    async def run_sync(self, actor: str, run_input: dict, timeout_s: Optional[int] = None) -> List[dict]:
        """Run an actor and collect its default dataset items (capped wait)."""
        deadline = timeout_s or settings.apify_run_timeout_s
        # Apify actor references use "username~actorName" (tilde), not "/".
        actor_ref = actor.replace("/", "~", 1) if "~" not in actor else actor
        url = f"/acts/{actor_ref}/run-sync-get-dataset-items"
        params = {"timeout": int(deadline * 1000)}
        # run-sync holds the HTTP connection for the WHOLE actor runtime
        # (minutes of Playwright scraping) β€” the 60s client default times out.
        res = await self.client().post(
            url, params=params, json=run_input,
            timeout=httpx.Timeout(30.0, read=deadline + 60.0),
        )
        if res.status_code in (402, 403):
            raise RuntimeError(f"Apify rejected the run (HTTP {res.status_code}): {res.text[:200]}")
        res.raise_for_status()
        try:
            items = res.json()
            return items if isinstance(items, list) else []
        except Exception:
            return []

    async def run_async(self, actor: str, run_input: dict) -> Dict[str, str]:
        """Start an actor run; returns {run_id, dataset_id} for polling."""
        actor_ref = actor.replace("/", "~", 1) if "~" not in actor else actor
        res = await self.client().post(f"/acts/{actor_ref}/runs", json=run_input)
        res.raise_for_status()
        data = res.json().get("data", {})
        return {"run_id": data.get("id"), "dataset_id": data.get("defaultDatasetId")}

    async def wait_run(self, run_id: str, poll_s: int = 10, timeout_s: Optional[int] = None) -> str:
        deadline = time.monotonic() + (timeout_s or settings.apify_run_timeout_s)
        while time.monotonic() < deadline:
            res = await self.client().get(f"/actor-runs/{run_id}")
            res.raise_for_status()
            data = res.json().get("data", {})
            status = data.get("status")
            if status in ("SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"):
                return status
            await asyncio.sleep(poll_s)
        return "TIMED-OUT"

    async def dataset_items(self, dataset_id: str, limit: int = 5000) -> List[dict]:
        res = await self.client().get(f"/datasets/{dataset_id}/items", params={"limit": limit})
        res.raise_for_status()
        items = res.json()
        return items if isinstance(items, list) else []


apify_client = ApifyClient()


# ── normalization: raw actor items β†’ ABM records ─────────────────────────────

def _pick(item: dict, *keys: str) -> Any:
    low = {k.lower(): v for k, v in item.items() if isinstance(k, str)}
    for k in keys:
        if k in low and low[k]:
            return low[k]
    return None


def normalize_account(raw: dict) -> Optional[dict]:
    """Map a Google Maps-ish item to an ABM account (schema-flexible)."""
    if not isinstance(raw, dict):
        return None
    name = _pick(raw, "name", "title", "companyname", "company", "searchstring")
    if not name:
        return None
    website = _pick(raw, "website", "websiteurl", "url", "site", "domain")
    domain = _pick(raw, "domain", "website_domain")
    if not domain and website:
        domain = str(website).replace("https://", "").replace("http://", "").split("/")[0]
    if isinstance(domain, str):
        domain = domain.removeprefix("www.").lower() or None
    return {
        "name": str(name)[:200],
        "domain": domain,
        "website": website,
        "phone": _pick(raw, "phone", "phonenumber", "telephone"),
        "industry": _pick(raw, "category", "maincategory", "categories", "industry", "subcategories"),
        "city": _pick(raw, "city", "locality"),
        "hq_country": _pick(raw, "country", "countrycode"),
        "address": _pick(raw, "address", "fulladdress", "location", "street", "addressline"),
        "employee_count": _pick(raw, "employeecount", "total_employees", "employees"),
        "rating": _pick(raw, "rating", "totalScore", "reviews", "reviewCount"),
        "description": _pick(raw, "description", "about", "snippet"),
        "source": "google_maps",
    }


def normalize_contact(raw: dict, account_id_hint: Optional[str] = None) -> Optional[dict]:
    """Map a LinkedIn-ish item to an ABM contact."""
    if not isinstance(raw, dict):
        return None
    email = _pick(raw, "email", "emailaddress", "mail", "business_email")
    full_name = _pick(raw, "fullname", "name", "full_name", " firstname") or \
        " ".join(x for x in [_pick(raw, "firstname", "first_name"), _pick(raw, "lastname", "last_name")] if x)
    if not email and not full_name:
        return None
    return {
        "email": str(email).strip().lower() if email else None,
        "full_name": str(full_name).strip()[:160] if full_name else None,
        "title": _pick(raw, "title", "position", "jobtitle", "role", "headline"),
        "linkedin_url": _pick(raw, "linkedinurl", "linkedin_url", "profile", "url"),
        "phone": _pick(raw, "phone", "phonenumber"),
        "company": _pick(raw, "company", "companyname", "organization", "current_company"),
        "source": "linkedin",
    }