1"""Business license leads scraper.
2
3Pulls business license and business tax registry records from official city,
4county and state open data portals (Socrata SODA API) and returns one clean,
5normalized row per licensed business.
6"""
7
8from __future__ import annotations
9
10import asyncio
11import json
12import re
13import urllib.parse
14from datetime import datetime, timezone
15from typing import Any
16
17import httpx
18from apify import Actor
19
20
21
22SOURCES = {
23 "chicago": {
24 "label": "Chicago, IL",
25 "domain": "data.cityofchicago.org",
26 "dataset": "r5kz-chrr",
27 "country": "US",
28 "defaultCity": "Chicago",
29 "defaultState": "IL",
30 "dateField": "date_issued",
31 "dateType": "ts",
32 "map": {
33 "businessName": "legal_name",
34 "tradeName": "doing_business_as_name",
35 "licenseNumber": "license_number",
36 "licenseType": "license_description",
37 "licenseStatus": "license_status",
38 "businessActivity": "business_activity",
39 "address": "address",
40 "city": "city",
41 "state": "state",
42 "zip": "zip_code",
43 "latitude": "latitude",
44 "longitude": "longitude",
45 "issueDate": "date_issued",
46 "expirationDate": "expiration_date",
47 },
48 },
49 "new-york-city": {
50 "label": "New York City, NY",
51 "domain": "data.cityofnewyork.us",
52 "dataset": "w7w3-xahh",
53 "country": "US",
54 "defaultCity": "New York",
55 "defaultState": "NY",
56 "dateField": "license_creation_date",
57 "dateType": "ts",
58 "addressParts": ["address_building", "address_street_name"],
59 "map": {
60 "businessName": "business_name",
61 "tradeName": "dba_trade_name",
62 "licenseNumber": "license_nbr",
63 "licenseType": "license_type",
64 "licenseStatus": "license_status",
65 "businessActivity": "business_category",
66 "phone": "contact_phone",
67 "city": "address_city",
68 "state": "address_state",
69 "zip": "address_zip",
70 "latitude": "latitude",
71 "longitude": "longitude",
72 "issueDate": "license_creation_date",
73 "expirationDate": "lic_expir_dd",
74 },
75 },
76 "seattle": {
77 "label": "Seattle, WA",
78 "domain": "data.seattle.gov",
79 "dataset": "wnbq-64tb",
80 "country": "US",
81 "defaultCity": "Seattle",
82 "defaultState": "WA",
83 "dateField": "license_start_date",
84 "dateType": "ymd",
85 "map": {
86 "businessName": "business_legal_name",
87 "tradeName": "trade_name",
88 "licenseNumber": "city_account_number",
89 "licenseType": "ownership_type",
90 "naicsCode": "naics_code",
91 "naicsDescription": "naics_description",
92 "phone": "business_phone",
93 "address": "street_address",
94 "city": "city",
95 "state": "state",
96 "zip": "zip",
97 "issueDate": "license_start_date",
98 },
99 },
100 "los-angeles": {
101 "label": "Los Angeles, CA",
102 "domain": "data.lacity.org",
103 "dataset": "6rrh-rzua",
104 "country": "US",
105 "dateField": "location_start_date",
106 "dateType": "ts",
107 "map": {
108 "businessName": "business_name",
109 "licenseNumber": "location_account",
110 "naicsCode": "naics",
111 "naicsDescription": "primary_naics_description",
112 "address": "street_address",
113 "city": "city",
114 "zip": "zip_code",
115 "issueDate": "location_start_date",
116 },
117 },
118 "san-francisco": {
119 "label": "San Francisco, CA",
120 "domain": "data.sfgov.org",
121 "dataset": "g8m3-pdis",
122 "country": "US",
123 "defaultCity": "San Francisco",
124 "defaultState": "CA",
125 "dateField": "location_start_date",
126 "dateType": "ts",
127 "map": {
128 "businessName": "ownership_name",
129 "tradeName": "dba_name",
130 "licenseNumber": "certificate_number",
131 "naicsCode": "self_reported_naics_code",
132 "address": "full_business_address",
133 "city": "city",
134 "state": "state",
135 "zip": "business_zip",
136 "issueDate": "location_start_date",
137 "expirationDate": "location_end_date",
138 },
139 },
140 "new-orleans": {
141 "label": "New Orleans, LA",
142 "domain": "data.nola.gov",
143 "dataset": "hjcd-grvu",
144 "country": "US",
145 "dateField": "businessstartdate",
146 "dateType": "ts",
147 "map": {
148 "businessName": "businessname",
149 "licenseNumber": "businesslicensenumber",
150 "businessActivity": "businesstype",
151 "ownerName": "ownername",
152 "phone": "phonenumber",
153 "address": "address",
154 "city": "city",
155 "state": "state",
156 "zip": "zip",
157 "latitude": "latitude",
158 "longitude": "longitude",
159 "issueDate": "businessstartdate",
160 },
161 },
162 "gainesville-fl": {
163 "label": "Gainesville, FL",
164 "domain": "data.cityofgainesville.org",
165 "dataset": "hk2b-em59",
166 "country": "US",
167 "defaultCity": "Gainesville",
168 "defaultState": "FL",
169 "dateField": "start_date",
170 "dateType": "ts",
171 "map": {
172 "businessName": "name",
173 "ownerName": "owner",
174 "businessActivity": "business_type",
175 "phone": "business_phone",
176 "email": "email",
177 "address": "physical_address",
178 "city": "physical_city",
179 "state": "physical_state",
180 "latitude": "latitude",
181 "longitude": "longitude",
182 "issueDate": "start_date",
183 },
184 },
185 "norfolk-va": {
186 "label": "Norfolk, VA",
187 "domain": "data.norfolk.gov",
188 "dataset": "dpi6-sct5",
189 "country": "US",
190 "defaultCity": "Norfolk",
191 "defaultState": "VA",
192 "dateField": "business_opened_date",
193 "dateType": "ts",
194 "map": {
195 "businessName": "trading_as_name",
196 "ownerName": "primary_owner",
197 "naicsCode": "naics",
198 "address": "location_address",
199 "latitude": "latitude",
200 "longitude": "longitude",
201 "issueDate": "business_opened_date",
202 },
203 },
204 "berkeley-ca": {
205 "label": "Berkeley, CA",
206 "domain": "data.cityofberkeley.info",
207 "dataset": "rwnf-bu3w",
208 "country": "US",
209 "defaultCity": "Berkeley",
210 "defaultState": "CA",
211 "dateField": None,
212 "map": {
213 "businessName": "b1_business_name",
214 "tradeName": "dba",
215 "licenseNumber": "recordid",
216 "licenseType": "b1_per_sub_type",
217 "businessActivity": "busdesc",
218 "naicsCode": "naics",
219 "address": "b1_full_address",
220 "city": "b1_situs_city",
221 "state": "b1_situs_state",
222 "zip": "b1_situs_zip",
223 "employeeCount": "employee_num",
224 },
225 },
226 "denver": {
227 "label": "Denver, CO",
228 "domain": "data.colorado.gov",
229 "dataset": "s9wt-dsfz",
230 "country": "US",
231 "defaultCity": "Denver",
232 "defaultState": "CO",
233 "dateField": None,
234 "map": {
235 "businessName": "entity_name",
236 "tradeName": "trade_name",
237 "licenseNumber": "bfn",
238 "licenseType": "license_type",
239 "licenseStatus": "license_status",
240 "businessActivity": "license_sub_type",
241 "expirationDate": "expiration_date",
242 },
243 },
244 "kansas-city-mo": {
245 "label": "Kansas City, MO",
246 "domain": "data.kcmo.org",
247 "dataset": "pnm4-68wg",
248 "country": "US",
249 "defaultCity": "Kansas City",
250 "defaultState": "MO",
251 "dateField": None,
252 "map": {
253 "businessName": "dba_name",
254 "businessActivity": "business_type",
255 "licenseNumber": "id",
256 "licenseStatus": "valid_license_for",
257 "address": "address",
258 "city": "city",
259 "state": "state",
260 "zip": "zipcode",
261 },
262 },
263 "marin-county-ca": {
264 "label": "Marin County, CA",
265 "domain": "data.marincounty.gov",
266 "dataset": "jfy7-shxv",
267 "country": "US",
268 "defaultState": "CA",
269 "dateField": "license_issue_date",
270 "dateType": "ts",
271 "map": {
272 "businessName": "active_business_name",
273 "businessActivity": "business_type",
274 "licenseNumber": "open_data_number",
275 "address": "address",
276 "city": "city_town",
277 "zip": "zipcode",
278 "issueDate": "license_issue_date",
279 },
280 },
281 "providence-ri": {
282 "label": "Providence, RI",
283 "domain": "data.providenceri.gov",
284 "dataset": "ui7z-kv69",
285 "country": "US",
286 "defaultCity": "Providence",
287 "defaultState": "RI",
288 "dateField": "permit_date",
289 "dateType": "ts",
290 "map": {
291 "businessName": "dba",
292 "licenseNumber": "bus_lic_id",
293 "licenseType": "primary_lic_desc",
294 "businessActivity": "primary_lic_purpose",
295 "address": "address",
296 "issueDate": "permit_date",
297 "expirationDate": "expiration_date",
298 },
299 },
300 "everett-wa": {
301 "label": "Everett, WA",
302 "domain": "data.everettwa.gov",
303 "dataset": "qvfa-2v26",
304 "country": "US",
305 "defaultCity": "Everett",
306 "defaultState": "WA",
307 "dateField": "issuedate",
308 "dateType": "ts",
309 "map": {
310 "businessName": "legalentityname",
311 "tradeName": "primarydba",
312 "licenseNumber": "registration",
313 "naicsCode": "naics",
314 "issueDate": "issuedate",
315 },
316 },
317 "delaware": {
318 "label": "Delaware (statewide)",
319 "domain": "data.delaware.gov",
320 "dataset": "5zy2-grhr",
321 "country": "US",
322 "defaultState": "DE",
323 "dateField": "current_license_valid_from",
324 "dateType": "ts",
325 "map": {
326 "businessName": "business_name",
327 "tradeName": "trade_name",
328 "licenseNumber": "license_number",
329 "businessActivity": "category",
330 "address": "address_1",
331 "city": "city",
332 "state": "state",
333 "zip": "zip",
334 "issueDate": "current_license_valid_from",
335 "expirationDate": "current_license_valid_to",
336 },
337 },
338 "calgary": {
339 "label": "Calgary, AB",
340 "domain": "data.calgary.ca",
341 "dataset": "vdjc-pybd",
342 "country": "CA",
343 "defaultCity": "Calgary",
344 "defaultState": "AB",
345 "dateField": "first_iss_dt",
346 "dateType": "ts",
347 "map": {
348 "businessName": "tradename",
349 "licenseNumber": "getbusid",
350 "licenseType": "licencetypes",
351 "licenseStatus": "jobstatusdesc",
352 "address": "address",
353 "issueDate": "first_iss_dt",
354 "expirationDate": "exp_dt",
355 },
356 },
357 "edmonton": {
358 "label": "Edmonton, AB",
359 "domain": "data.edmonton.ca",
360 "dataset": "qhi4-bdpu",
361 "country": "CA",
362 "defaultCity": "Edmonton",
363 "defaultState": "AB",
364 "dateField": "originalissuedate",
365 "dateType": "ts",
366 "map": {
367 "businessName": "business_name",
368 "licenseType": "licencetype",
369 "businessActivity": "business_licence_category",
370 "licenseNumber": "externalid",
371 "address": "business_address",
372 "issueDate": "originalissuedate",
373 "expirationDate": "expiry_date",
374 },
375 },
376 "winnipeg": {
377 "label": "Winnipeg, MB",
378 "domain": "data.winnipeg.ca",
379 "dataset": "d5k3-sfzx",
380 "country": "CA",
381 "defaultCity": "Winnipeg",
382 "defaultState": "MB",
383 "dateField": "issue_date",
384 "dateType": "ts",
385 "map": {
386 "businessName": "trade_name",
387 "licenseType": "folder_type",
388 "businessActivity": "folder_description",
389 "licenseStatus": "status",
390 "address": "address",
391 "issueDate": "issue_date",
392 "expirationDate": "expiry_date",
393 },
394 },
395}
396
397SOURCE_KEYS = list(SOURCES.keys())
398
399PAGE_SIZE = 1000
400MAX_RETRIES = 4
401REQUEST_TIMEOUT = 60.0
402USER_AGENT = "Mozilla/5.0 (compatible; ApifyBusinessLicenseLeads/1.0)"
403
404
405def clean(value: Any) -> str | None:
406 """Normalize a raw Socrata value into a trimmed string, or None."""
407 if value is None:
408 return None
409 if isinstance(value, bool):
410 return "true" if value else "false"
411 if isinstance(value, (int, float)):
412 return str(value)
413 if isinstance(value, dict):
414 human = value.get("human_address")
415 if human:
416 try:
417 parsed = json.loads(human)
418 joined = " ".join(
419 str(parsed.get(k, "")).strip()
420 for k in ("address", "city", "state", "zip")
421 ).strip()
422 return joined or None
423 except (ValueError, TypeError):
424 return None
425 return None
426 text = str(value).strip()
427 return text or None
428
429
430def normalize_phone(value: Any) -> str | None:
431 """Return a readable North American phone number where possible."""
432 raw = clean(value)
433 if not raw:
434 return None
435 digits = re.sub(r"\D", "", raw)
436 if not digits or set(digits) == {"0"}:
437 return None
438 if len(digits) == 11 and digits.startswith("1"):
439 digits = digits[1:]
440 if len(digits) == 10:
441 return f"({digits[0:3]}) {digits[3:6]}-{digits[6:]}"
442 return raw
443
444
445def normalize_date(value: Any) -> str | None:
446 """Return an ISO date (YYYY-MM-DD) from the several formats Socrata uses."""
447 raw = clean(value)
448 if not raw:
449 return None
450 if re.fullmatch(r"\d{8}", raw):
451 return f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]}"
452 match = re.match(r"(\d{4})-(\d{2})-(\d{2})", raw)
453 if match:
454 return f"{match.group(1)}-{match.group(2)}-{match.group(3)}"
455 for fmt in ("%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d"):
456 try:
457 return datetime.strptime(raw[:10], fmt).strftime("%Y-%m-%d")
458 except ValueError:
459 continue
460 return raw
461
462
463PLACEHOLDER_NAMES = {"new business", "vacant", "unknown", "n/a", "na", "none"}
464
465
466def is_usable(row: dict) -> bool:
467 """Drop rows that carry no identifiable business."""
468 for key in ("businessName", "tradeName"):
469 name = (row.get(key) or "").strip(" *-").strip()
470 if name and name.lower() not in PLACEHOLDER_NAMES:
471 return True
472 return False
473
474
475def extract_point(record: dict) -> tuple[str | None, str | None]:
476 """Pull latitude and longitude out of a Socrata point or location column."""
477 for key in ("location", "location_1", "point", "geolocation", "geocoded_point"):
478 value = record.get(key)
479 if isinstance(value, dict):
480 coords = value.get("coordinates")
481 if isinstance(coords, list) and len(coords) >= 2:
482 return str(coords[1]), str(coords[0])
483 lat = clean(value.get("latitude"))
484 lon = clean(value.get("longitude"))
485 if lat and lon:
486 return lat, lon
487 return None, None
488
489
490def build_where(config: dict, issued_since: str | None, newest_first: bool) -> str | None:
491 """Build a SoQL $where clause for the issue-date filter.
492
493 When sorting newest first the clause also drops records with an empty or
494 future-dated issue date, so the top of the dataset is real, already-issued
495 licenses instead of blanks and pre-registrations.
496 """
497 field = config.get("dateField")
498 if not field:
499 return None
500 date_type = config.get("dateType", "ts")
501 today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
502
503 def literal(day: str) -> str:
504 return day.replace("-", "") if date_type == "ymd" else f"{day}T23:59:59.999"
505
506 clauses: list[str] = []
507 if issued_since:
508 low = issued_since.replace("-", "") if date_type == "ymd" else f"{issued_since}T00:00:00.000"
509 clauses.append(f"{field} >= '{low}'")
510 if newest_first:
511 clauses.append(f"{field} IS NOT NULL")
512 clauses.append(f"{field} <= '{literal(today)}'")
513 return " AND ".join(clauses) if clauses else None
514
515
516def map_record(record: dict, source_key: str, config: dict, include_raw: bool) -> dict:
517 """Turn one portal-specific record into the shared output shape."""
518 field_map = config["map"]
519 row: dict[str, Any] = {
520 "source": source_key,
521 "sourceName": config["label"],
522 "country": config["country"],
523 "businessName": None,
524 "tradeName": None,
525 "licenseNumber": None,
526 "licenseType": None,
527 "licenseStatus": None,
528 "businessActivity": None,
529 "naicsCode": None,
530 "naicsDescription": None,
531 "ownerName": None,
532 "phone": None,
533 "email": None,
534 "employeeCount": None,
535 "address": None,
536 "city": None,
537 "state": None,
538 "zip": None,
539 "latitude": None,
540 "longitude": None,
541 "issueDate": None,
542 "expirationDate": None,
543 }
544
545 for target, column in field_map.items():
546 value = record.get(column)
547 if target == "phone":
548 row[target] = normalize_phone(value)
549 elif target in ("issueDate", "expirationDate"):
550 row[target] = normalize_date(value)
551 else:
552 row[target] = clean(value)
553
554 parts = config.get("addressParts")
555 if parts and not row.get("address"):
556 joined = " ".join(clean(record.get(p)) or "" for p in parts).strip()
557 row["address"] = joined or None
558
559 if not row.get("latitude") or not row.get("longitude"):
560 lat, lon = extract_point(record)
561 row["latitude"] = row.get("latitude") or lat
562 row["longitude"] = row.get("longitude") or lon
563
564 row["city"] = row.get("city") or config.get("defaultCity")
565 row["state"] = row.get("state") or config.get("defaultState")
566
567 if row.get("city"):
568 row["city"] = row["city"].title() if row["city"].isupper() else row["city"]
569
570 address_bits = [row.get("address"), row.get("city"), row.get("state"), row.get("zip")]
571 row["fullAddress"] = ", ".join(b for b in address_bits if b) or None
572 row["portalUrl"] = f"https://{config['domain']}/resource/{config['dataset']}"
573 row["scrapedAt"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
574
575 if include_raw:
576 row["raw"] = {k: v for k, v in record.items() if not k.startswith(":@")}
577
578 return row
579
580
581async def fetch_page(
582 client: httpx.AsyncClient,
583 config: dict,
584 offset: int,
585 limit: int,
586 search_term: str | None,
587 issued_since: str | None,
588 newest_first: bool,
589) -> list[dict]:
590 """Fetch one page from a Socrata dataset with retries and backoff."""
591 params: dict[str, Any] = {"$limit": limit, "$offset": offset}
592 if search_term:
593 params["$q"] = search_term
594 where = build_where(config, issued_since, newest_first)
595 if where:
596 params["$where"] = where
597 if newest_first and config.get("dateField"):
598 params["$order"] = f"{config['dateField']} DESC"
599
600 url = f"https://{config['domain']}/resource/{config['dataset']}.json"
601 query = urllib.parse.urlencode(params)
602
603 for attempt in range(MAX_RETRIES):
604 try:
605 response = await client.get(f"{url}?{query}")
606 if response.status_code == 200:
607 return response.json()
608 if response.status_code in (400, 404):
609 Actor.log.warning(
610 "%s returned %s, retrying without ordering: %s",
611 config["label"],
612 response.status_code,
613 response.text[:200],
614 )
615 params.pop("$order", None)
616 params.pop("$where", None)
617 query = urllib.parse.urlencode(params)
618 continue
619 if response.status_code in (429, 500, 502, 503, 504):
620 await asyncio.sleep(2 ** attempt)
621 continue
622 Actor.log.warning(
623 "%s returned HTTP %s", config["label"], response.status_code
624 )
625 return []
626 except (httpx.HTTPError, ValueError) as exc:
627 Actor.log.warning("%s request failed: %s", config["label"], exc)
628 await asyncio.sleep(2 ** attempt)
629 return []
630
631
632async def scrape_source(
633 client: httpx.AsyncClient,
634 source_key: str,
635 search_term: str | None,
636 issued_since: str | None,
637 newest_first: bool,
638 include_raw: bool,
639 limit_for_source: int,
640) -> list[dict]:
641 """Page through one portal until the per-source cap is reached."""
642 config = SOURCES[source_key]
643 collected: list[dict] = []
644 offset = 0
645
646 while len(collected) < limit_for_source:
647 page_size = min(PAGE_SIZE, limit_for_source - len(collected))
648 records = await fetch_page(
649 client, config, offset, page_size, search_term, issued_since, newest_first
650 )
651 if not records:
652 break
653 for record in records:
654 row = map_record(record, source_key, config, include_raw)
655 if is_usable(row):
656 collected.append(row)
657 if len(records) < page_size:
658 break
659 offset += len(records)
660 await asyncio.sleep(0.25)
661
662 Actor.log.info("%s: %s records", config["label"], len(collected))
663 return collected
664
665
666async def main() -> None:
667 async with Actor:
668 actor_input = await Actor.get_input() or {}
669
670 requested = actor_input.get("sources") or [
671 "chicago",
672 "new-york-city",
673 "seattle",
674 "los-angeles",
675 "san-francisco",
676 ]
677 sources = [s for s in requested if s in SOURCES]
678 unknown = [s for s in requested if s not in SOURCES]
679 if unknown:
680 Actor.log.warning("Ignoring unknown sources: %s", ", ".join(unknown))
681 if not sources:
682 Actor.log.warning("No valid sources given, falling back to all portals.")
683 sources = SOURCE_KEYS
684
685 search_term = (actor_input.get("searchTerm") or "").strip() or None
686 issued_since = (actor_input.get("issuedSince") or "").strip() or None
687 if issued_since and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", issued_since):
688 Actor.log.warning(
689 "issuedSince must look like YYYY-MM-DD, ignoring %r", issued_since
690 )
691 issued_since = None
692
693 newest_first = actor_input.get("sortNewestFirst", True)
694 include_raw = actor_input.get("includeRawRecord", False)
695 max_items = int(actor_input.get("maxItems") or 200)
696 per_source = int(
697 actor_input.get("maxItemsPerSource")
698 or max(1, -(-max_items // max(1, len(sources))))
699 )
700
701 Actor.log.info(
702 "Scraping %s portal(s): %s", len(sources), ", ".join(sources)
703 )
704
705 pushed = 0
706 headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
707 async with httpx.AsyncClient(
708 timeout=REQUEST_TIMEOUT, headers=headers, follow_redirects=True
709 ) as client:
710 for source_key in sources:
711 if pushed >= max_items:
712 break
713 remaining = max_items - pushed
714 rows = await scrape_source(
715 client,
716 source_key,
717 search_term,
718 issued_since,
719 newest_first,
720 include_raw,
721 min(per_source, remaining),
722 )
723 if rows:
724 await Actor.push_data(rows)
725 pushed += len(rows)
726
727 Actor.log.info("Done. %s business records pushed to the dataset.", pushed)
728 if pushed == 0:
729 Actor.log.warning(
730 "No records matched. Try a broader search term, an earlier "
731 "issuedSince date, or more source portals."
732 )