| """ |
| app.py β Google Maps leads dashboard (Streamlit @ HF Space) |
| |
| Reads scrape runs from an HF Dataset repo (the bridge). Each scrape run is a |
| separate file under runs/ β pick one run or merge them all. Can trigger a new |
| scrape on Kaggle (fire-and-forget). The table has an "explored" checkbox per |
| lead; progress is saved to the HF Dataset so it survives restarts. |
| |
| Configuration (env vars or Streamlit secrets): |
| HF_DATASET_REPO e.g. "username/gmaps-leads" (required) |
| HF_TOKEN HF WRITE token (data read + tracking save + Kaggle push) |
| KAGGLE_USERNAME kaggle username (optional: enables the trigger) |
| KAGGLE_KEY kaggle legacy API key (optional: enables the trigger) |
| """ |
|
|
| import json |
| import os |
|
|
| import folium |
| import pandas as pd |
| import plotly.express as px |
| import streamlit as st |
| from huggingface_hub import HfApi, hf_hub_download |
| from streamlit_folium import st_folium |
|
|
| |
| SURFACE = "#fcfcfb" |
| INK = "#0b0b0b" |
| INK_MUTED = "#898781" |
| GRID = "#e1e0d9" |
| BASELINE = "#c3c2b7" |
| SERIES = "#2a78d6" |
| SEQ_RAMP = ["#86b6ef", "#2a78d6", "#0d366b"] |
| FONT = 'system-ui, -apple-system, "Segoe UI", sans-serif' |
|
|
| TRACK_FILE = "tracking/explored.json" |
| DELETED_FILE = "tracking/deleted.json" |
|
|
| st.set_page_config(page_title="GMaps Leads", page_icon="πΊοΈ", layout="wide") |
|
|
|
|
| def conf(name: str, default: str = "") -> str: |
| val = os.environ.get(name) |
| if val: |
| return val |
| try: |
| return st.secrets[name] |
| except Exception: |
| return default |
|
|
|
|
| REPO_ID = conf("HF_DATASET_REPO") |
| HF_TOKEN = conf("HF_TOKEN") |
|
|
|
|
| def score(row: pd.Series) -> int: |
| s = 0 |
| if not row.get("website"): |
| s += 3 |
| if row["reviews_count"] >= 50 and row["rating"] < 4.0: |
| s += 2 |
| if row["reviews_count"] < 10: |
| s += 1 |
| return s |
|
|
|
|
| def lead_key(row) -> str: |
| return f"{row['name']}|{row.get('address', '')}" |
|
|
|
|
| @st.cache_data(ttl=600, show_spinner="Listing scrape runs...") |
| def list_runs(repo_id: str, token: str) -> list: |
| """All run CSVs in the dataset, newest first. Falls back to legacy leads.csv.""" |
| files = HfApi(token=token or None).list_repo_files(repo_id, repo_type="dataset") |
| runs = sorted((f for f in files if f.startswith("runs/") and f.endswith(".csv")), |
| reverse=True) |
| if not runs and "leads.csv" in files: |
| runs = ["leads.csv"] |
| return runs |
|
|
|
|
| @st.cache_data(ttl=600, show_spinner="Fetching data from the HF Dataset...") |
| def load_csv(repo_id: str, token: str, filename: str) -> pd.DataFrame: |
| path = hf_hub_download(repo_id=repo_id, filename=filename, |
| repo_type="dataset", token=token or None) |
| df = pd.read_csv(path) |
|
|
| df["reviews_count"] = ( |
| pd.to_numeric(df.get("reviews_count"), errors="coerce").fillna(0).astype(int) |
| ) |
| df["rating"] = pd.to_numeric(df.get("rating"), errors="coerce").fillna(0.0) |
| for col in ("website", "phone", "category", "address", "email"): |
| if col in df.columns: |
| df[col] = df[col].fillna("") |
| if "score" not in df.columns: |
| df["score"] = df.apply(score, axis=1) |
| df["lat"] = pd.to_numeric(df.get("lat"), errors="coerce") |
| df["lng"] = pd.to_numeric(df.get("lng"), errors="coerce") |
| return df |
|
|
|
|
| @st.cache_data(ttl=600) |
| def repo_last_updated(repo_id: str, token: str): |
| return HfApi(token=token or None).dataset_info(repo_id).last_modified |
|
|
|
|
| @st.cache_data(ttl=60) |
| def load_tracking(repo_id: str, token: str) -> dict: |
| """Explored-checkbox state, persisted in the dataset repo.""" |
| try: |
| path = hf_hub_download(repo_id=repo_id, filename=TRACK_FILE, |
| repo_type="dataset", token=token or None) |
| with open(path, encoding="utf-8") as fh: |
| return json.load(fh) |
| except Exception: |
| return {} |
|
|
|
|
| def save_tracking(repo_id: str, token: str, data: dict) -> None: |
| HfApi(token=token).upload_file( |
| path_or_fileobj=json.dumps(data, ensure_ascii=False).encode("utf-8"), |
| path_in_repo=TRACK_FILE, |
| repo_id=repo_id, |
| repo_type="dataset", |
| commit_message="update explored tracking", |
| ) |
|
|
|
|
| @st.cache_data(ttl=60) |
| def load_deleted(repo_id: str, token: str) -> list: |
| """Lead keys the user removed from the dashboard (tombstones).""" |
| try: |
| path = hf_hub_download(repo_id=repo_id, filename=DELETED_FILE, |
| repo_type="dataset", token=token or None) |
| with open(path, encoding="utf-8") as fh: |
| return json.load(fh) |
| except Exception: |
| return [] |
|
|
|
|
| def save_deleted(repo_id: str, token: str, keys: list) -> None: |
| HfApi(token=token).upload_file( |
| path_or_fileobj=json.dumps(sorted(set(keys)), |
| ensure_ascii=False).encode("utf-8"), |
| path_in_repo=DELETED_FILE, |
| repo_id=repo_id, |
| repo_type="dataset", |
| commit_message="update deleted leads", |
| ) |
|
|
|
|
| def run_label(filename: str) -> str: |
| """'runs/20260704-080501_rumah-makan-yogyakarta.csv' -> readable label.""" |
| if filename == "leads.csv": |
| return "leads.csv (legacy)" |
| stem = filename.removeprefix("runs/").removesuffix(".csv") |
| ts, _, slug = stem.partition("_") |
| return f"{slug or 'run'} Β· {ts}" |
|
|
|
|
| def style(fig, title: str): |
| fig.update_layout( |
| title=dict(text=title, font=dict(size=15, color=INK)), |
| plot_bgcolor=SURFACE, paper_bgcolor=SURFACE, |
| font=dict(family=FONT, color=INK, size=12), |
| margin=dict(l=10, r=10, t=45, b=10), |
| showlegend=False, |
| xaxis=dict(gridcolor=GRID, linecolor=BASELINE, zerolinecolor=BASELINE, |
| tickfont=dict(color=INK_MUTED)), |
| yaxis=dict(gridcolor=GRID, linecolor=BASELINE, zerolinecolor=BASELINE, |
| tickfont=dict(color=INK_MUTED)), |
| ) |
| return fig |
|
|
|
|
| |
| st.title("πΊοΈ Leads Dashboard β Google Maps") |
|
|
| if not REPO_ID: |
| st.error("Set `HF_DATASET_REPO` (env var / secret) first, e.g. `username/gmaps-leads`.") |
| st.stop() |
|
|
| try: |
| runs = list_runs(REPO_ID, HF_TOKEN) |
| updated = repo_last_updated(REPO_ID, HF_TOKEN) |
| except Exception as e: |
| st.error(f"Failed to read the dataset `{REPO_ID}`: {e}") |
| st.stop() |
|
|
| top_l, top_r = st.columns([5, 1], vertical_alignment="center") |
| top_l.caption(f"Source: `{REPO_ID}` Β· {len(runs)} run(s) Β· dataset last updated: " |
| f"**{updated:%d %b %Y %H:%M} UTC**") |
| if top_r.button("π Reload data", use_container_width=True): |
| st.cache_data.clear() |
| st.rerun() |
|
|
| |
| KAGGLE_USERNAME = conf("KAGGLE_USERNAME") |
| KAGGLE_KEY = conf("KAGGLE_KEY") |
|
|
| with st.expander("π Trigger a new scrape (runs on Kaggle)", expanded=not runs): |
| if not (KAGGLE_USERNAME and KAGGLE_KEY): |
| st.info( |
| "To enable this, add to the Space settings: `KAGGLE_USERNAME` (variable) " |
| "and `KAGGLE_KEY` (secret, legacy API key from kaggle.com/settings). " |
| "`HF_TOKEN` must be a **write** token." |
| ) |
| else: |
| import trigger |
|
|
| with st.form("trigger_form"): |
| t1, t2, t3 = st.columns(3) |
| t_keyword = t1.text_input("Keyword", "coffee shop") |
| t_city = t2.text_input("City", "Yogyakarta") |
| t_max = t3.number_input("Max results", min_value=10, max_value=200, |
| value=40, step=10) |
| t4, t5, t6 = st.columns(3) |
| t_min_rating = t4.number_input("Min. rating (0 = off)", min_value=0.0, |
| max_value=5.0, value=0.0, step=0.1) |
| t_min_reviews = t5.number_input("Min. total reviews (0 = off)", |
| min_value=0, max_value=100_000, |
| value=0, step=10) |
| t_only_new = t6.checkbox( |
| "Only new leads", |
| help="Drop leads that already exist in previous runs, so a " |
| "rescrape adds only businesses you haven't seen yet.") |
| t_details = st.checkbox("Fetch phone/website details (slower)", value=True) |
| t7, t8 = st.columns(2) |
| t_max_reviews = t7.number_input( |
| "Review texts per lead (0 = off)", min_value=0, max_value=200, |
| value=0, step=5, |
| help="Needs 'Fetch phone/website details'. Pulls full review " |
| "text β makes each lead noticeably slower to scrape.") |
| t_max_photos = t8.number_input( |
| "Photo URLs per lead (0 = off)", min_value=0, max_value=200, |
| value=0, step=5, |
| help="Needs 'Fetch phone/website details'. Pulls direct links " |
| "to listing photos.") |
| submitted = st.form_submit_button("βΆοΈ Start scrape on Kaggle") |
|
|
| if submitted: |
| with st.spinner("Pushing the kernel to Kaggle..."): |
| try: |
| out = trigger.trigger_scrape( |
| keyword=t_keyword, city=t_city, max_results=int(t_max), |
| with_details=t_details, |
| min_reviews=int(t_min_reviews), |
| min_rating=float(t_min_rating), |
| only_new=bool(t_only_new), |
| max_reviews=int(t_max_reviews), |
| max_photos=int(t_max_photos), |
| hf_repo=REPO_ID, hf_token=HF_TOKEN, |
| kaggle_username=KAGGLE_USERNAME, kaggle_key=KAGGLE_KEY, |
| ) |
| st.session_state["trigger_msg"] = ( |
| "β
Kernel pushed β Kaggle is running the scraper now. " |
| "The result appears as a NEW run in the dropdown in " |
| "~15β60 min β click **Reload data** then.\n\n" |
| + (out or "(no CLI output)") |
| ) |
| except Exception as e: |
| st.session_state["trigger_msg"] = f"β Trigger failed: {e}" |
|
|
| if st.session_state.get("trigger_msg"): |
| st.info(st.session_state["trigger_msg"]) |
|
|
| if st.button("π Check kernel status"): |
| try: |
| st.session_state["kernel_status"] = trigger.kernel_status( |
| KAGGLE_USERNAME, KAGGLE_KEY) |
| except Exception as e: |
| st.session_state["kernel_status"] = f"Status check failed: {e}" |
|
|
| if st.session_state.get("kernel_status"): |
| st.code(st.session_state["kernel_status"] or "(empty status)") |
|
|
| |
| if not runs: |
| st.warning("No scrape runs in the dataset yet β trigger one above.") |
| st.stop() |
|
|
| MERGED = "π§© All runs (merged & deduped)" |
| options = ([MERGED] + runs) if len(runs) > 1 else runs |
| sel_l, sel_r = st.columns([4, 1], vertical_alignment="bottom") |
| choice = sel_l.selectbox("Scrape run", options, format_func=lambda f: |
| f if f == MERGED else run_label(f)) |
|
|
| if choice != MERGED: |
| with sel_r.popover("ποΈ Delete run", use_container_width=True): |
| st.warning(f"Permanently delete `{run_label(choice)}` " |
| "from the HF Dataset?") |
| if st.button("Yes, delete this run", type="primary"): |
| try: |
| HfApi(token=HF_TOKEN).delete_file( |
| choice, REPO_ID, repo_type="dataset", |
| commit_message=f"delete {choice}") |
| st.cache_data.clear() |
| st.rerun() |
| except Exception as e: |
| st.error(f"Delete failed (HF_TOKEN must be a write token): {e}") |
|
|
| try: |
| if choice == MERGED: |
| frames = [load_csv(REPO_ID, HF_TOKEN, f) for f in runs] |
| df = (pd.concat(frames, ignore_index=True) |
| .drop_duplicates(subset=["name", "address"], keep="first")) |
| else: |
| df = load_csv(REPO_ID, HF_TOKEN, choice) |
| except Exception as e: |
| st.error(f"Failed to load `{choice}`: {e}") |
| st.stop() |
|
|
| |
| deleted_keys = set(load_deleted(REPO_ID, HF_TOKEN)) |
| if deleted_keys and len(df): |
| df = df[~df.apply(lambda r: lead_key(r) in deleted_keys, axis=1)] |
|
|
| if df.empty: |
| st.warning("This run contains 0 leads (probably a failed/captcha'd scrape, " |
| "or every lead was deleted). " |
| "Pick another run or trigger a new one above.") |
| st.stop() |
|
|
| |
| st.sidebar.header("Filters") |
| min_rev = st.sidebar.slider("Min. review count", 0, |
| max(int(df["reviews_count"].max()), 1), 0) |
| min_rating = st.sidebar.slider("Min. rating", 0.0, 5.0, 0.0, 0.1) |
| no_web_only = st.sidebar.checkbox("Only leads without a website") |
|
|
| f = df[(df["reviews_count"] >= min_rev) & (df["rating"] >= min_rating)] |
| if no_web_only: |
| f = f[f["website"] == ""] |
|
|
| |
| tracking = load_tracking(REPO_ID, HF_TOKEN) |
| explored_count = sum(1 for _, r in f.iterrows() if tracking.get(lead_key(r), False)) |
|
|
| c1, c2, c3, c4 = st.columns(4) |
| c1.metric("Total leads", len(f)) |
| c2.metric("Without website", int((f["website"] == "").sum())) |
| c3.metric("Median rating", f"{f['rating'].median():.1f}" if len(f) else "β") |
| c4.metric("Explored", f"{explored_count}/{len(f)}") |
|
|
| if not len(f): |
| st.warning("No leads pass the current filters.") |
| st.stop() |
|
|
| |
| left, right = st.columns(2) |
|
|
| hist = px.histogram(f, x="reviews_count", nbins=30, |
| color_discrete_sequence=[SERIES], |
| labels={"reviews_count": "review count"}) |
| left.plotly_chart(style(hist, "Review count distribution"), |
| use_container_width=True) |
|
|
| scat = px.scatter(f, x="reviews_count", y="rating", color="score", |
| color_continuous_scale=SEQ_RAMP, hover_name="name", |
| labels={"reviews_count": "review count", "score": "score"}) |
| scat.update_traces(marker=dict(size=9)) |
| scat.update_coloraxes(colorbar_title_text="score", |
| colorbar_tickfont_color=INK_MUTED) |
| right.plotly_chart(style(scat, "Rating vs reviews (darker = higher score)"), |
| use_container_width=True) |
|
|
| |
| st.subheader("Lead map") |
| st.caption("Marker color = score: π΅ light 0β1 Β· π΅ medium 2β3 Β· π΅ dark 4+") |
| geo = f.dropna(subset=["lat", "lng"]) |
| if len(geo): |
| m = folium.Map(location=[geo["lat"].mean(), geo["lng"].mean()], |
| zoom_start=12, tiles="cartodbpositron") |
| for _, r in geo.iterrows(): |
| color = SEQ_RAMP[0] if r["score"] <= 1 else ( |
| SEQ_RAMP[1] if r["score"] <= 3 else SEQ_RAMP[2]) |
| folium.CircleMarker( |
| location=[r["lat"], r["lng"]], radius=6, |
| color=color, fill=True, fill_color=color, fill_opacity=0.85, |
| popup=folium.Popup( |
| f"<b>{r['name']}</b><br>β {r['rating']} ({r['reviews_count']} reviews)" |
| f"<br>score: {r['score']}", max_width=280), |
| ).add_to(m) |
| st_folium(m, use_container_width=True, height=480, returned_objects=[]) |
| else: |
| st.info("No lat/lng coordinates in the filtered data.") |
|
|
| |
| st.subheader("Leads table (sorted by score)") |
| st.caption("Tick β
**explored** for leads you've already checked, then click " |
| "**Save progress**. Tick ποΈ **delete** and click **Delete selected** " |
| "to remove leads β both are stored in the HF Dataset and survive restarts.") |
|
|
| show_cols = [c for c in ("score", "name", "category", "rating", "reviews_count", |
| "address", "phone", "website", "email", "maps_url", |
| "reviews", "photos") |
| if c in f.columns] |
| table = f.sort_values(["score", "reviews_count"], |
| ascending=[False, False])[show_cols].reset_index(drop=True) |
| table.insert(0, "explored", |
| [tracking.get(lead_key(r), False) for _, r in table.iterrows()]) |
| table.insert(1, "delete", False) |
|
|
| edited = st.data_editor( |
| table, |
| use_container_width=True, |
| hide_index=True, |
| disabled=show_cols, |
| column_config={ |
| "explored": st.column_config.CheckboxColumn("β
explored"), |
| "delete": st.column_config.CheckboxColumn("ποΈ delete"), |
| "maps_url": st.column_config.LinkColumn("maps_url", display_text="open map"), |
| "website": st.column_config.LinkColumn("website"), |
| }, |
| key="leads_editor", |
| ) |
|
|
| save_l, del_l, spacer, dl_r = st.columns([1.2, 1.2, 2.6, 1.6]) |
|
|
| if save_l.button("πΎ Save progress", use_container_width=True): |
| new_tracking = dict(tracking) |
| for _, r in edited.iterrows(): |
| new_tracking[lead_key(r)] = bool(r["explored"]) |
| try: |
| save_tracking(REPO_ID, HF_TOKEN, new_tracking) |
| load_tracking.clear() |
| st.success("Progress saved to the HF Dataset.") |
| st.rerun() |
| except Exception as e: |
| st.error(f"Save failed (HF_TOKEN must be a write token): {e}") |
|
|
| if del_l.button("ποΈ Delete selected", use_container_width=True): |
| to_delete = [lead_key(r) for _, r in edited.iterrows() if r["delete"]] |
| if not to_delete: |
| st.info("No leads ticked for deletion.") |
| else: |
| try: |
| save_deleted(REPO_ID, HF_TOKEN, list(deleted_keys) + to_delete) |
| load_deleted.clear() |
| st.success(f"{len(to_delete)} lead(s) removed from the dashboard.") |
| st.rerun() |
| except Exception as e: |
| st.error(f"Delete failed (HF_TOKEN must be a write token): {e}") |
|
|
| dl_r.download_button("β¬οΈ Download filtered CSV", |
| edited.drop(columns=["delete"]) |
| .to_csv(index=False).encode("utf-8"), |
| "leads_filtered.csv", "text/csv", |
| use_container_width=True) |
|
|
| if deleted_keys: |
| with st.expander(f"β»οΈ {len(deleted_keys)} deleted lead(s)"): |
| st.write("\n".join(f"- {k.split('|')[0]}" for k in sorted(deleted_keys))) |
| if st.button("Restore all deleted leads"): |
| try: |
| save_deleted(REPO_ID, HF_TOKEN, []) |
| load_deleted.clear() |
| st.rerun() |
| except Exception as e: |
| st.error(f"Restore failed: {e}") |
|
|