Spaces:
Sleeping
Sleeping
Upload app.py
Browse files
app.py
CHANGED
|
@@ -1,160 +1,195 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
| 2 |
import pandas as pd
|
| 3 |
import gradio as gr
|
| 4 |
-
from html import escape as
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
try:
|
| 26 |
if s[:1] in "[{" or (s[:1] == '"' and s[-1:] == '"'):
|
| 27 |
j = json.loads(s)
|
| 28 |
if isinstance(j, list):
|
| 29 |
for item in j:
|
| 30 |
-
m =
|
| 31 |
if m: return m.group(0)
|
| 32 |
elif isinstance(j, dict):
|
| 33 |
for k in ("image","image_url","url","src"):
|
| 34 |
if k in j:
|
| 35 |
-
m =
|
| 36 |
if m: return m.group(0)
|
| 37 |
except Exception:
|
| 38 |
pass
|
| 39 |
-
|
| 40 |
-
m = re.search(r'https?://[^\s<>()\'"]+', s)
|
| 41 |
if m: return m.group(0)
|
| 42 |
-
# splitters
|
| 43 |
for sep in ("|","\n"," "):
|
| 44 |
if sep in s:
|
| 45 |
for part in s.split(sep):
|
| 46 |
-
m =
|
| 47 |
if m: return m.group(0)
|
| 48 |
return ""
|
| 49 |
|
| 50 |
-
def
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
dealer
|
| 60 |
-
|
| 61 |
-
trim
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
)
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
<div class="thumb"><img src="{img}" alt="{heading}" onerror="this.style.opacity=0.2"></div>
|
| 70 |
-
<div class="body">
|
| 71 |
-
<div class="sp">Starting Price<strong>{price or "—"}</strong></div>
|
| 72 |
-
<div class="heading">{heading}</div>
|
| 73 |
-
<div class="offer">{offer}</div>
|
| 74 |
-
</div>
|
| 75 |
-
<div class="meta">{chips}</div>
|
| 76 |
-
</div>
|
| 77 |
-
"""
|
| 78 |
|
| 79 |
-
def _grid_html(df: pd.DataFrame) -> str:
|
| 80 |
-
cards = "\n".join(_card_html(r) for _, r in df.iterrows())
|
| 81 |
return f"""
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
transition:transform .08s ease,box-shadow .2s ease;}}
|
| 92 |
-
.card:hover{{transform:translateY(-2px);box-shadow:0 8px 20px rgba(0,0,0,.12);}}
|
| 93 |
-
.thumb{{height:160px;overflow:hidden;background:#000;}}
|
| 94 |
-
.thumb img{{width:100%;height:100%;object-fit:cover;}}
|
| 95 |
-
.body{{padding:12px;display:flex;flex-direction:column;gap:6px;}}
|
| 96 |
-
.sp{{font-size:13px;color:var(--muted);}}
|
| 97 |
-
.sp strong{{display:block;font-size:20px;color:var(--ink);margin-top:2px;}}
|
| 98 |
-
.heading{{font-weight:700;color:var(--ink);font-size:15px;line-height:1.25;}}
|
| 99 |
-
.offer{{color:var(--ink);font-size:13px;line-height:1.45;opacity:.9;
|
| 100 |
-
display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;}}
|
| 101 |
-
.meta{{display:flex;gap:6px;flex-wrap:wrap;padding:0 12px 12px;}}
|
| 102 |
-
.chip{{font-size:11px;color:#374151;background:var(--chip);border-radius:999px;padding:4px 8px;}}
|
| 103 |
-
</style>
|
| 104 |
-
</head>
|
| 105 |
-
<body>
|
| 106 |
-
<div class="cards">{cards}</div>
|
| 107 |
-
</body>
|
| 108 |
-
</html>
|
| 109 |
-
"""
|
| 110 |
-
|
| 111 |
-
# ---------- Filtering logic ----------
|
| 112 |
-
def filter_offers(country: str, brand: str, dealer: str, limit: int):
|
| 113 |
-
df_f = DF.copy()
|
| 114 |
-
# normalize to str before comparing
|
| 115 |
-
if country and country != "All":
|
| 116 |
-
df_f = df_f[df_f["country"].astype(str) == str(country)]
|
| 117 |
-
if brand and brand != "All":
|
| 118 |
-
df_f = df_f[df_f["brand"].astype(str) == str(brand)]
|
| 119 |
-
if dealer and dealer != "All":
|
| 120 |
-
df_f = df_f[df_f["dealer"].astype(str) == str(dealer)]
|
| 121 |
-
df_show = df_f.head(int(limit))
|
| 122 |
-
return _grid_html(df_show)
|
| 123 |
-
|
| 124 |
-
# ---------- Build Gradio UI ----------
|
| 125 |
-
def app():
|
| 126 |
-
countries = ["All"] + sorted(DF["country"].dropna().astype(str).unique().tolist())
|
| 127 |
-
brands = ["All"] + sorted(DF["brand"].dropna().astype(str).unique().tolist())
|
| 128 |
-
dealers = ["All"] + sorted(DF["dealer"].dropna().astype(str).unique().tolist())
|
| 129 |
-
|
| 130 |
-
# One scrolling region (like an iframe) for the HTML
|
| 131 |
-
custom_css = """
|
| 132 |
-
body, .gradio-container, .gr-block, .gr-button, .gr-input, .gr-dropdown,
|
| 133 |
-
.gradio-container * { font-family: Inter, Segoe UI, Roboto, Helvetica, Arial, sans-serif !important; }
|
| 134 |
-
#cards-pane { height: 820px; overflow: auto; border-radius: 10px; }
|
| 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 |
if __name__ == "__main__":
|
| 160 |
-
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import re, json
|
| 3 |
+
from pathlib import Path
|
| 4 |
import pandas as pd
|
| 5 |
import gradio as gr
|
| 6 |
+
from html import escape as _esc
|
| 7 |
+
|
| 8 |
+
DATA_PATH = Path("data/offers_with_prices.csv")
|
| 9 |
+
|
| 10 |
+
# ---------- Utilities ----------
|
| 11 |
+
def h(x: str) -> str:
|
| 12 |
+
return _esc((x or ""))
|
| 13 |
+
|
| 14 |
+
def load_csv() -> pd.DataFrame:
|
| 15 |
+
if not DATA_PATH.exists():
|
| 16 |
+
cols = ["country","dealer","brand","model","trim_or_variant","starting_price",
|
| 17 |
+
"heading","offer_description","images","currency","vehicle_offer_url"]
|
| 18 |
+
return pd.DataFrame(columns=cols)
|
| 19 |
+
|
| 20 |
+
df = pd.read_csv(DATA_PATH)
|
| 21 |
+
df.columns = [c.strip().lower() for c in df.columns]
|
| 22 |
+
alias = {
|
| 23 |
+
"brand_name": "brand", "make": "brand",
|
| 24 |
+
"model_name": "model",
|
| 25 |
+
"img": "images", "image_url": "images", "image": "images", "imageurl": "images",
|
| 26 |
+
"desc": "offer_description", "offer_text": "offer_description",
|
| 27 |
+
"price": "starting_price",
|
| 28 |
+
"offer heading": "heading", "title": "heading",
|
| 29 |
+
"vehcileoffer_url": "vehicle_offer_url",
|
| 30 |
+
}
|
| 31 |
+
for a,b in alias.items():
|
| 32 |
+
if a in df.columns and b not in df.columns:
|
| 33 |
+
df[b] = df[a]
|
| 34 |
+
for c in ["country","dealer","brand","model","trim_or_variant","starting_price",
|
| 35 |
+
"heading","offer_description","images","currency","vehicle_offer_url"]:
|
| 36 |
+
if c not in df.columns: df[c] = ""
|
| 37 |
+
return df
|
| 38 |
+
|
| 39 |
+
_IMG_URL_RE = re.compile(r'https?://[^\s<>()\'"]+')
|
| 40 |
+
def first_image(url_field) -> str:
|
| 41 |
+
if not url_field: return ""
|
| 42 |
+
s = str(url_field).strip().replace("&", "&")
|
| 43 |
try:
|
| 44 |
if s[:1] in "[{" or (s[:1] == '"' and s[-1:] == '"'):
|
| 45 |
j = json.loads(s)
|
| 46 |
if isinstance(j, list):
|
| 47 |
for item in j:
|
| 48 |
+
m = _IMG_URL_RE.search(str(item))
|
| 49 |
if m: return m.group(0)
|
| 50 |
elif isinstance(j, dict):
|
| 51 |
for k in ("image","image_url","url","src"):
|
| 52 |
if k in j:
|
| 53 |
+
m = _IMG_URL_RE.search(str(j[k]))
|
| 54 |
if m: return m.group(0)
|
| 55 |
except Exception:
|
| 56 |
pass
|
| 57 |
+
m = _IMG_URL_RE.search(s)
|
|
|
|
| 58 |
if m: return m.group(0)
|
|
|
|
| 59 |
for sep in ("|","\n"," "):
|
| 60 |
if sep in s:
|
| 61 |
for part in s.split(sep):
|
| 62 |
+
m = _IMG_URL_RE.search(part)
|
| 63 |
if m: return m.group(0)
|
| 64 |
return ""
|
| 65 |
|
| 66 |
+
def price_number(s: str):
|
| 67 |
+
if not isinstance(s, str): return None
|
| 68 |
+
s = s.replace(",", "")
|
| 69 |
+
m = re.search(r"([\d]+(?:\.\d+)?)", s)
|
| 70 |
+
return float(m.group(1)) if m else None
|
| 71 |
+
|
| 72 |
+
# ---------- Card Renderer ----------
|
| 73 |
+
def card_html(row: pd.Series) -> str:
|
| 74 |
+
img = first_image(row.get("images",""))
|
| 75 |
+
brand, dealer, country = map(lambda x: h(str(x).strip()),
|
| 76 |
+
[row.get("brand",""), row.get("dealer",""), row.get("country","")])
|
| 77 |
+
model, trim = h(str(row.get("model","")).strip()), h(str(row.get("trim_or_variant","")).strip())
|
| 78 |
+
heading = h(str(row.get("heading","")).strip() or f"{brand} {model}".strip())
|
| 79 |
+
price = h(str(row.get("starting_price","")).strip())
|
| 80 |
+
offer = h(str(row.get("offer_description","")).strip()).replace("\n","<br>")
|
| 81 |
+
url = str(row.get("vehicle_offer_url","")).strip()
|
| 82 |
+
has_url = url and url.lower() != "nan"
|
| 83 |
+
onclick = f"onclick=\"window.open('{url}','_blank');\"" if has_url else ""
|
| 84 |
+
chips = "".join(f"<div class='chip'>{x}</div>" for x in [country, brand, dealer, model, trim] if x)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
|
|
|
|
|
|
| 86 |
return f"""
|
| 87 |
+
<div class="card" {onclick}>
|
| 88 |
+
<div class="thumb"><img src="{img}" alt="{brand} {model}" onerror="this.style.opacity=0.15"></div>
|
| 89 |
+
<div class="body">
|
| 90 |
+
<div class="sp">Starting Price<strong>{price or "—"}</strong></div>
|
| 91 |
+
<div class="heading">{heading}</div>
|
| 92 |
+
<div class="offer">{offer}</div>
|
| 93 |
+
</div>
|
| 94 |
+
<div class="meta">{chips}</div>
|
| 95 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
"""
|
| 97 |
|
| 98 |
+
# ---------- CSS ----------
|
| 99 |
+
CARD_CSS = """
|
| 100 |
+
<style>
|
| 101 |
+
body, * {
|
| 102 |
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial !important;
|
| 103 |
+
}
|
| 104 |
+
.offers-container {
|
| 105 |
+
height: 850px;
|
| 106 |
+
overflow: auto;
|
| 107 |
+
background: #f6f7fb;
|
| 108 |
+
padding: 8px;
|
| 109 |
+
border-radius: 8px;
|
| 110 |
+
}
|
| 111 |
+
.cards {
|
| 112 |
+
display: grid;
|
| 113 |
+
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
| 114 |
+
gap: 18px;
|
| 115 |
+
}
|
| 116 |
+
.card {
|
| 117 |
+
background:#fff;
|
| 118 |
+
border:1px solid #e5e7eb;
|
| 119 |
+
border-radius:16px;
|
| 120 |
+
overflow:hidden;
|
| 121 |
+
box-shadow:0 4px 18px rgba(0,0,0,.05);
|
| 122 |
+
display:flex;
|
| 123 |
+
flex-direction:column;
|
| 124 |
+
transition: transform .08s ease, box-shadow .2s ease;
|
| 125 |
+
cursor:pointer;
|
| 126 |
+
height:100%;
|
| 127 |
+
}
|
| 128 |
+
.card:hover { box-shadow:0 10px 28px rgba(2,6,23,.12); transform:translateY(-1px); }
|
| 129 |
+
.thumb { background:#0b0b0b; height:180px; overflow:hidden; }
|
| 130 |
+
.thumb img { width:100%; height:100%; object-fit:cover; }
|
| 131 |
+
.body { padding:14px; display:flex; flex-direction:column; gap:6px; flex:1 1 auto; }
|
| 132 |
+
.sp { font-size:15px; color:#070708; letter-spacing:.02em; }
|
| 133 |
+
.sp strong { display:block; font-size:22px; color:#e5c100; margin-top:2px; }
|
| 134 |
+
.heading { font-weight:700; color:#111827; font-size:16px; margin:4px 0; line-height:1.25; }
|
| 135 |
+
.offer { color:#111827; font-size:14px; line-height:1.45; opacity:.9;
|
| 136 |
+
display:-webkit-box; -webkit-line-clamp:5; -webkit-box-orient:vertical; overflow:hidden; }
|
| 137 |
+
.meta { display:flex; gap:8px; flex-wrap:wrap; padding:0 14px 14px; margin-top:auto; }
|
| 138 |
+
.chip { font-size:13px; color:#f3eac0; background:#1e2640; border-radius:999px;
|
| 139 |
+
border:1px solid #e5e7eb; padding:4px 8px; }
|
| 140 |
+
</style>
|
| 141 |
+
"""
|
| 142 |
+
|
| 143 |
+
# ---------- HTML Renderer ----------
|
| 144 |
+
def render_html(df: pd.DataFrame) -> str:
|
| 145 |
+
if df.empty:
|
| 146 |
+
return f"{CARD_CSS}<div class='offers-container'><p style='padding:10px;color:#64748b;'>No offers found.</p></div>"
|
| 147 |
+
cards = "".join(card_html(r) for _, r in df.iterrows())
|
| 148 |
+
return f"{CARD_CSS}<div class='offers-container'><div class='cards'>{cards}</div></div>"
|
| 149 |
+
|
| 150 |
+
# ---------- Filtering ----------
|
| 151 |
+
DF = load_csv()
|
| 152 |
+
|
| 153 |
+
def get_filters():
|
| 154 |
+
countries = ["All"] + sorted([x for x in DF["country"].dropna().astype(str).unique() if x])
|
| 155 |
+
brands = ["All"] + sorted([x for x in DF["brand"].dropna().astype(str).unique() if x])
|
| 156 |
+
dealers = ["All"] + sorted([x for x in DF["dealer"].dropna().astype(str).unique() if x])
|
| 157 |
+
return countries, brands, dealers
|
| 158 |
+
|
| 159 |
+
def update_cards(country, brand, dealer, sort_choice, limit):
|
| 160 |
+
df = DF.copy()
|
| 161 |
+
if country != "All": df = df[df["country"].astype(str) == country]
|
| 162 |
+
if brand != "All": df = df[df["brand"].astype(str) == brand]
|
| 163 |
+
if dealer != "All": df = df[df["dealer"].astype(str) == dealer]
|
| 164 |
+
|
| 165 |
+
if sort_choice != "Default":
|
| 166 |
+
tmp = df.apply(lambda r: price_number(r["starting_price"]), axis=1)
|
| 167 |
+
df = df.assign(__p=tmp)
|
| 168 |
+
df = df.sort_values("__p", ascending=(sort_choice=="Price (Low → High)"), na_position="last").drop(columns="__p", errors="ignore")
|
| 169 |
+
|
| 170 |
+
df = df.head(int(limit))
|
| 171 |
+
return render_html(df)
|
| 172 |
+
|
| 173 |
+
# ---------- Gradio UI ----------
|
| 174 |
+
countries, brands, dealers = get_filters()
|
| 175 |
+
|
| 176 |
+
with gr.Blocks(fill_height=True) as demo:
|
| 177 |
+
gr.Markdown("## 🚗 Vehicle Offers")
|
| 178 |
+
with gr.Row():
|
| 179 |
+
c = gr.Dropdown(countries, value="All", label="Country")
|
| 180 |
+
b = gr.Dropdown(brands, value="All", label="Brand")
|
| 181 |
+
d = gr.Dropdown(dealers, value="All", label="Dealer")
|
| 182 |
+
s = gr.Dropdown(["Default","Price (Low → High)","Price (High → Low)"], value="Default", label="Sort by")
|
| 183 |
+
l = gr.Slider(6, 200, 50, step=6, label="Max Cards")
|
| 184 |
+
out = gr.HTML()
|
| 185 |
+
|
| 186 |
+
# Auto-update and initial load
|
| 187 |
+
demo.load(update_cards, [c,b,d,s,l], [out])
|
| 188 |
+
c.change(update_cards, [c,b,d,s,l], [out])
|
| 189 |
+
b.change(update_cards, [c,b,d,s,l], [out])
|
| 190 |
+
d.change(update_cards, [c,b,d,s,l], [out])
|
| 191 |
+
s.change(update_cards, [c,b,d,s,l], [out])
|
| 192 |
+
l.change(update_cards, [c,b,d,s,l], [out])
|
| 193 |
|
| 194 |
if __name__ == "__main__":
|
| 195 |
+
demo.launch()
|