Junaid Hasan commited on
Commit
aa56d50
·
1 Parent(s): 276a23a

Improve listing quality and advanced filtering defaults

Browse files
Files changed (2) hide show
  1. .gitignore +1 -0
  2. app.py +191 -7
.gitignore CHANGED
@@ -12,6 +12,7 @@ venv/
12
  # Local artifacts / generated exports
13
  *.csv
14
  data/cars_by_year.csv
 
15
 
16
  # OS/editor
17
  .DS_Store
 
12
  # Local artifacts / generated exports
13
  *.csv
14
  data/cars_by_year.csv
15
+ notes.md
16
 
17
  # OS/editor
18
  .DS_Store
app.py CHANGED
@@ -18,6 +18,8 @@ USER_AGENT = (
18
  "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
19
  )
20
 
 
 
21
 
22
  @st.cache_data
23
  def load_cars() -> list[dict[str, Any]]:
@@ -46,6 +48,24 @@ def parse_max_miles(spec: str) -> int | None:
46
  return value
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  def query_for_car(car_name: str) -> str:
50
  raw = re.sub(r"\(.*?\)", "", car_name)
51
  raw = re.sub(r"\s+", " ", raw).strip()
@@ -163,6 +183,120 @@ def extract_listings(page_html: str) -> list[dict[str, str]]:
163
  return results
164
 
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  def search_car(
167
  index: int,
168
  car: dict[str, Any],
@@ -171,12 +305,38 @@ def search_car(
171
  budget: int,
172
  distance_miles: int,
173
  clean_title_only: bool,
 
174
  ) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  url = build_search_url(
176
  car,
177
  postal,
178
  min_price,
179
- budget,
180
  distance_miles,
181
  clean_title_only,
182
  )
@@ -184,7 +344,7 @@ def search_car(
184
  car,
185
  postal,
186
  min_price,
187
- budget,
188
  distance_miles,
189
  )
190
  try:
@@ -195,7 +355,14 @@ def search_car(
195
  allow_redirects=True,
196
  )
197
  response.raise_for_status()
198
- listings = extract_listings(response.text)[:MAX_LINKS_PER_CAR]
 
 
 
 
 
 
 
199
  final_search_url = response.url
200
  except Exception as exc: # noqa: BLE001
201
  return {
@@ -205,6 +372,7 @@ def search_car(
205
  "carComplaintsPage": car.get("carComplaintsPage", ""),
206
  "autotempestUrl": autotempest_url,
207
  "listings": [],
 
208
  "error": str(exc),
209
  "searchUrl": url,
210
  }
@@ -216,6 +384,7 @@ def search_car(
216
  "carComplaintsPage": car.get("carComplaintsPage", ""),
217
  "autotempestUrl": autotempest_url,
218
  "listings": listings,
 
219
  "error": "",
220
  "searchUrl": final_search_url,
221
  }
@@ -228,6 +397,7 @@ def run_search(
228
  budget: int,
229
  distance_miles: int,
230
  clean_title_only: bool,
 
231
  ) -> list[dict[str, Any]]:
232
  jobs: list[Any] = []
233
  with ThreadPoolExecutor(max_workers=8) as executor:
@@ -242,6 +412,7 @@ def run_search(
242
  budget,
243
  distance_miles,
244
  clean_title_only,
 
245
  )
246
  )
247
 
@@ -400,12 +571,21 @@ def main() -> None:
400
  index=0,
401
  )
402
 
403
- adv_col4, adv_col5, adv_col6 = st.columns([2, 2, 2])
404
  with adv_col4:
405
  clean_title_only = st.checkbox("Clean title", value=True)
406
  with adv_col5:
407
- show_complaints = st.checkbox("Browse complaints", value=False)
 
 
 
 
 
 
 
408
  with adv_col6:
 
 
409
  show_autotempest = st.checkbox("Browse autotempest", value=False)
410
 
411
  submitted = st.form_submit_button("Search Listings", use_container_width=True)
@@ -432,20 +612,24 @@ def main() -> None:
432
  int(budget),
433
  int(distance_miles),
434
  clean_title_only,
 
435
  )
436
 
437
  st.session_state["search_results"] = [row for row in results if row["listings"]]
438
  st.session_state["search_errors"] = [row for row in results if row["error"]]
439
  st.session_state["search_count"] = len(selected_cars)
 
 
 
440
 
441
  rows = st.session_state.get("search_results", [])
442
  errors = st.session_state.get("search_errors", [])
443
  searched_count = st.session_state.get("search_count")
 
444
 
445
  if searched_count is not None:
446
  st.subheader("Results")
447
- st.write(f"Cars searched: {searched_count}")
448
- st.write(f"Cars with listings: {len(rows)}")
449
 
450
  if not rows:
451
  st.info("No matching listings found for the current inputs.")
 
18
  "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
19
  )
20
 
21
+ V6_HINT_RE = re.compile(r"\b(v6|6\s*cyl|6-cylinder|3\.5l?)\b", re.I)
22
+
23
 
24
  @st.cache_data
25
  def load_cars() -> list[dict[str, Any]]:
 
