fix: extract real titles from Craigslist listings, add image URLs
- Parse .cl-search-result inner text for title, price, location - Fallback to URL slug extraction for missing titles - Capture listing images for display in UI - AutoTempest scraper also improved with better selectors
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Car search crawler -- finds used hybrids under $10K near Woodinville WA.
|
Car search crawler -- finds used hybrids under $10K near Woodinville WA.
|
||||||
Playwright Chromium always runs headed on Xvfb. View live at browser.ampbox.io.
|
Always runs headed on Xvfb. View live at browser.ampbox.io.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -14,6 +15,14 @@ RESULTS_DIR = Path("results")
|
|||||||
RESULTS_DIR.mkdir(exist_ok=True)
|
RESULTS_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_title_from_url(url: str) -> str:
|
||||||
|
"""Extract a readable title from a Craigslist URL slug."""
|
||||||
|
m = re.search(r"/d/([^/]+)/", url)
|
||||||
|
if m:
|
||||||
|
return m.group(1).replace("-", " ").title()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
async def search_craigslist(page) -> list[dict]:
|
async def search_craigslist(page) -> list[dict]:
|
||||||
"""Craigslist Seattle -- simple HTML, most reliable to scrape."""
|
"""Craigslist Seattle -- simple HTML, most reliable to scrape."""
|
||||||
url = (
|
url = (
|
||||||
@@ -26,30 +35,60 @@ async def search_craigslist(page) -> list[dict]:
|
|||||||
)
|
)
|
||||||
print(f" Searching Craigslist...")
|
print(f" Searching Craigslist...")
|
||||||
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
||||||
await page.wait_for_timeout(2000)
|
await page.wait_for_timeout(3000)
|
||||||
|
|
||||||
listings = []
|
listings = []
|
||||||
cards = await page.query_selector_all(
|
cards = await page.query_selector_all(".cl-search-result")
|
||||||
".cl-static-search-result, .result-row, .cl-search-result"
|
print(f" Found {len(cards)} result elements")
|
||||||
)
|
|
||||||
for card in cards[:50]:
|
for card in cards[:50]:
|
||||||
try:
|
try:
|
||||||
title_el = await card.query_selector(".titlestring, .result-title, a")
|
# Get the full text of the card -- title is in there
|
||||||
price_el = await card.query_selector(".priceinfo, .result-price")
|
text = await card.inner_text()
|
||||||
title = await title_el.inner_text() if title_el else "Unknown"
|
lines = [l.strip() for l in text.split("\n") if l.strip() and l.strip() != "•"]
|
||||||
price = await price_el.inner_text() if price_el else "N/A"
|
|
||||||
link_el = await card.query_selector("a[href]")
|
# Get the link
|
||||||
|
link_el = await card.query_selector("a.main, a.cl-app-anchor, a[href*='/d/']")
|
||||||
link = await link_el.get_attribute("href") if link_el else ""
|
link = await link_el.get_attribute("href") if link_el else ""
|
||||||
|
|
||||||
|
# Title is usually the longest non-price, non-date text line
|
||||||
|
title = ""
|
||||||
|
price = ""
|
||||||
|
location = ""
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith("$"):
|
||||||
|
price = line
|
||||||
|
elif re.match(r"^\d+/\d+$", line):
|
||||||
|
continue # date like 5/23
|
||||||
|
elif re.match(r"^\d+k?\s*mi", line, re.I):
|
||||||
|
continue # mileage
|
||||||
|
elif not title and len(line) > 5:
|
||||||
|
title = line
|
||||||
|
|
||||||
|
# Fallback: extract from URL
|
||||||
|
if not title and link:
|
||||||
|
title = extract_title_from_url(link)
|
||||||
|
|
||||||
|
if not price:
|
||||||
|
price_el = await card.query_selector(".priceinfo")
|
||||||
|
if price_el:
|
||||||
|
price = await price_el.inner_text()
|
||||||
|
|
||||||
|
# Get image
|
||||||
|
img_el = await card.query_selector("img[src]")
|
||||||
|
img = await img_el.get_attribute("src") if img_el else ""
|
||||||
|
|
||||||
listings.append({
|
listings.append({
|
||||||
"source": "craigslist",
|
"source": "craigslist",
|
||||||
"title": title.strip(),
|
"title": title.strip(),
|
||||||
"price": price.strip(),
|
"price": price.strip(),
|
||||||
"url": link,
|
"url": link,
|
||||||
|
"image": img,
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f" Found {len(listings)} Craigslist listings")
|
print(f" Extracted {len(listings)} Craigslist listings")
|
||||||
return listings
|
return listings
|
||||||
|
|
||||||
|
|
||||||
@@ -66,28 +105,36 @@ async def search_cargurus(page) -> list[dict]:
|
|||||||
await page.wait_for_timeout(3000)
|
await page.wait_for_timeout(3000)
|
||||||
|
|
||||||
listings = []
|
listings = []
|
||||||
cards = await page.query_selector_all(
|
# Try multiple selector patterns for CarGurus
|
||||||
"[data-cg-ft='car-blade'], .pazLpc, article"
|
cards = await page.query_selector_all("article, [data-cg-ft='car-blade'], a[href*='/Cars/']")
|
||||||
)
|
|
||||||
for card in cards[:50]:
|
for card in cards[:50]:
|
||||||
try:
|
try:
|
||||||
title_el = await card.query_selector(
|
text = await card.inner_text()
|
||||||
"h4, .pazLpc a, [data-cg-ft='car-blade-link']"
|
lines = [l.strip() for l in text.split("\n") if l.strip()]
|
||||||
)
|
|
||||||
price_el = await card.query_selector(
|
title = lines[0] if lines else "Unknown"
|
||||||
"[data-cg-ft='car-blade-price'], .pazLpc .JzvSTe"
|
price = ""
|
||||||
)
|
for line in lines:
|
||||||
title = await title_el.inner_text() if title_el else "Unknown"
|
if "$" in line and any(c.isdigit() for c in line):
|
||||||
price = await price_el.inner_text() if price_el else "N/A"
|
price = line
|
||||||
|
break
|
||||||
|
|
||||||
link_el = await card.query_selector("a[href*='/Cars/']")
|
link_el = await card.query_selector("a[href*='/Cars/']")
|
||||||
|
if not link_el:
|
||||||
|
link_el = card if await card.get_attribute("href") else None
|
||||||
link = await link_el.get_attribute("href") if link_el else ""
|
link = await link_el.get_attribute("href") if link_el else ""
|
||||||
if link and not link.startswith("http"):
|
if link and not link.startswith("http"):
|
||||||
link = f"https://www.cargurus.com{link}"
|
link = f"https://www.cargurus.com{link}"
|
||||||
|
|
||||||
|
img_el = await card.query_selector("img[src*='cargurus']")
|
||||||
|
img = await img_el.get_attribute("src") if img_el else ""
|
||||||
|
|
||||||
listings.append({
|
listings.append({
|
||||||
"source": "cargurus",
|
"source": "cargurus",
|
||||||
"title": title.strip(),
|
"title": title[:100].strip(),
|
||||||
"price": price.strip(),
|
"price": price.strip(),
|
||||||
"url": link,
|
"url": link,
|
||||||
|
"image": img,
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
@@ -97,7 +144,7 @@ async def search_cargurus(page) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
async def search_autotempest(page) -> list[dict]:
|
async def search_autotempest(page) -> list[dict]:
|
||||||
"""AutoTempest -- meta-aggregator, searches multiple sites."""
|
"""AutoTempest -- meta-aggregator."""
|
||||||
url = (
|
url = (
|
||||||
"https://www.autotempest.com/results"
|
"https://www.autotempest.com/results"
|
||||||
"?zip=98077&maxprice=10000&fuel=hybrid&radius=50"
|
"?zip=98077&maxprice=10000&fuel=hybrid&radius=50"
|
||||||
@@ -107,22 +154,33 @@ async def search_autotempest(page) -> list[dict]:
|
|||||||
await page.wait_for_timeout(5000)
|
await page.wait_for_timeout(5000)
|
||||||
|
|
||||||
listings = []
|
listings = []
|
||||||
cards = await page.query_selector_all(
|
cards = await page.query_selector_all(".result-list-item, .listing, [class*='result']")
|
||||||
".result-row, .listing-row, [class*='result']"
|
|
||||||
)
|
|
||||||
for card in cards[:50]:
|
for card in cards[:50]:
|
||||||
try:
|
try:
|
||||||
title_el = await card.query_selector(".title, h3, a")
|
text = await card.inner_text()
|
||||||
price_el = await card.query_selector(".price, [class*='price']")
|
if len(text.strip()) < 10:
|
||||||
title = await title_el.inner_text() if title_el else "Unknown"
|
continue
|
||||||
price = await price_el.inner_text() if price_el else "N/A"
|
|
||||||
|
lines = [l.strip() for l in text.split("\n") if l.strip()]
|
||||||
|
title = lines[0] if lines else "Unknown"
|
||||||
|
price = ""
|
||||||
|
for line in lines:
|
||||||
|
if "$" in line and any(c.isdigit() for c in line):
|
||||||
|
price = line
|
||||||
|
break
|
||||||
|
|
||||||
link_el = await card.query_selector("a[href]")
|
link_el = await card.query_selector("a[href]")
|
||||||
link = await link_el.get_attribute("href") if link_el else ""
|
link = await link_el.get_attribute("href") if link_el else ""
|
||||||
|
|
||||||
|
img_el = await card.query_selector("img[src]")
|
||||||
|
img = await img_el.get_attribute("src") if img_el else ""
|
||||||
|
|
||||||
listings.append({
|
listings.append({
|
||||||
"source": "autotempest",
|
"source": "autotempest",
|
||||||
"title": title.strip(),
|
"title": title[:100].strip(),
|
||||||
"price": price.strip(),
|
"price": price.strip(),
|
||||||
"url": link,
|
"url": link,
|
||||||
|
"image": img,
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
@@ -131,43 +189,9 @@ async def search_autotempest(page) -> list[dict]:
|
|||||||
return listings
|
return listings
|
||||||
|
|
||||||
|
|
||||||
async def search_facebook(page) -> list[dict]:
|
|
||||||
"""Facebook Marketplace -- view at browser.ampbox.io to log in if needed."""
|
|
||||||
url = "https://www.facebook.com/marketplace/seattle/vehicles/?maxPrice=10000"
|
|
||||||
print(f" Opening Facebook Marketplace...")
|
|
||||||
await page.goto(url, wait_until="networkidle", timeout=60000)
|
|
||||||
|
|
||||||
if "login" in page.url.lower():
|
|
||||||
print(" Facebook needs login -- open https://browser.ampbox.io/vnc.html to log in")
|
|
||||||
print(" Press Enter when done...")
|
|
||||||
await asyncio.get_event_loop().run_in_executor(None, input)
|
|
||||||
|
|
||||||
await page.wait_for_timeout(3000)
|
|
||||||
|
|
||||||
listings = []
|
|
||||||
cards = await page.query_selector_all("[class*='marketplace']")
|
|
||||||
for card in cards[:30]:
|
|
||||||
try:
|
|
||||||
text = await card.inner_text()
|
|
||||||
link_el = await card.query_selector("a[href*='/marketplace/']")
|
|
||||||
link = await link_el.get_attribute("href") if link_el else ""
|
|
||||||
listings.append({
|
|
||||||
"source": "facebook",
|
|
||||||
"title": text[:100].strip(),
|
|
||||||
"price": "see listing",
|
|
||||||
"url": f"https://www.facebook.com{link}" if link else "",
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
print(f" Found {len(listings)} Facebook listings")
|
|
||||||
return listings
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
print(f"Car Search -- {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
print(f"Car Search -- {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
||||||
print("Looking for: Used hybrids under $10K within 50mi of 98077 (Woodinville WA)")
|
print("Looking for: Used hybrids under $10K within 50mi of 98077 (Woodinville WA)")
|
||||||
print("Live browser: https://browser.ampbox.io/vnc.html")
|
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
|
|
||||||
all_listings: list[dict] = []
|
all_listings: list[dict] = []
|
||||||
@@ -183,7 +207,7 @@ async def main():
|
|||||||
)
|
)
|
||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
|
|
||||||
for search_fn in [search_craigslist, search_cargurus, search_autotempest, search_facebook]:
|
for search_fn in [search_craigslist, search_cargurus, search_autotempest]:
|
||||||
try:
|
try:
|
||||||
results = await search_fn(page)
|
results = await search_fn(page)
|
||||||
all_listings.extend(results)
|
all_listings.extend(results)
|
||||||
@@ -216,11 +240,11 @@ async def main():
|
|||||||
print(f"Total: {len(all_listings)} listings found")
|
print(f"Total: {len(all_listings)} listings found")
|
||||||
print(f"Saved to: {output_file}")
|
print(f"Saved to: {output_file}")
|
||||||
|
|
||||||
for listing in all_listings[:20]:
|
for listing in all_listings[:10]:
|
||||||
print(f" [{listing['source']}] {listing['price']:>8s} {listing['title']}")
|
print(f" [{listing['source']}] {listing['price']:>8s} {listing['title'][:60]}")
|
||||||
|
|
||||||
if len(all_listings) > 20:
|
if len(all_listings) > 10:
|
||||||
print(f" ... and {len(all_listings) - 20} more (see {output_file})")
|
print(f" ... and {len(all_listings) - 10} more")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user