"""market_analyzer_parts.py — Per-part eBay market analysis using parts.txt + ScrapingBee + Perplexity + DeepSeek.
TODO: SEND CAR IMAGES TO CHATGPT VISION AND ASK TO DESCRIBE THE CAR AND THE PARTS
describe the parts you see, describe the car and part conditions, what types are there, what kind of uniqueness you see
And save that data to and use for future reference and analysis. Use them for image generation and description generation.
For each part in parts.txt:
1. Searches eBay (active + sold) — auto-calculates pages from result count ÷ 240, cap 30
2. Saves EVERY scraped listing (title, price, url, condition, image)
3. Fetches detail pages for representative items
4. Queries Perplexity sonar-pro for OEM numbers, variants, demand, buyer concerns
5. DeepSeek call 1 (deepseek-reasoner) — Demand analysis: size, competition, sell-speed
6. DeepSeek call 2 (deepseek-reasoner) — Inventory analysis: what variants/types exist
7. DeepSeek call 3 (deepseek-reasoner) — Strategy: pricing, buyer psychology, risks
8. DeepSeek call 4 (deepseek-reasoner) — Listings: one listing per distinct physical unit
9. DeepSeek call 5 (deepseek-reasoner, optional) — Damage assessment per-part
10. Saves result incrementally after each part
Final output:
- Per-part JSON with all raw listings, extended stats, and all 5 analysis layers
- Total potential earnings + clarifying questions grouped by part
Usage:
python3 market_analyzer_parts.py \\
--year 2009 --make Honda --model Pilot \\
--color Silver --paint-code NH-623M \\
--transmission "Automatic 5-speed" --mileage 145000 \\
--condition "Used - Good" --extra-notes "no accidents, 2nd owner" \\
[--parts parts.txt]
[--start 0 --end 20]
[--max-pages 30] # hard ceiling (auto-calc from result count)
[--detail-pages 5]
[--output reports/parts_analysis.json]
[--skip-search]
[--concurrency 2]
"""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import re
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from urllib.parse import quote
import aiohttp
import openai
from bs4 import BeautifulSoup
sys.path.insert(0, str(Path(__file__).parent))
from toomuch.config import CONFIG
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SCRAPINGBEE_API_KEY = CONFIG["SCRAPINGBEE_API_KEY"]
SCRAPINGBEE_URL = "https://app.scrapingbee.com/api/v1"
DEEPSEEK_API_KEY = CONFIG["DEEPSEEK_API_KEY"]
DEEPSEEK_CHAT = CONFIG.get("DEEPSEEK_MODEL", "deepseek-chat")
DEEPSEEK_REASONER = CONFIG.get("DEEPSEEK_REASONER_MODEL", "deepseek-reasoner")
PERPLEXITY_API_KEY = CONFIG.get("PERPLEXITY_API_KEY", "")
PERPLEXITY_MODEL = CONFIG.get("PERPLEXITY_MODEL", "sonar-pro")
EBAY_COOKIES = "lc=en-US;ebay=%5Esbf%3D%2340000000000100000000007089ed9fff%5E"
ITEMS_PER_PAGE = 240
MAX_PAGES_HARD_LIMIT = 30
# ---------------------------------------------------------------------------
# Utility helpers
# ---------------------------------------------------------------------------
def _safe_float(value: Any) -> Optional[float]:
if value is None:
return None
s = re.sub(r"[^\d.]", "", str(value))
try:
return float(s) if s else None
except ValueError:
return None
def _price_stats(prices: List[float]) -> Dict[str, Any]:
if not prices:
return {"count": 0, "min": None, "max": None, "avg": None,
"p25": None, "p50": None, "p75": None, "p90": None}
s = sorted(prices)
n = len(s)
def pct(p: float) -> float:
idx = (n - 1) * p / 100
lo, hi = int(idx), min(int(idx) + 1, n - 1)
return round(s[lo] + (s[hi] - s[lo]) * (idx - lo), 2)
return {
"count": n,
"min": round(min(s), 2),
"max": round(max(s), 2),
"avg": round(sum(s) / n, 2),
"p25": pct(25),
"p50": pct(50),
"p75": pct(75),
"p90": pct(90),
}
def _extended_stats(listings: List[Dict]) -> Dict[str, Any]:
"""Compute condition breakdown, price buckets, and free-shipping rate."""
conditions: Counter = Counter()
buckets: Counter = Counter()
free_ship = 0
for l in listings:
cond = (l.get("condition") or "Unknown").strip() or "Unknown"
conditions[cond] += 1
p = l.get("price")
if p is not None:
if p < 25: buckets["$0–25"] += 1
elif p < 50: buckets["$25–50"] += 1
elif p < 100: buckets["$50–100"] += 1
elif p < 200: buckets["$100–200"] += 1
elif p < 500: buckets["$200–500"] += 1
else: buckets["$500+"] += 1
if l.get("shipping") == 0.0:
free_ship += 1
total = len(listings) or 1
return {
"condition_breakdown": dict(conditions.most_common(10)),
"price_buckets": dict(buckets),
"free_shipping_pct": round(free_ship / total * 100, 1),
}
def _fmt_price(v: Optional[float]) -> str:
return f"{v:.2f}" if v is not None else "N/A"
def _parse_json_obj(text: str) -> Optional[Dict]:
"""Extract first {...} JSON object from text, handling markdown fences."""
text = re.sub(r"^```(?:json)?\s*", "", text.strip())
text = re.sub(r"\s*```$", "", text)
idx = text.find("{")
if idx == -1:
return None
depth, in_str, esc_next, end_idx = 0, False, False, -1
for i in range(idx, len(text)):
c = text[i]
if esc_next:
esc_next = False
continue
if c == "\\":
esc_next = True
continue
if c == '"':
in_str = not in_str
if not in_str:
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
end_idx = i
break
if end_idx == -1:
return None
try:
return json.loads(text[idx: end_idx + 1])
except json.JSONDecodeError:
return None
# ---------------------------------------------------------------------------
# Title cleaning + deduplication
# ---------------------------------------------------------------------------
_JUNK_RE = re.compile(
r"\bOpens?\s+in\s+a\s+new\s+window\b"
r"|\(For:\s*[^)]+\)"
r"|\bNew\s+Listing\b"
r"|\bTop\s+Rated\b"
r"|\bFast\s+'?N?\s*Free\b"
r"|\bSponsor(?:ed)?\b",
re.I,
)
def _clean_title(raw: str) -> str:
return re.sub(r"\s+", " ", _JUNK_RE.sub(" ", raw)).strip()
def extract_unique_titles(listings: List[Dict], max_titles: int = 120) -> List[str]:
seen: Set[str] = set()
out: List[str] = []
for l in listings:
cleaned = _clean_title(l.get("title") or "")
norm = cleaned.lower()
if cleaned and norm not in seen:
seen.add(norm)
out.append(cleaned)
if len(out) >= max_titles:
break
return out
# ---------------------------------------------------------------------------
# ScrapingBee session
# ---------------------------------------------------------------------------
BEE_MAX_CONCURRENT = 6 # hard cap on simultaneous ScrapingBee HTTP requests
class BeeSession:
def __init__(self) -> None:
self._session: Optional[aiohttp.ClientSession] = None
self.request_count = 0
self._sem: Optional[asyncio.Semaphore] = None # lazy — created inside event loop
def _get_sem(self) -> asyncio.Semaphore:
if self._sem is None:
self._sem = asyncio.Semaphore(BEE_MAX_CONCURRENT)
return self._sem
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=120),
connector=aiohttp.TCPConnector(ssl=False, limit=BEE_MAX_CONCURRENT + 2),
)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
@staticmethod
def _fmt_exc(exc: Exception) -> str:
"""Return a non-empty description regardless of exception type.
Some aiohttp exceptions (ServerDisconnectedError, etc.) have empty str().
repr() always includes the class name and is never blank.
"""
msg = str(exc).strip()
return msg if msg else repr(exc)
def _build_params(
self,
url: str,
*,
render_js: bool = True,
wait_ms: int = 7000,
wait_for: Optional[str] = None,
premium: bool = False,
stealth: bool = False,
) -> Dict[str, str]:
params: Dict[str, str] = {
"api_key": SCRAPINGBEE_API_KEY,
"url": url,
"render_js": str(render_js).lower(),
"country_code": "us",
"wait": str(wait_ms),
"cookies": EBAY_COOKIES,
"forward_headers": "true",
}
if stealth:
params["stealth_proxy"] = "true"
elif premium:
params["premium_proxy"] = "true"
if wait_for:
params["wait_for"] = wait_for
return params
async def fetch(
self,
url: str,
*,
render_js: bool = True,
wait_ms: int = 7000,
wait_for: Optional[str] = None,
max_retries: int = 4,
) -> Optional[BeautifulSoup]:
short_url = url[:90] + "…" if len(url) > 90 else url
for attempt in range(1, max_retries + 1):
premium = attempt == 2
stealth = attempt >= 3
params = self._build_params(
url, render_js=render_js, wait_ms=wait_ms,
wait_for=wait_for, premium=premium, stealth=stealth,
)
self.request_count += 1
label = "stealth" if stealth else ("premium" if premium else "standard")
try:
session = await self._get_session()
async with self._get_sem():
async with session.get(SCRAPINGBEE_URL, params=params) as resp:
if resp.status != 200:
snippet = (await resp.text())[:400]
print(
f" [bee] HTTP {resp.status} ({label}, attempt {attempt})"
f"\n URL : {short_url}"
f"\n Body: {snippet}"
)
if attempt < max_retries:
await asyncio.sleep(min(2 ** attempt, 30))
continue
content = await resp.read()
await asyncio.sleep(1.5)
return BeautifulSoup(content, "html.parser")
except Exception as exc:
err = self._fmt_exc(exc)
print(
f" [bee] {type(exc).__name__} ({label}, attempt {attempt}): {err}"
f"\n URL: {short_url}"
)
# Close the stale session so the next attempt gets a fresh one.
if self._session and not self._session.closed:
await self._session.close()
self._session = None
if attempt < max_retries:
await asyncio.sleep(min(2 ** attempt, 30))
return None
async def ping(self) -> bool:
"""Quick connectivity check against ScrapingBee. Returns True on success."""
params = self._build_params(
"https://httpbin.org/status/200",
render_js=False,
wait_ms=3000,
)
try:
session = await self._get_session()
async with session.get(
SCRAPINGBEE_URL, params=params,
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
return resp.status == 200
except Exception as exc:
print(f" [bee-ping] {type(exc).__name__}: {self._fmt_exc(exc)}")
return False
# ---------------------------------------------------------------------------
# eBay search URL builder
# ---------------------------------------------------------------------------
def build_search_url(query: str, listing_type: str, page: int, items_per_page: int = ITEMS_PER_PAGE) -> str:
base = "https://www.ebay.com/sch/6030/i.html"
params = [
f"_nkw={quote(query)}",
f"_ipg={items_per_page}",
"_ul=US",
"_fcid=1",
]
if listing_type == "sold":
params += ["LH_Sold=1", "LH_Complete=1", "rt=nc"]
if page > 1:
params.append(f"_pgn={page}")
return f"{base}?{'&'.join(params)}"
# ---------------------------------------------------------------------------
# Search result parser
# ---------------------------------------------------------------------------
def parse_search_page(soup: BeautifulSoup, listing_type: str) -> List[Dict[str, Any]]:
results: List[Dict[str, Any]] = []
container = soup.find("div", id="srp-river-results") or soup
elements = container.select("ul li.s-card") or container.select(".s-card") or []
if not elements:
elements = container.select("li.s-item") or []
for el in elements:
title_el = el.select_one(".s-card__title, .s-item__title")
if not title_el:
continue
title = _clean_title(title_el.get_text(strip=True))
if not title or title.lower() in {"shop on ebay"}:
continue
link = el.find("a", href=re.compile(r"/itm/\d+"))
if not link:
continue
url = link.get("href", "")
if not url.startswith("http"):
url = "https://www.ebay.com" + url
m = re.search(r"/itm/(\d+)", url)
if not m:
continue
item_id = m.group(1)
price_el = el.select_one(".s-card__price, .s-item__price")
price_txt = price_el.get_text(strip=True) if price_el else ""
price_txt = re.split(r"\s+to\s+", price_txt, flags=re.I)[0]
price = _safe_float(price_txt)
shipping: Optional[float] = None
ship_el = el.select_one(".s-item__shipping, .s-item__logisticsCost")
if ship_el:
ship_txt = ship_el.get_text(strip=True)
shipping = 0.0 if re.search(r"free", ship_txt, re.I) else _safe_float(ship_txt)
elif re.search(r"free\s+shipping", str(el), re.I):
shipping = 0.0
cond_el = el.select_one(".SECONDARY_INFO, .s-item__subtitle")
condition = cond_el.get_text(strip=True) if cond_el else ""
img_el = el.find("img")
img_src = None
if img_el:
img_src = img_el.get("src") or img_el.get("data-src")
if img_src and img_src.startswith("//"):
img_src = "https:" + img_src
# clean URL to canonical form
cm = re.search(r"(https?://www\.ebay\.com/itm/\d+)", url)
clean_url = cm.group(1) if cm else url
results.append({
"item_id": item_id,
"title": title,
"price": price,
"url": clean_url,
"shipping": shipping,
"condition": condition,
"listing_type": listing_type,
"image_url": img_src,
})
return results
def extract_result_count(soup: BeautifulSoup) -> Optional[int]:
"""Read total result count from eBay SRP page."""
for sel in (
".srp-controls__count-heading",
".srp-controls__count",
"[class*='srp-controls__count']",
):
el = soup.select_one(sel)
if el:
m = re.search(r"([\d,]+)", el.get_text())
if m:
return int(m.group(1).replace(",", ""))
return None
# ---------------------------------------------------------------------------
# Detail page extractor
# ---------------------------------------------------------------------------
def extract_detail_page(soup: BeautifulSoup, url: str) -> Dict[str, Any]:
out: Dict[str, Any] = {"url": url}
h1 = (
soup.select_one("h1.x-item-title__mainTitle span")
or soup.select_one("h1[class*='title'] span")
or soup.select_one("h1")
)
out["title"] = h1.get_text(strip=True) if h1 else ""
price_el = (
soup.select_one(".x-price-primary span[itemprop='price']")
or soup.select_one(".x-price-primary")
or soup.select_one("[itemprop='price']")
)
out["price"] = _safe_float(
price_el.get("content") or (price_el.get_text() if price_el else None)
)
cond = (
soup.select_one(".x-item-condition-text .clipped")
or soup.select_one(".x-item-condition-text")
or soup.select_one("[class*='condition']")
)
out["condition"] = cond.get_text(strip=True) if cond else ""
specifics: Dict[str, str] = {}
for section in soup.select(".ux-layout-section-evo__row"):
labels = section.select(".ux-labels-values__labels")
values = section.select(".ux-labels-values__values")
for lbl, val in zip(labels, values):
k = lbl.get_text(strip=True).rstrip(":")
v = val.get_text(" ", strip=True)
if k and v:
specifics[k] = v
if not specifics:
for row in soup.select(".itemAttr tr, .attrLabels + td"):
cells = row.find_all("td")
if len(cells) >= 2:
k = cells[0].get_text(strip=True).rstrip(":")
v = cells[1].get_text(strip=True)
if k and v:
specifics[k] = v
out["specifics"] = specifics
variations: List[str] = []
for opt in soup.select(".x-msku-select-menu option, .msku-sel-menu option"):
txt = opt.get_text(strip=True)
if txt and txt.lower() not in {"select", "choose", "--"}:
variations.append(txt)
out["variations"] = variations[:25]
compat = soup.select_one(".ux-section-compatibility-cars, [class*='compatibility']")
out["compatibility"] = compat.get_text(separator=" ", strip=True)[:500] if compat else ""
desc_text = ""
for candidate in [
soup.select_one("#descTabsPage"),
soup.select_one(".vim.d-item-description"),
soup.select_one("[class*='item-description']"),
soup.select_one("#desc_ifr"),
]:
if candidate:
desc_text = candidate.get_text(separator="\n", strip=True)
if len(desc_text) > 50:
break
out["description"] = desc_text[:2500]
seller_el = (
soup.select_one(".x-sellercard-atf__info__about-seller")
or soup.select_one("[class*='seller-info']")
)
out["seller_info"] = seller_el.get_text(separator=" ", strip=True)[:300] if seller_el else ""
sold_el = soup.select_one(".x-quantity__availability span, [class*='sold-count']")
out["sold_qty_shown"] = sold_el.get_text(strip=True) if sold_el else ""
return out
# ---------------------------------------------------------------------------
# AI clients
# ---------------------------------------------------------------------------
async def _ds_call(
client: openai.AsyncOpenAI,
prompt: str,
model: str,
label: str,
max_tokens: int = 4000,
) -> Optional[Dict]:
"""Single DeepSeek call with retries. Returns parsed JSON or None."""
for attempt in range(1, 6):
try:
resp = await client.chat.completions.create(
model=model,
temperature=0.15,
max_tokens=max_tokens,
messages=[{"role": "user", "content": prompt}],
)
raw = resp.choices[0].message.content.strip()
result = _parse_json_obj(raw)
if result is None:
print(f" [{label}] JSON parse failed — storing raw snippet")
return {"raw_response": raw[:2000]}
return result
except Exception as exc:
if attempt == 5:
print(f" [{label}] failed after 5 attempts: {exc}")
return {"error": str(exc)}
wait = 2 ** attempt
print(f" [{label}] retry {attempt}/4 in {wait}s — {type(exc).__name__}: {exc}")
await asyncio.sleep(wait)
return None
async def _perplexity_research(
client: openai.AsyncOpenAI,
year: int,
make: str,
model: str,
part_name: str,
) -> str:
query = (
f"Used {year} {make} {model} {part_name} — automotive parts market research. "
f"Cover: (1) OEM part numbers for this vehicle, (2) all major variants "
f"(trim levels, sub-types, OEM vs aftermarket, generation differences), "
f"(3) typical used resale price range in the US, "
f"(4) demand level — is this part commonly searched/bought?, "
f"(5) known quality issues, common defects, buyer concerns, "
f"(6) what eBay buyers search for — keywords, fitment filters, conditions, "
f"(7) how many sellers typically list this — is the market saturated? "
f"Be specific and factual."
)
for attempt in range(1, 4):
try:
resp = await client.chat.completions.create(
model=PERPLEXITY_MODEL,
messages=[
{
"role": "system",
"content": (
"You are an automotive parts market research expert. "
"Provide specific, factual information about OEM part numbers, "
"variants, demand, competition, and pricing for used auto parts."
),
},
{"role": "user", "content": query},
],
max_tokens=2000,
)
return resp.choices[0].message.content.strip()
except Exception as exc:
if attempt == 3:
print(f" [perplexity] failed: {exc}")
return f"(Perplexity unavailable: {exc})"
await asyncio.sleep(2 ** attempt)
return ""
# ---------------------------------------------------------------------------
# Prompt builders (4 focused calls)
# ---------------------------------------------------------------------------
def _sample_lines(listings: List[Dict], n: int = 40) -> str:
rows = []
for l in listings[:n]:
p = f"${l['price']:.2f}" if l.get("price") else "??"
cond = (l.get("condition") or "")[:28]
title = (l.get("title") or "")[:80]
rows.append(f" {p:>10} {cond:<30} {title}")
return "\n".join(rows) if rows else " (none)"
def _detail_block(details: List[Dict]) -> str:
parts = []
for d in details:
lines = [f"── {d.get('title') or d.get('url')} ──"]
if d.get("price"): lines.append(f" Price: ${d['price']:.2f}")
if d.get("condition"): lines.append(f" Condition: {d['condition']}")
if d.get("sold_qty_shown"):lines.append(f" Sold qty: {d['sold_qty_shown']}")
if d.get("specifics"):
lines.append(" Item Specifics:")
for k, v in list(d["specifics"].items())[:14]:
lines.append(f" {k}: {v}")
if d.get("variations"): lines.append(f" Variations: {', '.join(d['variations'][:14])}")
if d.get("compatibility"): lines.append(f" Compatibility: {d['compatibility'][:220]}")
if d.get("description"): lines.append(f" Description:\n {d['description'][:700]}")
if d.get("seller_info"): lines.append(f" Seller: {d['seller_info'][:150]}")
parts.append("\n".join(lines))
return "\n\n".join(parts) if parts else "(none fetched)"
# ── Call 1: Demand analysis (deepseek-chat, fast) ────────────────────────
DEMAND_PROMPT = """\
You are an eBay automotive parts market analyst. Analyze market demand for this part.
VEHICLE: {year} {make} {model}
PART: {part_name}
═══ SCRAPED EBAY DATA ═══
Active listings found : {active_count}
Sold listings found : {sold_count}
Sell-through rate : {sell_through:.1f}% (sold ÷ total found)
Active price range : ${active_min}–${active_max} | avg ${active_avg} | p50 ${active_p50} | p75 ${active_p75}
Sold price range : ${sold_min}–${sold_max} | avg ${sold_avg} | p50 ${sold_p50} | p75 ${sold_p75}
Active condition breakdown:
{active_conditions}
Sold condition breakdown:
{sold_conditions}
Price bucket distribution (active):
{active_price_buckets}
Sample active listings (price | condition | title):
{active_samples}
Sample sold listings (price | condition | title):
{sold_samples}
═══ PERPLEXITY MARKET RESEARCH ═══
{perplexity_text}
Analyze demand, competition, and market health. Return a JSON object with EXACTLY these keys:
{{
"demand_level": "HIGH | MEDIUM | LOW",
"demand_evidence": "Specific evidence from data — sell-through rate, volume, consistency of sold listings",
"total_active_found": {active_count},
"total_sold_found": {sold_count},
"sell_through_rate_pct": {sell_through:.1f},
"unique_sellers_estimate": "e.g. '40–60 active sellers' — infer from listing count, title diversity, price spread",
"market_saturation": "SATURATED | COMPETITIVE | MODERATE | OPEN",
"competition_density": "Detailed: how many sellers, price clustering, any dominant sellers at extreme prices?",
"sell_speed": "FAST (<7 days) | MEDIUM (1–4 weeks) | SLOW (>1 month) — based on sold volume vs active count",
"price_stability": "STABLE | DECLINING | RISING — is the price range consistent or shifting?",
"seasonal_demand": "Any seasonal patterns for this part? When does demand peak?",
"best_selling_condition": "Which condition grade generates the most sold listings at best prices?",
"buyer_volume_signal": "High-volume buyer market (many repeat transactions) vs niche? Evidence?",
"market_size_assessment": "Small niche (<50 sales/month), medium (50–300/month), large (300+/month) — estimate",
"demand_verdict": "One sentence summary: is demand worth selling into, and why?"
}}
Return ONLY the JSON. No markdown, no text outside the JSON.
"""
def build_demand_prompt(
part_name: str, year: int, make: str, model: str,
active: List[Dict], sold: List[Dict],
active_ext: Dict, sold_ext: Dict,
perplexity_text: str,
) -> str:
ap = _price_stats([l["price"] for l in active if l.get("price")])
sp = _price_stats([l["price"] for l in sold if l.get("price")])
total = len(active) + len(sold)
st = round(len(sold) / total * 100, 1) if total else 0.0
def _cond_lines(ext: Dict) -> str:
bd = ext.get("condition_breakdown", {})
return "\n".join(f" {k}: {v}" for k, v in bd.items()) or " (no data)"
def _bucket_lines(ext: Dict) -> str:
bd = ext.get("price_buckets", {})
return "\n".join(f" {k}: {v}" for k, v in bd.items()) or " (no data)"
return DEMAND_PROMPT.format(
year=year, make=make, model=model, part_name=part_name,
active_count=len(active), sold_count=len(sold),
sell_through=st,
active_min=_fmt_price(ap["min"]), active_max=_fmt_price(ap["max"]),
active_avg=_fmt_price(ap["avg"]), active_p50=_fmt_price(ap["p50"]),
active_p75=_fmt_price(ap["p75"]),
sold_min=_fmt_price(sp["min"]), sold_max=_fmt_price(sp["max"]),
sold_avg=_fmt_price(sp["avg"]), sold_p50=_fmt_price(sp["p50"]),
sold_p75=_fmt_price(sp["p75"]),
active_conditions=_cond_lines(active_ext),
sold_conditions=_cond_lines(sold_ext),
active_price_buckets=_bucket_lines(active_ext),
active_samples=_sample_lines(active, 40),
sold_samples=_sample_lines(sold, 40),
perplexity_text=perplexity_text or "(not available)",
)
# ── Call 2: Inventory analysis (deepseek-chat, fast) ────────────────────
INVENTORY_PROMPT = """\
You are analyzing eBay listing data to understand what product variants and types \
exist for a specific auto part.
VEHICLE: {year} {make} {model}
PART: {part_name}
═══ ALL UNIQUE TITLES FOUND ON EBAY ({title_count} unique titles from {total_listings} listings) ═══
{unique_titles}
═══ DETAIL PAGE DATA ({detail_count} pages) ═══
{detail_sections}
Analyze what variants, types, and sub-categories exist. Return a JSON object:
{{
"variant_count": <integer — how many meaningfully different variants exist>,
"main_variants": [
"Variant 1 — description and what makes it different",
"Variant 2",
"..."
],
"sub_types": [
"Sub-type 1 — e.g. halogen vs LED, complete vs bare shell, OEM vs aftermarket",
"..."
],
"oem_brands": ["OEM brand names seen in titles/specifics"],
"aftermarket_brands": ["Aftermarket brand names seen"],
"common_title_keywords": ["most frequent keywords in titles that buyers search for"],
"fitment_years": "Year range this part fits — e.g. '2003–2008 Honda Pilot' or 'all years'",
"placement_options": ["Left", "Right", "Front", "Rear", "Center", "Driver", "Passenger", "..."],
"condition_notes": "What condition info appears most in titles — OEM, tested, no cracks, etc.",
"oem_part_numbers_found": ["OEM part numbers visible in titles or item specifics"],
"assembly_vs_component": "Is this sold as a complete assembly or as an individual component? Both?",
"bundling_patterns": "Are items sold in pairs/sets? What configurations?",
"most_common_listing_format": "How do top sellers structure their titles? Pattern?",
"missing_info_in_titles": "What info do sellers leave out that buyers actually need?",
"aftermarket_oem_split_pct": "Estimate: what % of listings are OEM vs aftermarket?"
}}
Return ONLY the JSON. No markdown, no text outside the JSON.
"""
def build_inventory_prompt(
part_name: str, year: int, make: str, model: str,
active: List[Dict], sold: List[Dict],
details: List[Dict],
) -> str:
all_listings = active + sold
unique_titles = extract_unique_titles(all_listings, max_titles=120)
title_block = "\n".join(f" {i+1:>3}. {t}" for i, t in enumerate(unique_titles))
return INVENTORY_PROMPT.format(
year=year, make=make, model=model, part_name=part_name,
title_count=len(unique_titles),
total_listings=len(all_listings),
unique_titles=title_block or " (none)",
detail_count=len(details),
detail_sections=_detail_block(details),
)
# ── Call 3: Strategy analysis (deepseek-reasoner, thorough) ─────────────
STRATEGY_PROMPT = """\
You are a professional eBay automotive parts seller and market strategist.
VEHICLE: {year} {make} {model}
PART: {part_name}
═══ DEMAND ANALYSIS (from market data) ═══
{demand_summary}
═══ INVENTORY ANALYSIS (what variants/types exist) ═══
{inventory_summary}
═══ PERPLEXITY MARKET RESEARCH ═══
{perplexity_text}
═══ DETAIL PAGE INTELLIGENCE ═══
{detail_sections}
═══ YOUR DONOR CAR ═══
Color : {color}
Paint Code : {paint_code}
Transmission : {transmission}
Mileage : {mileage}
Part Condition : {default_condition}
Extra Notes : {extra_notes}
Based on all the data above, create a comprehensive sell strategy. Return a JSON object:
{{
"variants": "All specific versions/types that exist — OEM vs aftermarket, specs, \
trim/year compatibility. Which variants sell best and command higher prices?",
"quantity_strategy": "Single vs set/pair analysis with dollar numbers. \
Price premium for selling as a set? Specific recommendation with amounts.",
"sides": "Left/right/front/rear split: do buyers filter by side? \
Price delta between single and pair. List individually or together?",
"types": "Fundamentally different types (halogen vs LED, hydraulic vs electric, \
OEM vs aftermarket). Price difference by type. Which type is this donor car likely to have?",
"pricing_strategy": "Exact recommended listing price with full reasoning. \
Reference specific prices from sold data. Why some listings command a premium.",
"price_premium_reasons": [
"Reason 1 — e.g. OEM with part number commands 40% premium over unbranded",
"Reason 2 — e.g. complete assembly vs bare component adds $X value",
"Reason 3 — e.g. tested/working claim increases buyer confidence",
"Reason 4 — e.g. specific photos that prove condition",
"..."
],
"buyer_specifics": "What do buyers look for? Exact search terms, OEM part numbers they \
reference, conditions they filter on, questions they ask sellers. What builds buyer trust?",
"listing_title_formula": "Exact title template: e.g. \
'OEM [Year] [Make] [Model] [Part] [Key Spec] [Condition] Tested OEM#XXXXX'",
"listing_must_haves": [
"Photo: [exact angles — e.g. 'straight-on, 45° angle, part number label, any damage']",
"Item Specifics: [which eBay fields drive the most buyer clicks]",
"Description must mention: [specific details that close the sale and prevent returns]",
"Shipping: [box size, fragile? typical weight, carrier recommendation]",
"..."
],
"competition_analysis": "How many sellers active now, price clustering (tight or fragmented?), \
dominant sellers to watch, what separates $X listings from $Y listings in quality.",
"why_buyers_choose": "Specific reasons why a buyer clicks BUY NOW on one listing \
and ignores another. Trust signals, presentation factors, listing quality elements.",
"listing_differentiation": "How do top listings differ from mediocre ones? \
Title keywords, photo count/quality, specifics completeness, description language, \
shipping speed, seller feedback. Minimum viable listing vs premium listing.",
"risk_assessment": "Demand stability (seasonal?), common defects to inspect, \
fragile/breakable aspects, typical return reasons, return risk level, shipping difficulty.",
"go_no_go": "SELL / AVOID / CONDITIONAL — followed by one-sentence rationale.",
"action_plan": [
"Step 1: ...",
"Step 2: ...",
"..."
]
}}
Return ONLY the JSON. No markdown, no text outside the JSON.
"""
def build_strategy_prompt(
part_name: str, year: int, make: str, model: str,
demand_report: Dict, inventory_report: Dict,
details: List[Dict], perplexity_text: str, donor_car: Dict,
) -> str:
def _summarize(d: Dict, label: str) -> str:
if not d or "raw_response" in d or "error" in d:
return f"({label} not available)"
return json.dumps(d, indent=2)[:3000]
return STRATEGY_PROMPT.format(
year=year, make=make, model=model, part_name=part_name,
demand_summary=_summarize(demand_report, "demand"),
inventory_summary=_summarize(inventory_report, "inventory"),
perplexity_text=perplexity_text or "(not available)",
detail_sections=_detail_block(details),
color=donor_car.get("color", "Not specified"),
paint_code=donor_car.get("paint_code", "Not specified"),
transmission=donor_car.get("transmission", "Not specified"),
mileage=donor_car.get("mileage", "Not specified"),
default_condition=donor_car.get("condition", "Used - Good"),
extra_notes=donor_car.get("extra_notes", "—"),
)
# ── Call 5: Damage assessment (deepseek-chat, fast) ─────────────────────
DAMAGE_PROMPT = """\
You are an automotive parts damage assessor. Given a description of damage on a donor car, \
determine whether a specific part is likely affected by that damage.
VEHICLE: {year} {make} {model}
PART: {part_name}
═══ DONOR CAR DAMAGE DESCRIPTION ═══
{damage_description}
═══ PART CONTEXT (from market analysis) ═══
Variants/types that exist: {variants_summary}
Placement options: {placement_summary}
Assembly vs component: {assembly_summary}
Based ONLY on the damage description and what you know about this part's location/function \
on a {year} {make} {model}, assess whether this specific part is damaged.
Return a JSON object:
{{
"is_damaged": true | false,
"damage_confidence": "HIGH | MEDIUM | LOW",
"affected_variants": [
"Specific variant/side/position likely damaged — e.g. 'Driver-side headlight assembly'",
"..."
],
"undamaged_variants": [
"Specific variant/side/position likely undamaged — e.g. 'Passenger-side headlight assembly'",
"..."
],
"damage_severity": "TOTAL_LOSS | HEAVY | MODERATE | COSMETIC | NONE",
"damage_reasoning": "Explain WHY the damage description does or does not affect this part. \
Be specific about location, proximity to damage area, and typical failure modes.",
"listing_impact": {{
"adjusted_condition": "Used - For Parts Only | Used - Acceptable | Used - Good | Not affected",
"price_adjustment_pct": <integer — e.g. -50 means 50% price reduction, 0 means no change>,
"price_adjustment_reason": "Why this price adjustment",
"listing_disclosure": "Exact text to include in the eBay listing description to disclose damage honestly",
"still_sellable": true | false,
"sell_as_parts_only": true | false
}},
"inspection_checklist": [
"Specific thing to check on this part before listing — e.g. 'Check lens for cracks'",
"..."
]
}}
Return ONLY the JSON. No markdown, no text outside the JSON.
"""
def build_damage_prompt(
part_name: str, year: int, make: str, model: str,
damage_description: str,
inventory_report: Dict,
strategy_report: Dict,
) -> str:
variants = inventory_report.get("main_variants") or []
placement = inventory_report.get("placement_options") or []
assembly = inventory_report.get("assembly_vs_component") or "Unknown"
strat_vars = strategy_report.get("variants") or ""
variants_summary = (
"; ".join(variants[:6]) if variants else str(strat_vars)[:300] or "Unknown"
)
placement_summary = (
", ".join(placement[:8]) if placement else "Unknown"
)
return DAMAGE_PROMPT.format(
year=year, make=make, model=model, part_name=part_name,
damage_description=damage_description or "(none provided)",
variants_summary=variants_summary,
placement_summary=placement_summary,
assembly_summary=str(assembly)[:200],
)
# ── Call 4: Listing synthesis (deepseek-reasoner, thorough) ─────────────
LISTINGS_PROMPT = """\
You are a professional eBay automotive parts seller creating optimized listings.
VEHICLE: {year} {make} {model}
PART: {part_name}
═══ YOUR DONOR CAR ═══
Color : {color}
Paint Code : {paint_code}
Transmission : {transmission}
Mileage : {mileage}
Part Condition : {default_condition}
Extra Notes : {extra_notes}
═══ KNOWN VARIANTS & PLACEMENTS (from eBay inventory scan) ═══
{variants_checklist}
═══ SELL STRATEGY ═══
{strategy_summary}
═══ DEMAND CONTEXT ═══
{demand_context}
TASK — Create one eBay listing object per DISTINCT PHYSICAL UNIT the donor car likely has.
Rules:
1. If the part has Left/Right OR Driver/Passenger placements that exist as separate physical \
units, generate ONE listing per side — NEVER merge them into a single listing.
2. If a "set of N" configuration sells at a meaningful premium AND the donor has multiple \
units, add a SEPARATE set listing in addition to individual listings.
3. Cross out variants the donor car CANNOT have based on its trim/spec (e.g. non-M BMW won't \
have M-Sport airbag; base trim won't have sport-package parts).
4. Do NOT generate listings for variants the donor car clearly does not have.
5. Generate up to 8 listing objects total.
Return a JSON object:
{{
"optimal_price": <number — price for the single most common unit>,
"confidence": <1–10 — confidence based on data richness>,
"clarifying_questions": [
"Q: [Question the seller MUST answer — specific to THIS part. \
Only ask what is genuinely needed: color/paint code for body panels, \
tested/working status for electronics/mechanicals, engine mileage for drivetrain, \
visible OEM part number, exact sub-type if ambiguous. DO NOT ask generic questions.]",
"..."
],
"listings": [
{{
"listing_title": "Exact eBay title ≤ 80 chars — include year/make/model/part/placement/key spec",
"recommended_price": <number>,
"quantity": <integer — physical units in this listing>,
"quantity_label": "Single | Pair | Set of N | Left | Right | Driver | Passenger | ...",
"variant_key": "Short label identifying this specific unit, e.g. 'Driver-side', 'Left Curtain', 'Set of 4'",
"seo_description_hints": "Key phrases and claims to include in the eBay description for SEO and conversion",
"part_specifics": {{
"Part": "<exact part name>",
"Make": "{make}",
"Model": "{model}",
"Year": "{year}",
"Placement": "<Left | Right | Front | Rear | Driver | Passenger | Center | N/A>",
"Color": "<color — only if relevant to this part>",
"Condition": "<Used - Good | Used - Acceptable | Used - Fair>",
"Quantity": "<1 | 2 | Set of N | ...>",
"Donor Car Paint Code": "{paint_code}",
"OEM Part Number": "<only if confirmed — OMIT key entirely if unknown>",
"<any other eBay item specifics buyers actively filter on>": "<value>"
}},
"notes": "<one sentence: what makes this specific listing configuration worth listing separately>",
"go_no_go": "SELL | AVOID | CONDITIONAL",
"go_no_go_reason": "<one sentence rationale for this specific unit>"
}}
]
}}
Only include Transmission in part_specifics when it genuinely affects fitment. \
For clarifying_questions: only ask what is GENUINELY required for THIS specific part — \
no generic questions. Return ONLY the JSON. No markdown, no text outside the JSON.
"""
def build_listings_prompt(
part_name: str, year: int, make: str, model: str,
strategy_report: Dict, demand_report: Dict, donor_car: Dict,
inventory_report: Optional[Dict] = None,
) -> str:
def _summarize(d: Dict, label: str, max_len: int = 3000) -> str:
if not d or "raw_response" in d or "error" in d:
return f"({label} not available)"
return json.dumps(d, indent=2)[:max_len]
demand_ctx = {
k: demand_report.get(k)
for k in ("demand_level", "total_sold_found", "total_active_found",
"sell_through_rate_pct", "unique_sellers_estimate",
"market_saturation", "sell_speed", "demand_verdict")
if demand_report.get(k) is not None
}
# Build an explicit variants/placements checklist from inventory data
inv = inventory_report or {}
vc_lines: List[str] = []
if inv.get("main_variants"):
vc_lines.append("Main variants found on eBay:")
for v in inv["main_variants"][:10]:
vc_lines.append(f" • {v}")
if inv.get("placement_options"):
vc_lines.append(f"Placement options: {', '.join(inv['placement_options'][:12])}")
if inv.get("sub_types"):
vc_lines.append("Sub-types:")
for st in inv["sub_types"][:8]:
vc_lines.append(f" • {st}")
if inv.get("assembly_vs_component"):
vc_lines.append(f"Assembly vs component: {inv['assembly_vs_component']}")
if inv.get("bundling_patterns"):
vc_lines.append(f"Bundling/set patterns: {inv['bundling_patterns']}")
if inv.get("fitment_years"):
vc_lines.append(f"Fitment years: {inv['fitment_years']}")
# also pull variants text from strategy if inventory is sparse
if not vc_lines and strategy_report.get("variants"):
vc_lines.append("Strategy variants summary:")
vc_lines.append(f" {str(strategy_report['variants'])[:500]}")
if strategy_report.get("sides"):
vc_lines.append(f"Sides/positions: {str(strategy_report['sides'])[:300]}")
variants_checklist = "\n".join(vc_lines) or "(not available — infer from strategy)"
return LISTINGS_PROMPT.format(
year=year, make=make, model=model, part_name=part_name,
variants_checklist=variants_checklist,
strategy_summary=_summarize(strategy_report, "strategy", 4000),
demand_context=json.dumps(demand_ctx, indent=2),
color=donor_car.get("color", "Not specified"),
paint_code=donor_car.get("paint_code", "Not specified"),
transmission=donor_car.get("transmission", "Not specified"),
mileage=donor_car.get("mileage", "Not specified"),
default_condition=donor_car.get("condition", "Used - Good"),
extra_notes=donor_car.get("extra_notes", "—"),
)
# ---------------------------------------------------------------------------
# Main analyzer
# ---------------------------------------------------------------------------
class PartsAnalyzer:
def __init__(
self,
year: int,
make: str,
model: str,
page_limit: int = MAX_PAGES_HARD_LIMIT,
detail_pages_n: int = 5,
skip_search: bool = False,
donor_car: Optional[Dict] = None,
) -> None:
self.year = year
self.make = make
self.model = model
self.page_limit = min(page_limit, MAX_PAGES_HARD_LIMIT)
self.detail_pages_n = detail_pages_n
self.skip_search = skip_search
self.donor_car = donor_car or {}
self.damage_description = (donor_car or {}).get("damage_description", "")
self.bee = BeeSession()
self._ds = openai.AsyncOpenAI(
api_key=DEEPSEEK_API_KEY,
base_url="https://api.deepseek.com/v1",
)
self.perplexity = (
openai.AsyncOpenAI(
api_key=PERPLEXITY_API_KEY,
base_url="https://api.perplexity.ai",
)
if PERPLEXITY_API_KEY else None
)
# ── Scraping ─────────────────────────────────────────────────────────
async def _scrape_search(self, query: str, listing_type: str) -> List[Dict]:
all_listings: List[Dict] = []
seen: Set[str] = set()
pages_to_scrape = self.page_limit
page = 1
while page <= pages_to_scrape:
url = build_search_url(query, listing_type, page)
wait_for = ".srp-controls__count-heading" if page == 1 else "#srp-river-results"
soup = await self.bee.fetch(url, wait_for=wait_for)
if not soup:
print(f" [{listing_type} p{page}] fetch failed")
break
if page == 1:
total_count = extract_result_count(soup)
if total_count is not None:
needed = math.ceil(total_count / ITEMS_PER_PAGE)
pages_to_scrape = min(needed, self.page_limit)
print(
f" [{listing_type}] {total_count:,} results → "
f"{needed} pages needed, scraping {pages_to_scrape} "
f"(limit {self.page_limit})"
)
else:
print(f" [{listing_type}] result count unreadable, using limit={self.page_limit}")
page_listings = parse_search_page(soup, listing_type)
new = [l for l in page_listings if l["item_id"] not in seen]
for l in new:
seen.add(l["item_id"])
all_listings.extend(new)
print(
f" [{listing_type} p{page}/{pages_to_scrape}] "
f"{len(page_listings)} listings, {len(new)} new → total {len(all_listings)}"
)
if not page_listings:
break
page += 1
return all_listings
def _pick_detail_urls(self, active: List[Dict], sold: List[Dict], n: int) -> List[str]:
candidates: List[Dict] = []
sold_sorted = sorted([l for l in sold if l.get("price") and l.get("url")], key=lambda x: x["price"])
if sold_sorted:
candidates.append(sold_sorted[-1])
candidates.append(sold_sorted[0])
if len(sold_sorted) > 2:
candidates.append(sold_sorted[len(sold_sorted) // 2])
active_sorted = sorted([l for l in active if l.get("price") and l.get("url")], key=lambda x: x["price"])
if active_sorted:
candidates.append(active_sorted[-1])
if len(active_sorted) > 1:
candidates.append(active_sorted[0])
seen_ids: Set[str] = set()
urls: List[str] = []
for c in candidates:
iid = str(c.get("item_id") or "")
if iid and iid not in seen_ids and c.get("url"):
seen_ids.add(iid)
m = re.search(r"(https?://www\.ebay\.com/itm/\d+)", c["url"])
urls.append(m.group(1) if m else c["url"])
if len(urls) >= n:
break
return urls
async def _fetch_detail(self, url: str) -> Optional[Dict]:
soup = await self.bee.fetch(
url, wait_ms=9000, wait_for=".x-item-title__mainTitle", max_retries=3
)
return extract_detail_page(soup, url) if soup else None
# ── 4-call analysis ───────────────────────────────────────────────────
async def analyze_part(self, part_name: str, rank: int) -> Dict[str, Any]:
print(f"\n{'─'*68}")
print(f" [{rank}] {part_name}")
# ── 1. Scrape eBay ────────────────────────────────────────────────
active: List[Dict] = []
sold: List[Dict] = []
if not self.skip_search:
query = f"{self.make} {self.model} {self.year} {part_name}"
print(f" Searching eBay: {query!r}")
active, sold = await asyncio.gather(
self._scrape_search(query, "active"),
self._scrape_search(query, "sold"),
)
print(f" eBay totals: {len(active)} active, {len(sold)} sold "
f"({len(active)+len(sold)} items saved)")
else:
print(" [skip-search] no eBay scraping")
# ── 2. Detail pages ───────────────────────────────────────────────
detail_urls = self._pick_detail_urls(active, sold, self.detail_pages_n)
print(f" Fetching {len(detail_urls)} detail page(s)…")
detail_results = await asyncio.gather(
*[self._fetch_detail(u) for u in detail_urls],
return_exceptions=True,
)
details = [d for d in detail_results if isinstance(d, dict)]
print(f" Got {len(details)} detail pages")
# ── 3. Extended stats ─────────────────────────────────────────────
ap = _price_stats([l["price"] for l in active if l.get("price")])
sp = _price_stats([l["price"] for l in sold if l.get("price")])
active_ext = _extended_stats(active)
sold_ext = _extended_stats(sold)
total = len(active) + len(sold)
str_pct = round(len(sold) / total * 100, 1) if total else None
unique_titles = extract_unique_titles(active + sold, max_titles=120)
# ── 4. Perplexity ─────────────────────────────────────────────────
if self.perplexity:
perplexity_text = await _perplexity_research(
self.perplexity, self.year, self.make, self.model, part_name
)
else:
perplexity_text = "(PERPLEXITY_API_KEY not set)"
print(f" Perplexity: {len(perplexity_text)} chars")
# ── 5. DeepSeek call 1: Demand analysis (deepseek-chat) ───────────
print(f" [1/4] DeepSeek demand analysis (chat)…")
demand_prompt = build_demand_prompt(
part_name, self.year, self.make, self.model,
active, sold, active_ext, sold_ext, perplexity_text,
)
demand_report = await _ds_call(self._ds, demand_prompt, DEEPSEEK_CHAT,
"demand", max_tokens=3000)
print(f" demand_level={demand_report.get('demand_level','?')} "
f"saturation={demand_report.get('market_saturation','?')}")
# ── 6. DeepSeek call 2: Inventory analysis (deepseek-chat) ───────
print(f" [2/4] DeepSeek inventory analysis (chat)…")
inventory_prompt = build_inventory_prompt(
part_name, self.year, self.make, self.model,
active, sold, details,
)
inventory_report = await _ds_call(self._ds, inventory_prompt, DEEPSEEK_CHAT,
"inventory", max_tokens=3000)
print(f" variant_count={inventory_report.get('variant_count','?')} "
f"placement={inventory_report.get('placement_options','?')}")
# ── 7. DeepSeek call 3: Strategy analysis (deepseek-reasoner) ────
print(f" [3/4] DeepSeek strategy analysis (reasoner)…")
strategy_prompt = build_strategy_prompt(
part_name, self.year, self.make, self.model,
demand_report, inventory_report, details, perplexity_text, self.donor_car,
)
strategy_report = await _ds_call(self._ds, strategy_prompt, DEEPSEEK_REASONER,
"strategy", max_tokens=5000)
print(f" go_no_go={strategy_report.get('go_no_go','?')[:30]}")
# ── 8. DeepSeek call 4: Listing synthesis (deepseek-reasoner) ────
call_label = "4/5" if self.damage_description else "4/4"
print(f" [{call_label}] DeepSeek listing synthesis (reasoner)…")
listings_prompt = build_listings_prompt(
part_name, self.year, self.make, self.model,
strategy_report, demand_report, self.donor_car,
inventory_report=inventory_report,
)
listings_report = await _ds_call(self._ds, listings_prompt, DEEPSEEK_REASONER,
"listings", max_tokens=5000)
print(f" optimal_price=${listings_report.get('optimal_price','?')} "
f"confidence={listings_report.get('confidence','?')}/10 "
f"listings={len(listings_report.get('listings',[]))}")
# ── 9. DeepSeek call 5: Damage assessment (deepseek-chat) ────────
damage_report: Dict = {}
if self.damage_description:
print(f" [5/5] DeepSeek damage assessment (chat)…")
damage_prompt = build_damage_prompt(
part_name, self.year, self.make, self.model,
self.damage_description, inventory_report, strategy_report,
)
damage_report = await _ds_call(self._ds, damage_prompt, DEEPSEEK_CHAT,
"damage", max_tokens=2000)
is_dmg = damage_report.get("is_damaged", False)
severity = damage_report.get("damage_severity", "?")
conf = damage_report.get("damage_confidence", "?")
sellable = damage_report.get("listing_impact", {}).get("still_sellable", "?")
print(f" is_damaged={is_dmg} severity={severity} "
f"confidence={conf} still_sellable={sellable}")
# ── 11. Merge all analysis layers ─────────────────────────────────
analysis: Dict[str, Any] = {}
# Layer 1 — demand
for k in ("demand_level", "demand_evidence", "total_active_found", "total_sold_found",
"sell_through_rate_pct", "unique_sellers_estimate", "market_saturation",
"competition_density", "sell_speed", "price_stability", "seasonal_demand",
"best_selling_condition", "buyer_volume_signal", "market_size_assessment",
"demand_verdict"):
if demand_report.get(k) is not None:
analysis[k] = demand_report[k]
# Layer 2 — inventory
for k in ("variant_count", "main_variants", "sub_types", "oem_brands",
"aftermarket_brands", "common_title_keywords", "fitment_years",
"placement_options", "condition_notes", "oem_part_numbers_found",
"assembly_vs_component", "bundling_patterns", "most_common_listing_format",
"missing_info_in_titles", "aftermarket_oem_split_pct"):
if inventory_report.get(k) is not None:
analysis[k] = inventory_report[k]
# Layer 3 — strategy
for k in ("variants", "quantity_strategy", "sides", "types", "pricing_strategy",
"price_premium_reasons", "buyer_specifics", "listing_title_formula",
"listing_must_haves", "competition_analysis", "why_buyers_choose",
"listing_differentiation", "risk_assessment", "go_no_go", "action_plan"):
if strategy_report.get(k) is not None:
analysis[k] = strategy_report[k]
# Layer 4 — listings
for k in ("optimal_price", "confidence", "clarifying_questions", "listings"):
if listings_report.get(k) is not None:
analysis[k] = listings_report[k]
# Layer 5 — damage assessment
if damage_report:
for k in ("is_damaged", "damage_confidence", "affected_variants",
"undamaged_variants", "damage_severity", "damage_reasoning",
"listing_impact", "inspection_checklist"):
if damage_report.get(k) is not None:
analysis[k] = damage_report[k]
else:
analysis["is_damaged"] = False
analysis["damage_severity"] = "NONE"
# Store raw sub-reports for debugging / re-analysis
analysis["_demand_report"] = demand_report
analysis["_inventory_report"] = inventory_report
analysis["_strategy_report"] = strategy_report
analysis["_listings_report"] = listings_report
analysis["_damage_report"] = damage_report
return {
"rank": rank,
"part_name": part_name,
"ebay_stats": {
"active": ap,
"sold": sp,
"sell_through_pct": str_pct,
"unique_titles_count": len(unique_titles),
"active_extended": active_ext,
"sold_extended": sold_ext,
},
"scraped_listings": {
"active": active,
"sold": sold,
},
"unique_titles": unique_titles,
"detail_pages": details,
"detail_pages_fetched": len(details),
"bee_requests_so_far": self.bee.request_count,
"perplexity_research": perplexity_text,
"analysis": analysis,
}
async def close(self) -> None:
await self.bee.close()
# ---------------------------------------------------------------------------
# Parts file reader
# ---------------------------------------------------------------------------
def load_parts(path: Path) -> List[str]:
parts: List[str] = []
with open(path, encoding="utf-8") as f:
for line in f:
stripped = line.strip()
if stripped and not stripped.startswith("#"):
parts.append(stripped)
return parts
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Per-part eBay analysis from parts.txt using ScrapingBee + Perplexity + DeepSeek (4-call pipeline)",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("--year", type=int, required=True, help="Vehicle year (e.g. 2009)")
p.add_argument("--make", required=True, help="Vehicle make (e.g. Honda)")
p.add_argument("--model", required=True, help="Vehicle model (e.g. Pilot)")
p.add_argument("--parts", default="parts.txt", help="Parts list file (one part per line)")
p.add_argument("--start", type=int, default=0, help="Start index in parts list")
p.add_argument("--end", type=int, default=None, help="End index (exclusive). Default: all")
p.add_argument("--max-pages", type=int, default=MAX_PAGES_HARD_LIMIT,
help=f"Hard page ceiling per listing type (auto-calc from result count ÷ {ITEMS_PER_PAGE})")
p.add_argument("--detail-pages", type=int, default=5, help="Detail pages to fetch per part")
p.add_argument("--output", default=None, help="Output JSON path (auto-named if omitted)")
p.add_argument("--skip-search", action="store_true", help="Skip eBay scraping")
p.add_argument("--concurrency", type=int, default=2, help="Parallel parts (keep ≤3 to save credits)")
donor = p.add_argument_group("donor car details")
donor.add_argument("--color", default="Not specified", help="Exterior color (e.g. Silver)")
donor.add_argument("--paint-code", default="Not specified", help="Paint code (e.g. NH-623M)")
donor.add_argument("--transmission", default="Not specified", help="Transmission type")
donor.add_argument("--mileage", default="Not specified", help="Odometer reading")
donor.add_argument("--condition", default="Used - Good", help="Default part condition")
donor.add_argument("--extra-notes", default="—", help="Additional donor car notes")
donor.add_argument(
"--damage", default="",
help=(
"Description of damage on the donor car. When set, a 5th DeepSeek call runs "
"after each part's analysis to assess whether that part is affected. "
"Example: \"front-end collision — cracked bumper, bent hood, broken driver headlight, "
"airbags deployed\""
),
)
return p.parse_args()
async def main() -> None:
args = parse_args()
parts_path = Path(args.parts)
if not parts_path.exists():
sys.exit(f"Parts file not found: {parts_path}")
all_parts = load_parts(parts_path)
if not all_parts:
sys.exit(f"No parts found in {parts_path}")
start = max(0, args.start)
end = min(len(all_parts), args.end if args.end is not None else len(all_parts))
parts = all_parts[start:end]
print(f"Vehicle : {args.year} {args.make} {args.model}")
print(f"Parts : {parts_path} ({len(all_parts)} total, processing {len(parts)} [{start}:{end}])")
print(
f"Donor : color={args.color} paint={args.paint_code} "
f"trans={args.transmission} mileage={args.mileage} cond={args.condition}"
)
if args.damage:
print(f"Damage : {args.damage}")
n_calls = 5 if args.damage else 4
print(f"Pipeline: {n_calls} DeepSeek calls per part "
f"({DEEPSEEK_CHAT} for demand+inventory{'+ damage' if args.damage else ''}, "
f"{DEEPSEEK_REASONER} for strategy+listings)")
donor_car = {
"color": args.color,
"paint_code": args.paint_code,
"transmission": args.transmission,
"mileage": args.mileage,
"condition": args.condition,
"extra_notes": args.extra_notes,
"damage_description": args.damage,
}
vehicle_slug = f"{args.year}_{args.make}_{args.model}"
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
out_path = (
Path(args.output)
if args.output
else Path("reports") / f"parts_analysis_{vehicle_slug}_{start}_{end}_{ts}.json"
)
out_path.parent.mkdir(parents=True, exist_ok=True)
analyzer = PartsAnalyzer(
year=args.year,
make=args.make,
model=args.model,
page_limit=args.max_pages,
detail_pages_n=args.detail_pages,
skip_search=args.skip_search,
donor_car=donor_car,
)
# ── Connectivity check ────────────────────────────────────────────────
if not args.skip_search:
print("Checking ScrapingBee connectivity…", end=" ", flush=True)
ok = await analyzer.bee.ping()
if ok:
print("✓ OK")
else:
print("✗ FAILED")
print(
" ScrapingBee ping failed. Possible causes:\n"
" • Invalid or expired SCRAPINGBEE_API_KEY in config\n"
" • No internet / firewall blocking outbound HTTPS\n"
" • ScrapingBee service outage\n"
" Re-run with --skip-search to test the AI pipeline without scraping."
)
await analyzer.close()
sys.exit(1)
output: Dict[str, Any] = {
"vehicle": f"{args.year} {args.make} {args.model}",
"donor_car": donor_car,
"parts_file": str(parts_path),
"range": {"start": start, "end": end},
"generated_at": ts,
"analyses": [],
}
concurrency = max(1, args.concurrency)
semaphore = asyncio.Semaphore(concurrency)
save_lock = asyncio.Lock()
print(f"Concurrency: {concurrency} part{'s' if concurrency != 1 else ''} at a time\n")
async def process_one(part_name: str, rank: int) -> Dict[str, Any]:
print(f" → queued [{rank:>3}] {part_name}")
async with semaphore:
result = await analyzer.analyze_part(part_name, rank)
async with save_lock:
output["analyses"].append(result)
output["analyses"].sort(key=lambda x: x.get("rank", 0))
with open(out_path, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, ensure_ascii=False)
an = result["analysis"]
go = an.get("go_no_go", "?")
price = an.get("optimal_price", "?")
conf = an.get("confidence", "?")
demand = an.get("demand_level", "?")
sat = an.get("market_saturation", "?")
n_lst = len(an.get("listings") or [])
print(
f" ✓ done [{rank:>3}] {part_name:<38} "
f"{str(go)[:12]:<12} ${str(price):>7} conf={conf}/10 "
f"demand={demand} sat={sat} listings={n_lst} "
f"(bee:{analyzer.bee.request_count})"
)
return result
try:
tasks = [process_one(p, start + i + 1) for i, p in enumerate(parts)]
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, r in enumerate(results):
if isinstance(r, Exception):
print(f" ✕ failed [{start+i+1:>3}] {parts[i]}: {r}")
finally:
await analyzer.close()
# ── Final summary ─────────────────────────────────────────────────────
print(f"\n{'═'*80}")
print(f"Done. {len(output['analyses'])} parts analyzed.")
print(f"Total ScrapingBee requests: {analyzer.bee.request_count}")
print(f"Total listings scraped : "
f"{sum(len(a.get('scraped_listings',{}).get('active',[])) + len(a.get('scraped_listings',{}).get('sold',[])) for a in output['analyses'])}")
print(f"Output: {out_path}")
has_damage = bool(args.damage)
if has_damage:
print(f"\n{'Part':<40} {'Go/No-go':<14} {'Damage':<10} {'Severity':<10} {'Demand':<8} {'Price':>8} {'Conf':>4}")
print("─" * 100)
else:
print(f"\n{'Part':<40} {'Go/No-go':<14} {'Demand':<8} {'Saturation':<14} {'Price':>8} {'Conf':>4}")
print("─" * 92)
sell_total = 0.0
cond_total = 0.0
sell_count = 0
cond_count = 0
damaged_parts: List[str] = []
for a in output["analyses"]:
an = a.get("analysis", {})
go = str(an.get("go_no_go", "?"))
price = an.get("optimal_price") or 0
conf = an.get("confidence", "?")
demand = str(an.get("demand_level", "?"))[:8]
sat = str(an.get("market_saturation", "?"))[:14]
is_dmg = an.get("is_damaged", False)
severity = str(an.get("damage_severity", "NONE"))[:10]
dmg_flag = "DAMAGED" if is_dmg else "OK"
if has_damage:
print(
f" {a['part_name']:<38} {go[:14]:<14} {dmg_flag:<10} {severity:<10} "
f"{demand:<8} ${str(an.get('optimal_price','?')):>7} {conf}/10"
)
else:
print(f" {a['part_name']:<38} {go[:14]:<14} {demand:<8} {sat:<14} ${str(an.get('optimal_price','?')):>7} {conf}/10")
for lst in an.get("listings", []):
qty = lst.get("quantity_label", "")
lprice = lst.get("recommended_price", "?")
title = lst.get("listing_title", "")[:50]
print(f" → [{qty:<10}] ${str(lprice):>7} {title}")
if is_dmg:
damaged_parts.append(a["part_name"])
go_upper = go.upper()
if "SELL" in go_upper and "AVOID" not in go_upper:
sell_total += float(price); sell_count += 1
elif "CONDITIONAL" in go_upper:
cond_total += float(price); cond_count += 1
if has_damage and damaged_parts:
print(f"\n{'─'*80}")
print(f"DAMAGED PARTS ({len(damaged_parts)}):")
for pname in damaged_parts:
an = next((a["analysis"] for a in output["analyses"] if a["part_name"] == pname), {})
sev = an.get("damage_severity", "?")
reason = an.get("damage_reasoning", "")[:120]
impact = an.get("listing_impact", {})
adj = impact.get("price_adjustment_pct", 0)
disc = impact.get("listing_disclosure", "")[:100]
print(f" • {pname} [{sev}] adj={adj:+d}%")
print(f" Reason : {reason}")
print(f" Disclose: {disc}")
ebay_fee = 0.1325
print(f"\n{'═'*80}")
print(f"EARNINGS ESTIMATE — {args.year} {args.make} {args.model}")
print(f"{'─'*80}")
print(f" SELL parts ({sell_count:>3}): ${sell_total:>10,.2f} gross")
print(f" CONDITIONAL parts ({cond_count:>3}): ${cond_total:>10,.2f} gross (if conditions met)")
combined = sell_total + cond_total
after_fees = combined * (1 - ebay_fee)
print(f" Combined gross: ${combined:>10,.2f}")
print(f" After eBay fees (~13%): ${after_fees:>10,.2f}")
print(f" Excludes: shipping costs, packaging, part removal labor.")
all_questions: List[Tuple[str, List[str]]] = []
for a in output["analyses"]:
an = a.get("analysis", {})
go = str(an.get("go_no_go", "")).upper()
if "AVOID" in go and "SELL" not in go and "CONDITIONAL" not in go:
continue
qs = [q for q in an.get("clarifying_questions", []) if q]
if qs:
all_questions.append((a["part_name"], qs))
if all_questions:
total_q = sum(len(qs) for _, qs in all_questions)
print(f"\n{'═'*80}")
print(f"CLARIFYING QUESTIONS BEFORE LISTING ({total_q} questions, {len(all_questions)} parts)")
print("─" * 80)
for part, qs in all_questions:
print(f"\n [{part}]")
for q in qs:
print(f" • {q}")
if __name__ == "__main__":
asyncio.run(main())
Workflows from the Neura Market marketplace related to this Perplexity resource