48
  return value
49
 
50
 
51
+ def parse_price_value(spec: str) -> int | None:
52
+ text = spec.strip().lower().replace(",", "")
53
+ text = text.replace("$", "")
54
+
55
+ if not text:
56
+ return None
57
+
58
+ k_match = re.search(r"(\d+(?:\.\d+)?)\s*k\b", text)
59
+ if k_match:
60
+ return int(float(k_match.group(1)) * 1000)
61
+
62
+ num_match = re.search(r"\d+", text)
63
+ if num_match:
64
+ return int(num_match.group(0))
65
+
66
+ return None
67
+
68
+
69
  def query_for_car(car_name: str) -> str:
70
  raw = re.sub(r"\(.*?\)", "", car_name)
71
  raw = re.sub(r"\s+", " ", raw).strip()
 
183
  return results
184
 
185
 
186
+ def filter_listings_by_price(
187
+ listings: list[dict[str, str]],
188
+ max_price: int,
189
+ ) -> list[dict[str, str]]:
190
+ filtered: list[dict[str, str]] = []
191
+ for listing in listings:
192
+ listing_price = parse_price_value(listing.get("price", ""))
193
+ if listing_price is None or listing_price <= max_price:
194
+ filtered.append(listing)
195
+ return filtered
196
+
197
+
198
+ def car_requires_v6_filter(car: dict[str, Any]) -> bool:
199
+ return "(v6)" in car.get("car", "").lower()
200
+
201
+
202
+ def engine_prefixes_from_spec(engine_spec: str) -> list[str]:
203
+ prefixes: list[str] = []
204
+ for token in re.split(r"[^A-Za-z0-9]+", engine_spec.upper()):
205
+ if not token:
206
+ continue
207
+ if not (re.search(r"[A-Z]", token) and re.search(r"\d", token)):
208
+ continue
209
+ prefix_match = re.match(r"([A-Z]?\d{1,3}[A-Z]{1,3})", token)
210
+ if prefix_match:
211
+ prefix = prefix_match.group(1)
212
+ if prefix not in prefixes:
213
+ prefixes.append(prefix)
214
+ return prefixes
215
+
216
+
217
+ def extract_cylinder_count(listing_html: str) -> int | None:
218
+ vin_pattern = re.search(r'"Engine Number of Cylinders","(\d+)"', listing_html)
219
+ if vin_pattern:
220
+ return int(vin_pattern.group(1))
221
+
222
+ attr_pattern = re.search(
223
+ r'<span class="attr">\s*<b>([^<]+)</b>\s*cylinders\s*</span>',
224
+ listing_html,
225
+ re.I,
226
+ )
227
+ if attr_pattern:
228
+ digits = re.search(r"\d+", attr_pattern.group(1))
229
+ if digits:
230
+ return int(digits.group(0))
231
+
232
+ return None
233
+
234
+
235
+ def extract_engine_model(listing_html: str) -> str | None:
236
+ vin_pattern = re.search(r'"Engine Model","([^"]+)"', listing_html)
237
+ if vin_pattern:
238
+ return vin_pattern.group(1).upper()
239
+ return None
240
+
241
+
242
+ def fetch_listing_specs(url: str) -> tuple[int | None, str | None]:
243
+ try:
244
+ response = requests.get(
245
+ url,
246
+ timeout=12,
247
+ headers={"User-Agent": USER_AGENT},
248
+ allow_redirects=True,
249
+ )
250
+ response.raise_for_status()
251
+ cylinders = extract_cylinder_count(response.text)
252
+ engine_model = extract_engine_model(response.text)
253
+ return cylinders, engine_model
254
+ except Exception: # noqa: BLE001
255
+ return None, None
256
+
257
+
258
+ def filter_v6_listings(
259
+ listings: list[dict[str, str]],
260
+ engine_prefixes: list[str],
261
+ ) -> list[dict[str, str]]:
262
+ keep_map: dict[int, bool] = {}
263
+ candidates_for_lookup: list[tuple[int, dict[str, str]]] = []
264
+
265
+ for idx, listing in enumerate(listings):
266
+ title = listing.get("title", "")
267
+ if V6_HINT_RE.search(title):
268
+ keep_map[idx] = True
269
+ continue
270
+ candidates_for_lookup.append((idx, listing))
271
+
272
+ if not candidates_for_lookup:
273
+ return [
274
+ listing for idx, listing in enumerate(listings) if keep_map.get(idx, False)
275
+ ]
276
+
277
+ with ThreadPoolExecutor(max_workers=8) as executor:
278
+ future_to_listing = {
279
+ executor.submit(fetch_listing_specs, listing["url"]): (idx, listing)
280
+ for idx, listing in candidates_for_lookup
281
+ }
282
+ for future in as_completed(future_to_listing):
283
+ idx, _ = future_to_listing[future]
284
+ cylinders, engine_model = future.result()
285
+
286
+ if cylinders is not None:
287
+ keep_map[idx] = cylinders == 6
288
+ continue
289
+
290
+ if engine_model and engine_prefixes:
291
+ # Least aggressive behavior: do not exclude on engine-model mismatch.
292
+ keep_map[idx] = True
293
+ continue
294
+
295
+ keep_map[idx] = True
296
+
297
+ return [listing for idx, listing in enumerate(listings) if keep_map.get(idx, False)]
298
+
299
+
300
  def search_car(
301
  index: int,
302
  car: dict[str, Any],
 
305
  budget: int,
306
  distance_miles: int,
307
  clean_title_only: bool,
308
+ aggressive_mode: bool,
309
  ) -> dict[str, Any]:
