Ebaylister Perplexity Rules — Free Perplexity Rules Template
    Neura MarketNeura Market/Perplexity
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityDeepSeekDeepSeek
    CoPilotCoPilotStable DiffusionStable DiffusionMidjourneyMidjourney
    View All Directories
    OverviewRulesPromptsMCPsAgentsGamesBlogVideosGuidesCoursesCommunityTrending
    PerplexityRulesEbaylister Perplexity Rules
    Back to Rules

    Ebaylister Perplexity Rules

    batyok32 July 19, 2026
    0 copies 0 downloads
    Rule Content
    """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())
    

    Comments

    More Rules

    View all

    Textbased Predictor Perplexity Rules

    H
    Havcker243

    Intelligent Team Building Recommendation System Perplexity Rules

    R
    royswastik

    Pita Perplexity Rules

    S
    SaratBobbili

    Eskrev Perplexity Rules

    R
    rfmss

    Founder Control Room Perplexity Rules

    J
    jussray

    N8nWorkflows Perplexity Rules

    S
    Samarth-ITM

    Stay up to date

    Get the latest Perplexity prompts, rules, and resources delivered to your inbox weekly.

    Neura Market LogoNeura Market

    Discover the best AI prompts, plugins, and resources for Perplexity and more.

    Content Types

    • Rules
    • Prompts
    • MCPs
    • Agents
    • Guides

    Platforms

    • ChatGPT Directory
    • Claude Directory
    • Gemini Directory
    • Cursor Directory
    • Grok Directory
    • Perplexity Directory
    • DeepSeek Directory
    • CoPilot Directory
    • Stable Diffusion Directory
    • Midjourney Directory
    • All Directories

    Resources

    • Blog
    • Documentation
    • Help Center
    • Marketplace

    Legal

    • Privacy Policy
    • Terms of Service

    © 2026 Neura Market. All rights reserved.

    |

    Not affiliated with any AI platform vendors.

    Neura Market

    Custom AI Systems & Services

    Our team of experienced AI builders will help build custom AI systems, workflows, and solutions for your business.

    Request custom work

    Ready-made automations for this

    Workflows from the Neura Market marketplace related to this Perplexity resource

    • Automated Lead Generation & Contact Enrichment with Hunter.io and Perplexity AIn8n · $24.99 · Related topic
    • Automate SEO-Optimized Blog Creation with GPT-4, Perplexity AI & Multi-Language Supportn8n · $24.99 · Related topic
    • Automate SEO Blog Content Creation with GPT-4, Perplexity AI, and WordPressn8n · $24.99 · Related topic
    • Automate SEO Blog Creation + Social Media with GPT-4, Perplexity, and WordPressn8n · $24.99 · Related topic
    Browse all workflows