mz_chatbot / app /services /maps_source.py
Shurem's picture
added new functionality on lead finding
8f75b8b
Raw History Blame Contribute Delete
8.09 kB
"""
maps_source.py — bulk business discovery via Google Places API (New), used as
the cheap/bounded replacement for the LLM grounded-search discovery agent.
POST https://places.googleapis.com/v1/places:searchText
Field mask kept to a handful of fields (id, displayName, websiteUri,
formattedAddress, internationalPhoneNumber) to stay in the cheapest Places
API (New) pricing tier -- a wider mask (reviews, ratings, opening hours, ...)
moves the same call into a more expensive SKU.
Needs a Google Cloud project with billing enabled and "Places API (New)"
turned on; there is a monthly free-usage credit but the key will not
authenticate at all without billing enabled. Never raises -- returns ([], 0)
on any failure and lets the caller log, matching contactout.py / signalhire.py.
"""
from __future__ import annotations
import logging
import re
import time
from urllib.parse import urlparse
import requests
from app.services.secrets import get_google_maps_key
log = logging.getLogger("outreach.maps_source")
# User-reported, 2026-10-07: searching "find UK BASED campers and trailers company" (max_leads=6) returned
# exactly 1 result, already a duplicate -- 0 new leads. Root cause: reps type an AI-prompt-style instruction
# into this box (same habit as the AI Discovery tab, which genuinely IS a prompt to Gemini) -- the
# placeholder models a plain phrase ("e.g. custom golf cart dealers in Texas"), but Google's Places Text
# Search is a literal keyword match against business listings, not an LLM -- an imperative like "find X"
# measurably hurts its matching versus just searching "X". Stripped here, ONLY on the Google Maps path
# (never the AI Discovery prompt sent to Gemini, which should stay exactly as typed).
_IMPERATIVE_PREFIX_RE = re.compile(r"^\s*find(\s+me)?(\s+(a|an|the|top|\d+))?\s+", re.IGNORECASE)
def _clean_query(query: str) -> str:
cleaned = _IMPERATIVE_PREFIX_RE.sub("", query, count=1).strip()
return cleaned or query.strip()
BASE_URL = "https://places.googleapis.com/v1/places:searchText"
_TIMEOUT = 25
_FIELD_MASK = (
"places.id,places.displayName,places.websiteUri,"
"places.formattedAddress,places.internationalPhoneNumber,nextPageToken"
)
# Google's own ceiling on Text Search pagination (20 results/page, 3 pages).
_MAX_PAGES = 3
# A fresh nextPageToken needs a short delay before Google accepts it.
_PAGE_TOKEN_DELAY_S = 2
def available() -> bool:
return bool(get_google_maps_key())
def _search_page(query: str, page_size: int, page_token: str = "", region_code: str = "") -> dict:
"""One Text Search page. {} on any failure -- never raises."""
key = get_google_maps_key()
if not key or not query:
return {}
body: dict = {"textQuery": query, "pageSize": max(1, min(20, page_size))}
if page_token:
body["pageToken"] = page_token
if region_code:
# ISO 3166-1 alpha-2 -- biases (does not hard-filter) results toward
# this country, same code format leads.country already uses.
body["regionCode"] = region_code
headers = {
"Content-Type": "application/json",
"X-Goog-Api-Key": key,
"X-Goog-FieldMask": _FIELD_MASK,
}
try:
r = requests.post(BASE_URL, json=body, headers=headers, timeout=_TIMEOUT)
except requests.RequestException as exc:
log.warning("maps search failed: %s", exc)
return {}
if r.status_code != 200:
log.warning("maps search HTTP %s: %s", r.status_code, r.text[:200])
return {}
try:
return r.json() or {}
except ValueError:
return {}
def find_businesses(
query: str, max_results: int, region_code: str = "", require_website: bool = True
) -> tuple[list[dict], int]:
"""
Text-search Google Places for `query`, paging up to _MAX_PAGES times, and
return up to `max_results` businesses that have a website -- rows without
one cannot be scanned/qualified downstream, so they are dropped here and
counted instead of returned. Dedupes by place id.
require_website=False keeps a row with no website as long as it has a
listed phone (the caller saves it as a call-only lead -- see
qualify.save_call_only); a row with neither is still dropped and counted.
region_code (ISO 3166-1 alpha-2, optional) biases results toward that
country -- e.g. "US" so "custom golf cart dealers" doesn't also pull in
an unrelated UAE/Saudi result.
Returns (rows, dropped_no_website_count). Never raises. Row shape:
{"company_name", "company_website", "address", "phone", "place_id"}.
"""
query = _clean_query(query or "")
max_results = max(1, int(max_results or 1))
if not query or not available():
return [], 0
rows: list[dict] = []
seen_ids: set[str] = set()
dropped = 0
page_token = ""
for _ in range(_MAX_PAGES):
if page_token:
time.sleep(_PAGE_TOKEN_DELAY_S)
data = _search_page(query, max_results, page_token, region_code)
places = data.get("places")
if not isinstance(places, list):
break
for p in places:
place_id = (p.get("id") or "").strip()
if not place_id or place_id in seen_ids:
continue
seen_ids.add(place_id)
website = (p.get("websiteUri") or "").strip()
phone = (p.get("internationalPhoneNumber") or "").strip()
if not website and (require_website or not phone):
dropped += 1
continue
name = ((p.get("displayName") or {}).get("text") or "").strip()
if not name:
continue
rows.append({
"company_name": name,
"company_website": website,
"address": (p.get("formattedAddress") or "").strip(),
"phone": phone,
"place_id": place_id,
})
if len(rows) >= max_results:
return rows, dropped
page_token = (data.get("nextPageToken") or "").strip()
if not page_token:
break
return rows, dropped
def _host(url: str) -> str:
"""Lower-cased hostname without a leading "www." ("" if unparseable)."""
raw = (url or "").strip()
if not raw:
return ""
if "://" not in raw:
raw = "https://" + raw
host = (urlparse(raw).netloc or "").lower().split(":")[0]
return host[4:] if host.startswith("www.") else host
def _same_site(a: str, b: str) -> bool:
"""True when two URLs are on the same domain (or one is a subdomain of the other)."""
ha, hb = _host(a), _host(b)
if not ha or not hb:
return False
return ha == hb or ha.endswith("." + hb) or hb.endswith("." + ha)
def lookup_business(company_name: str, website: str, region_code: str = "") -> dict | None:
"""
Look up ONE already-known company on Google Places and return its row --
the same shape as find_businesses() rows -- but ONLY if a result's own
website is on the same domain as `website`. That domain match is the safety
check: a name search alone happily returns a different business with a
similar name. None when there is no such result. Never raises.
One Text Search request per call (billed like any other Text Search).
"""
name = (company_name or "").strip()
host = _host(website)
if not name or not host or not available():
return None
data = _search_page(f"{name} {host}", 5, "", region_code)
places = data.get("places")
if not isinstance(places, list):
return None
for p in places:
if not _same_site((p.get("websiteUri") or ""), website):
continue
return {
"company_name": ((p.get("displayName") or {}).get("text") or "").strip(),
"company_website": (p.get("websiteUri") or "").strip(),
"address": (p.get("formattedAddress") or "").strip(),
"phone": (p.get("internationalPhoneNumber") or "").strip(),
"place_id": (p.get("id") or "").strip(),
}
return None