310
+ effective_max_price = budget
311
+ model_max_price = parse_price_value(car.get("maxPrice", ""))
312
+ if not aggressive_mode and model_max_price is not None:
313
+ effective_max_price = min(budget, int(model_max_price * 1.1))
314
+
315
+ if effective_max_price < min_price:
316
+ autotempest_url = build_autotempest_url(
317
+ car,
318
+ postal,
319
+ min_price,
320
+ min_price,
321
+ distance_miles,
322
+ )
323
+ return {
324
+ "index": index,
325
+ "car": car["car"],
326
+ "years": car.get("years", ""),
327
+ "carComplaintsPage": car.get("carComplaintsPage", ""),
328
+ "autotempestUrl": autotempest_url,
329
+ "listings": [],
330
+ "totalListings": 0,
331
+ "error": "",
332
+ "searchUrl": "",
333
+ }
334
+
335
  url = build_search_url(
336
  car,
337
  postal,
338
  min_price,
339
+ effective_max_price,
340
  distance_miles,
341
  clean_title_only,
342
  )
 
344
  car,
345
  postal,
346
  min_price,
347
+ effective_max_price,
348
  distance_miles,
349
  )
350
  try:
 
355
  allow_redirects=True,
356
  )
357
  response.raise_for_status()
358
+ listings = extract_listings(response.text)
359
+ if not aggressive_mode:
360
+ listings = filter_listings_by_price(listings, effective_max_price)
361
+ if car_requires_v6_filter(car):
362
+ engine_prefixes = engine_prefixes_from_spec(car.get("engine", ""))
363
+ listings = filter_v6_listings(listings, engine_prefixes)
364
+ total_listings = len(listings)
365
+ listings = listings[:MAX_LINKS_PER_CAR]
366
  final_search_url = response.url
367
  except Exception as exc: # noqa: BLE001
368
  return {
 
372
  "carComplaintsPage": car.get("carComplaintsPage", ""),
373
  "autotempestUrl": autotempest_url,
374
  "listings": [],
375
+ "totalListings": 0,
376
  "error": str(exc),
377
  "searchUrl": url,
378
  }
 
384
  "carComplaintsPage": car.get("carComplaintsPage", ""),
385
  "autotempestUrl": autotempest_url,
386
  "listings": listings,
387
+ "totalListings": total_listings,
388
  "error": "",
389
  "searchUrl": final_search_url,
390
  }
 
397
  budget: int,
398
  distance_miles: int,
399
  clean_title_only: bool,
400
+ aggressive_mode: bool,
401
  ) -> list[dict[str, Any]]:
402
  jobs: list[Any] = []
403
  with ThreadPoolExecutor(max_workers=8) as executor:
 
412
  budget,
413
  distance_miles,
414
  clean_title_only,
415
+ aggressive_mode,
416
  )
417
  )
418
 
 
571
  index=0,
572
  )
573
 
574
+ adv_col4, adv_col5, adv_col6, adv_col7 = st.columns([2, 2, 2, 2])
575
  with adv_col4:
576
  clean_title_only = st.checkbox("Clean title", value=True)
577
  with adv_col5:
578
+ aggressive_mode = st.checkbox(
579
+ "Aggressive",
580
+ value=False,
581
+ help=(
582
+ "When off, each car is capped at its JSON maxPrice +10%. "
583
+ "When on, only your Budget is used."
584
+ ),
585
+ )
586
  with adv_col6:
587
+ show_complaints = st.checkbox("Browse complaints", value=False)
588
+ with adv_col7:
589
  show_autotempest = st.checkbox("Browse autotempest", value=False)
590
 
591
  submitted = st.form_submit_button("Search Listings", use_container_width=True)
 
612
  int(budget),
613
  int(distance_miles),
614
  clean_title_only,
615
+ aggressive_mode,
616
  )
617
 
618
  st.session_state["search_results"] = [row for row in results if row["listings"]]
619
  st.session_state["search_errors"] = [row for row in results if row["error"]]
620
  st.session_state["search_count"] = len(selected_cars)
621
+ st.session_state["total_listings"] = sum(
622
+ row.get("totalListings", 0) for row in results
623
+ )
624
 
625
  rows = st.session_state.get("search_results", [])
626
  errors = st.session_state.get("search_errors", [])
627
  searched_count = st.session_state.get("search_count")
628
+ total_listings = st.session_state.get("total_listings", 0)
629
 
630
  if searched_count is not None:
631
  st.subheader("Results")
632
+ st.write(f"Total listings found: {total_listings}")
 
633
 
634
  if not rows:
635
  st.info("No matching listings found for the current inputs.")