Skill: pangolinfo/pangolinfo-amazon-product-explorer (version 2.0.0)

--- name: pangolinfo-amazon-product-explorer description: > This skill serves as an advanced Amazon Product Discovery and Market Research Engine (powered by Pangolinfo API). It executes a complex, multi-step Go-To-Market (GTM) research SOP. It is strictly designed for 'Zero-to-One' new product development, niche market validation, market monopoly analysis, consumer pain-point extraction (via external SERP and Amazon reviews), and WIPO trademark risk screening. metadata: openclaw: emoji: "🔭" os: ["darwin", "linux"] requires: env: - PANGOLINFO_API_KEY - PANGOLINFO_EMAIL - PANGOLINFO_PASSWORD notes: "Auth: set PANGOLINFO_API_KEY (recommended) OR PANGOLINFO_EMAIL + PANGOLINFO_PASSWORD. All bundled scripts share the same credentials." tags: ["amazon", "product-explorer", "market-research", "fba", "ecommerce", "niche-hunting", "data-analysis", "business-intelligence", "亚马逊", "选品", "市场调研"] version: 1.0.2 homepage: https://pangolinfo.com/?referrer=clawhub_product_discovery --- ## 📦 Bundled Tools (Built-in Capabilities) This is a **Super Skill** that bundles multiple underlying Pangolinfo APIs out-of-the-box. No extra installation required: - **Amazon Niche & Search** - **Amazon Scraper (ASIN/Reviews)** - **AI SERP (Google)** - **WIPO Trademark Check** ## 🤖 Compatible Agent Frameworks - **OpenClaw** (Native super-skill for autonomous GTM workflows) - **LangGraph / CrewAI** (Easily ported as a multi-step research tool) ### Tool Description **✅ WHEN TO USE (Trigger Scenarios):** - **New Product Discovery:** Use when the user has no product yet and asks for high-margin product recommendations, blue-ocean niches, or category trends (e.g., "What are some profitable niches right now?", "Help me find a good product to sell"). - **Market Validation:** Use when the user wants to evaluate the feasibility of entering a specific new niche (e.g., "Is it profitable to start selling [Product X]?", "Analyze the top-brand monopoly, search volume, and return rates for this category"). - **Consumer Pain-point Mining:** Use when the user wants to uncover product defects or unmet needs for a potential new product by scraping external forums (Reddit/Quora) or Amazon critical reviews. - **Compliance & Risk Screening:** Use when the user needs to check WIPO trademark risks or patent red flags before sourcing a new product. **❌ WHEN NOT TO USE (Strict Negative Boundaries):** - **DO NOT** use this skill if the user is asking to track daily keyword rankings, monitor specific competitor price drops, or analyze daily market trends for their _currently selling/existing_ products. (Route these to the [REDACTED_SECRET] skill instead). - **DO NOT** use this skill if the user is asking to write, rewrite, or optimize Amazon Titles, Bullet Points (Five Features), A+ Content, or SEO Search Terms. (Route these to the `pangolinfo-listing-optimization` skill instead). - **DO NOT** use this skill for basic, single-data-point queries (e.g., "What is the price of ASIN XYZ today?"). This tool is meant for comprehensive, strategic market analysis. --- ### Bundled Scripts This skill is a flat toolkit — all Python scripts are under `scripts/`: | Script | Capability | Typical Invocation | |---|---|---| | `scripts/ai_serp.py` | Google SERP + AI Overview | `python3 scripts/ai_serp.py --q "<query>" --mode ai-mode` | | `scripts/amazon_scraper.py` | Amazon ASIN / keyword / reviews | `python3 scripts/amazon_scraper.py --asin <ASIN> --site amz_us` | | `scripts/amazon_niche.py` | Amazon niche / category filter | `python3 scripts/amazon_niche.py --api niche-filter --marketplace-id ATVPDKIKX0DER --niche-title "<keyword>"` | | `scripts/wipo.py` | WIPO design / trademark lookup | `python3 scripts/wipo.py --q "<term>"` | Reference docs for each capability are in `references/` (prefixed by capability name). --- ### Skill System Prompt / SOP ```xml # Role & Persona You are "Lobster", a Senior Amazon Growth Navigator and Data-Driven E-commerce Consultant. Your primary function is to execute a rigorous, multi-step Go-To-Market (GTM) Product Discovery SOP using the Pangolinfo Data Engine. You provide sellers with highly actionable, data-backed insights, from macro niche filtering to micro ASIN tear-downs and WIPO compliance checks. # 🛑 ABSOLUTE RULES (STRICT MANDATES) 1. <Single_Auth_Rule>: All Pangolinfo tools (serp, scraper, niche, wipo) share the SAME API Key/Auth. Once validated/cached, NEVER ask the user for their API Key again. 2. <Data_Integrity_Rule>: You MUST rely ONLY on data fetched via APIs. NO HALLUCINATION. If data is missing, explicitly state "Data unavailable/requires manual fetch". NEVER invent search volumes, conversion rates, or rankings. 3. <Third_Party_Tool_Rule>: NEVER proactively mention external tools (e.g., Keepa, Sif, SellerSprite). If data is lacking, stay silent. If the user asks, reply politely: "If you can provide reports from third-party tools, I can perform deeper cross-analysis." 4. <Default_Marketplace_Rule>: Unless specified, ALL searches, metrics, and API calls MUST default to Amazon US (`marketplaceId: ATVPDKIKX0DER`) and use US Zip Code `90001` (Los Angeles). 5. <Close_Competitor_Definition>: True competitors are NOT just those adjacent on the BSR list. They are the ASINs fiercely competing for the top organic slots on the SERP for the Top 3 core conversion keywords. 6. <Language_Adaptation_Rule>: You MUST dynamically detect the language used by the user in their prompt. ALL your final outputs, including greetings, warnings, intermediate prompts, and the final GTM report, MUST be generated in the SAME language the user used. 7. <Single_Tool_Mode_Rule>: If the user's request is a simple, single-operation query that matches ONE bundled script's capability (e.g., "search Google for X", "look up ASIN B0XXX", "check WIPO for trademark Y"), DO NOT execute the full discovery SOP. Instead, directly invoke the corresponding script under `scripts/`. Only execute the full 9-step SOP when the user explicitly requests product selection, niche discovery, or GTM strategy. # 🏁 ONBOARDING (Initialization) When invoked by the user for the first time, you MUST output the following welcome message (TRANSLATE it naturally into the user's language): "🎉 Welcome to Lobster, your Amazon Growth Navigator! 🏎️ In this fierce Amazon race, you hit the gas, and I read the pace notes. Powered by the Pangolinfo Data Engine, I will help you accurately detect blue-ocean niches and price tiers. *(Note: Gemini 3.0 or above is recommended for the best experience. Please ensure your Pangolinfo API Key is configured. New drivers can register at pangolinfo.com to get 60 free credits!)*" # ⚙️ EXECUTION WORKFLOW (The SOP) Execute the following steps sequentially in the background. DO NOT expose the raw API JSON or intermediate technical steps to the final user. ## Phase 1: Discovery & Macro Filtering - Step 1 [Seed Extraction]: Extract the core noun from the user's prompt as `{Seed_Keyword}`. - Step 2 [AI SERP Concept Expansion]: - Call `pangolinfo-ai-serp` using Google Dorks to extract trend forecasts from geek forums/media: ```bash python3 scripts/ai_serp.py --q "<dork>" --mode ai-mode ``` - Dork A: `intitle:"{Seed_Keyword}" ("best for" OR "used for" OR "designed for") -site:amazon.com -site:ebay.com` - Dork B: `"{Seed_Keyword}" (trend OR "new technology" OR alternative) inurl:blog OR inurl:news` - Action: Extract 5-10 long-tail "scenario/tech keywords" to form the [Candidate Niche Pool]. ## Phase 2: Micro Niche Locking & Risk Evasion - Step 3 [Amazon Data Filtering]: - Call `pangolinfo-amazon-niche`. If parameters aren't specified, inject this strict payload to block red-ocean markets: ```bash python3 scripts/amazon_niche.py --api niche-filter --marketplace-id ATVPDKIKX0DER --niche-title "<keyword>" --search-volume-t90-min 20000 --top5-brands-click-share-max 0.40 --product-count-max 300 --search-volume-growth-t90-min 0.05 --return-rate-t360-max 0.10 ``` `searchVolumeT90Min: 20000`, `top5BrandsClickShareMax: 0.40`, `productCountMax: 300`, `searchVolumeGrowthT90Min: 0.05`, `returnRateT360Max: 0.10` - Action: Extract the passing `nicheId` and `nicheTitle`. - Step 4 [Voice of Customer / Reddit Pain Points]: - Call `pangolinfo-ai-serp` (Pure Search Mode) to find raw complaints: ```bash python3 scripts/ai_serp.py --q "\"{Exact_Niche_Title}\" (\"sucks\" OR \"hate\" OR \"broken\" OR \"issue\") (site:reddit.com OR site:quora.com)" --mode serp ``` `"{Exact_Niche_Title}" ("sucks" OR "hate" OR "broken" OR "issue") (site:reddit.com OR site:quora.com)` - Action: Summarize the Top 3 consumer pain points. - Step 5 [Niche Matrix Selection]: Select 2-3 highly viable niches based on Steps 3 & 4. Strictly DO NOT provide filler/junk options. ## Phase 3: Target ASIN Extraction & WIPO Compliance - Step 6 [Double-Blind ASIN Cross-Match]: - Call `pangolinfo-amazon-scraper` (Search) for Page 1 Organic ASINs + Leaf Node IDs: ```bash python3 scripts/amazon_scraper.py --q "<niche_title>" --site amz_us ``` - Call `pangolinfo-amazon-scraper` (New Releases) for that Leaf Node: ```bash python3 scripts/amazon_scraper.py --content "<new_releases_url>" --parser amzNewReleases ``` - Action: Isolate "Benchmark ASINs" that appear BOTH on the organic Page 1 AND the New Releases list. - Step 7 [WIPO Risk Check]: - Extract category generic terms, tech modifiers, and the Brand Names of the Benchmark ASINs. - Call `pangolinfo-wipo` (Target US/Nice Classification): ```bash python3 scripts/wipo.py --q "<term>" ``` - Action: If the status is 'Active' and held by a major entity/law firm, instantly ELIMINATE that niche/keyword. ## Phase 4: Pricing Tier & Review Teardown - Step 8 [Price Stratification]: Split the surviving ASINs into Low (<P33), Mid (P33-P66), and High (>P66) tiers. - Step 9 [Critical Review Exploitation]: - Call `pangolinfo-amazon-scraper` (Amazon Reviews): ```bash python3 scripts/amazon_scraper.py --content "<review_url>" --mode review --filter-star critical --sort-by recent ``` - Payload MUST include: `filterByStar: "critical"`, `sortBy: "recent"`. - Action: Ignore FBA/shipping complaints. Retain ONLY core product defects (material, function, ergonomics, packaging). # 📊 FINAL DELIVERABLE & OUTPUT FORMAT You MUST synthesize all findings into a professional, consultant-grade "Go-To-Market (GTM) Strategy Report". **Output Language: STRICTLY match the user's input language.** Tone: Expert, decisive, and insightful. DO NOT list API call steps. Deliver business value directly. Your report MUST contain the following sections (Translate the section headers into the user's language natively): 1. [Analytical Transparency & Niche Matrix]: Explain the logic using this exact format translated to the user's language: `[Deduction Logic: Because <Data A> + <Data B>, combined with Amazon A9 algorithm traits, we deduce <Conclusion>]`. Include the Niche Matrix (Search volume, Monopoly rate, Return rate). 2. [Target ASIN Tear-down]: Explicitly list the Target ASINs. Analyze their specific traffic strategy (what keywords/main images they used to rank) and their FATAL WEAKNESSES based on Step 9 critical reviews. 3. [GTM Strategy - Production QC]: Based on the reviews, mandate the exact "Extreme Stress Tests" required during factory sampling (e.g., waterproof limits, zipper pull tests) to control return rates. 4. [GTM Strategy - Listing SEO/CRO]: Outline the core traffic keywords and the specific "Pain Point Solutions" that MUST be highlighted in the Main Image and A+ Content. 5. [IP & Compliance Warning]: List high-risk keywords strictly prohibited in the Title/Search Terms (based on WIPO data). Add a disclaimer translated to the user's language: *"AI currently cannot perform design patent image searches. Manual legal review is advised before tooling/manufacturing."* 6. [Final Investment Verdict]: Conclude with a clear traffic light recommendation: 🔴 Abandon / 🟡 Pivot or Adjust / 🟢 Safe to Launch. ``` ## 🌐 多语言适配 (Multi-language Support) - **🇨🇳 中文适用场景**: 亚马逊从0到1自动化选品与市场验证引擎。自动分析BSR、挖掘蓝海Niche、跨平台提取消费者痛点。 - **Agent Directive**: Always output the final analysis/report in the language of the user's prompt (e.g., reply in Chinese if asked in Chinese).

path: scripts/ai_serp.py
content: #!/usr/bin/env python3
"""
Pangolinfo Google SERP & AI Mode API Client

Zero-dependency Python client for Pangolinfo's Google SERP and AI Mode APIs.
Supports AI Mode search (Google AI Overviews), standard SERP results,
SERP Plus (cheaper), multi-turn dialogue, and screenshot capture.

Usage:
    pangolinfo.py --q "quantum computing"
    pangolinfo.py --q "best databases 2025" --mode serp --screenshot
    pangolinfo.py --q "best databases 2025" --mode serp-plus
    pangolinfo.py --q "kubernetes" --follow-up "how to deploy"
    pangolinfo.py --auth-only

Environment:
    PANGOLINFO_API_KEY  - API Key (skips login)
    PANGOLINFO_EMAIL    - Account email (for login)
    PANGOLINFO_PASSWORD - Account password (for login)
"""

import argparse
import io
import json
import os
import stat
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

# ---------------------------------------------------------------------------
# Fix Windows / macOS console encoding for Unicode output
# ---------------------------------------------------------------------------
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
    sys.stdout = io.TextIOWrapper(
        sys.stdout.buffer, encoding="utf-8", errors="replace"
    )
if sys.stderr.encoding and sys.stderr.encoding.lower() != "utf-8":
    sys.stderr = io.TextIOWrapper(
        sys.stderr.buffer, encoding="utf-8", errors="replace"
    )

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
API_BASE = "https://scrapeapi.pangolinfo.com"
AUTH_ENDPOINT = f"{API_BASE}/api/v1/auth"
SCRAPE_ENDPOINT = f"{API_BASE}/api/v2/scrape"
API_KEY_CACHE_PATH = Path.home() / ".pangolinfo_api_key"

# User-facing registration/top-up link (not the API endpoint)
REFERRER_TAG = "clawhub_serp"
PANGOLINFO_URL = f"https://pangolinfo.com/?referrer={REFERRER_TAG}"

# Caching credentials to disk is opt-in only.
CACHE_TO_DISK = False

EXIT_SUCCESS = 0
EXIT_API_ERROR = 1
EXIT_USAGE_ERROR = 2
EXIT_NETWORK_ERROR = 3
EXIT_AUTH_ERROR = 4

# Mode-to-parser mapping (must match backend ApiName enum values exactly)
MODE_PARSER_MAP = {
    "ai-mode": "googleAiSearch",
    "serp": "googleSearch",
    "serp-plus": "googleSearchPlus",
}

# Supported regions (must match backend GoogleRegion enum)
SUPPORTED_REGIONS = {
    "us", "uk", "it", "jp", "de", "fr", "nl", "cn",
    "au", "ca", "dk", "nz", "no", "pt", "es", "se",
}


# ---------------------------------------------------------------------------
# Unified error helper
# ---------------------------------------------------------------------------
def _emit_error(code, message, hint=None, api_code=None, exit_code=None):
    """Print a structured error envelope to stderr and optionally exit."""
    envelope = {
        "success": False,
        "error": {
            "code": code,
            "message": message,
        },
    }
    if api_code is not None:
        envelope["error"]["api_code"] = api_code
    if hint:
        envelope["error"]["hint"] = hint
    print(json.dumps(envelope, ensure_ascii=False), file=sys.stderr)
    if exit_code is not None:
        sys.exit(exit_code)


def _is_ssl_error(exc):
    """Check if an exception is SSL-related."""
    msg = str(exc)
    return "CERTIFICATE_VERIFY_FAILED" in msg or "SSL" in msg


def _emit_ssl_error():
    """Emit a standardized SSL certificate error and exit."""
    _emit_error(
        "SSL_CERT",
        "SSL certificate verification failed.",
        hint=(
            "macOS: run '/Applications/Python 3.x/Install Certificates.command' "
            "or set SSL_CERT_FILE. See: python3 -c \"import certifi; print(certifi.where())\""
        ),
        exit_code=EXIT_NETWORK_ERROR,
    )


# ---------------------------------------------------------------------------
# API key caching
# ---------------------------------------------------------------------------
def load_cached_api_key():
    """Load API key from cache file if it exists."""
    if API_KEY_CACHE_PATH.exists():
        [REDACTED_SECRET]).strip()
        if api_key and len(api_key.split(".")) == 3:
            return api_key
    return None


def save_cached_api_key(api_key):
    """Save API key to cache file (only when explicitly enabled).

    Uses atomic write with restricted permissions to avoid race conditions.
    """
    if not CACHE_TO_DISK:
        return
    # Write to a temp file with restricted permissions, then rename atomically
    try:
        fd = os.open(
            str(API_KEY_CACHE_PATH) + ".tmp",
            os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
            stat.S_IRUSR | stat.S_IWUSR,  # 0o600 from creation
        )
        with os.fdopen(fd, "w") as f:
            f.write(api_key)
        # Atomic rename (on POSIX; best-effort on Windows)
        os.replace(str(API_KEY_CACHE_PATH) + ".tmp", str(API_KEY_CACHE_PATH))
    except OSError:
        # Fallback: direct write
        API_KEY_CACHE_PATH.write_text(api_key)
        try:
            API_KEY_CACHE_PATH.chmod(0o600)
        except OSError:
            pass


# ---------------------------------------------------------------------------
# HTTP helper
# ---------------------------------------------------------------------------
def _http_post(url, body_dict, headers=None, timeout=30):
    """POST JSON and return parsed response. Handles SSL and network errors."""
    payload = json.dumps(body_dict).encode()
    req_headers = {"Content-Type": "application/json"}
    if headers:
        req_headers.update(headers)
    req = urllib.request.Request(url, data=payload, headers=req_headers, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read()
    except urllib.error.URLError as e:
        if _is_ssl_error(e):
            _emit_ssl_error()
        raise
    try:
        return json.loads(raw)
    except (json.JSONDecodeError, ValueError):
        _emit_error(
            "PARSE_ERROR",
            "API returned invalid JSON.",
            hint="The API may be temporarily unavailable. Retry in a moment.",
            exit_code=EXIT_NETWORK_ERROR,
        )


# ---------------------------------------------------------------------------
# Authentication
# ---------------------------------------------------------------------------
def authenticate(email, password):
    """Authenticate with Pangolinfo API and return an API key."""
    try:
        result = _http_post(AUTH_ENDPOINT, {"email": email, "password": password})
    except urllib.error.URLError:
        _emit_error(
            "NETWORK",
            "Network error during authentication.",
            hint="Check your internet connection and try again.",
            exit_code=EXIT_NETWORK_ERROR,
        )

    if result.get("code") != 0:
        _emit_error(
            "AUTH_FAILED",
            "Authentication failed.",
            hint="Verify PANGOLINFO_EMAIL and PANGOLINFO_PASSWORD are correct.",
            api_code=result.get("code"),
            exit_code=EXIT_AUTH_ERROR,
        )

    [REDACTED_SECRET]"data"]
    save_cached_api_key(api_key)
    return api_key


def get_api_key():
    """Resolve API key from env var, cache file, or fresh login."""
    [REDACTED_SECRET]"PANGOLINFO_API_KEY")
    if [REDACTED_SECRET])
        return api_key

    [REDACTED_SECRET])
    if [REDACTED_SECRET] api_key

    email = os.environ.get("PANGOLINFO_EMAIL")
    [REDACTED_SECRET]"PANGOLINFO_PASSWORD")
    if not email or not [REDACTED_SECRET]
            "MISSING_ENV",
            "No authentication credentials found.",
            hint=(
                "Set PANGOLINFO_API_KEY, or both PANGOLINFO_EMAIL and PANGOLINFO_PASSWORD "
                "environment variables."
            ),
            exit_code=EXIT_AUTH_ERROR,
        )

    return authenticate(email, password)


def refresh_api_key():
    """Force re-authentication using email/password."""
    email = os.environ.get("PANGOLINFO_EMAIL")
    [REDACTED_SECRET]"PANGOLINFO_PASSWORD")
    if not email or not [REDACTED_SECRET]
            "MISSING_ENV",
            "Cannot refresh API key without credentials.",
            hint="Set PANGOLINFO_EMAIL and PANGOLINFO_PASSWORD environment variables.",
            exit_code=EXIT_AUTH_ERROR,
        )
    return authenticate(email, password)


# ---------------------------------------------------------------------------
# Request building
# ---------------------------------------------------------------------------
def build_request_body(query, mode, screenshot, follow_ups, num, region=None):
    """Build request body for Google SERP / AI Mode APIs."""
    parser_name = MODE_PARSER_MAP[mode]

    if mode == "ai-mode":
        url = (
            f"https://www.google.com/search?num={num}&udm=50"
            f"&q={urllib.parse.quote_plus(query)}"
        )
    else:
        url = (
            f"https://www.google.com/search?num={num}"
            f"&q={urllib.parse.quote_plus(query)}"
        )

    body = {
        "url": url,
        "parserName": parser_name,
    }

    if mode in ("serp", "serp-plus"):
        body["format"] = "json"
        body["scrapeContext"] = {"resultNum": num}
        if region:
            body["scrapeContext"]["region"] = region

    if screenshot:
        body["screenshot"] = True

    if follow_ups:
        body["param"] = follow_ups

    return body


# ---------------------------------------------------------------------------
# API call with retry
# ---------------------------------------------------------------------------
def call_api(api_key, body, max_retries=3, timeout=120):
    """Call the scrape API with retry and exponential backoff."""
    headers = {
        "Authorization": f"Bearer {api_key}",
        "User-Agent": "Pangolinfo-CLI/2.0",
    }

    for attempt in range(max_retries):
        try:
            result = _http_post(SCRAPE_ENDPOINT, body, headers=headers, timeout=timeout)
            return result
        except urllib.error.HTTPError as e:
            error_body = ""
            try:
                error_body = e.read().decode() if e.fp else ""
            except Exception:
                pass

            if e.code == 429:
                if attempt < max_retries - 1:
                    time.sleep(2 ** attempt)
                    continue
                _emit_error(
                    "RATE_LIMIT",
                    "Rate limited by API server.",
                    hint="Wait a moment and retry, or reduce request frequency.",
                    exit_code=EXIT_NETWORK_ERROR,
                )

            if attempt < max_retries - 1:
                time.sleep(2 ** attempt)
                continue

            # Include server error details if available
            detail = ""
            if error_body:
                try:
                    err_json = json.loads(error_body)
                    detail = f" Server: {err_json.get('message', error_body[:200])}"
                except (json.JSONDecodeError, ValueError):
                    detail = f" Server: {error_body[:200]}"

            _emit_error(
                "API_ERROR",
                f"HTTP {e.code} from API.{detail}",
                hint="Check your request parameters and try again.",
                exit_code=EXIT_NETWORK_ERROR,
            )
        except urllib.error.URLError:
            # SSL errors already handled by _http_post; this is a non-SSL network error
            if attempt < max_retries - 1:
                time.sleep(2 ** attempt)
                continue
            _emit_error(
                "NETWORK",
                "Network error communicating with API.",
                hint="Check your internet connection and try again.",
                exit_code=EXIT_NETWORK_ERROR,
            )

    # Should not reach here, but guard against it
    _emit_error(
        "NETWORK",
        "API call failed after retries.",
        hint="Check your internet connection and try again.",
        exit_code=EXIT_NETWORK_ERROR,
    )


# ---------------------------------------------------------------------------
# Response handling
# ---------------------------------------------------------------------------
# Known Pangolinfo API error code hints
API_ERROR_HINTS = {
    1004: "Invalid token. Will auto-retry with fresh credentials.",
    1009: "Invalid parser name. Check --mode value.",
    2001: f"Insufficient credits. Top up at {PANGOLINFO_URL}",
    2005: f"No active plan. Subscribe at {PANGOLINFO_URL}",
    2007: f"Account expired. Renew at {PANGOLINFO_URL}",
    2009: "Usage limit reached for current billing cycle. Contact support.",
    2010: "Bill day not configured. Contact support.",
    4029: "Rate limited by server. Reduce request frequency.",
    10000: "Task execution failed. Retry or check query format.",
    10001: "Task execution failed. Likely a temporary server issue.",
}


def handle_response(result, api_key, body, timeout=120):
    """Handle API response, retrying auth on 1004 error."""
    if result.get("code") == 1004:
        new_api_key = refresh_api_key()
        return call_api(new_api_key, body, timeout=timeout)
    return result


def extract_output(result):
    """Extract structured output from Google SERP / AI Mode API response."""
    code = result.get("code")
    if code != 0:
        hint = API_ERROR_HINTS.get(code, f"Pangolinfo API error code {code}. See references/error-codes.md.")
        return {
            "success": False,
            "error": {
                "code": "API_ERROR",
                "api_code": code,
                "message": result.get("message", "Unknown API error"),
                "hint": hint,
            },
        }

    data = result.get("data", {})
    output = {
        "success": True,
        "task_id": data.get("taskId"),
    }

    json_data = data.get("json", {})

    # V1/V2 response format handling:
    # Some parsers return json as array: [{code, data: {items: [...]}}]
    # Others return json as object: {items: [...]} or {type, items: [...]}
    if isinstance(json_data, list) and len(json_data) > 0:
        inner = json_data[0]
        items = inner.get("data", {}).get("items", [])
    elif isinstance(json_data, dict):
        items = json_data.get("items", [])
    else:
        items = []

    ai_overviews = []
    organic_results = []

    for item in items:
        item_type = item.get("type")
        if item_type == "ai_overview":
            overview = {"content": [], "references": []}
            for sub in item.get("items", []):
                if sub.get("type") == "ai_overview_elem":
                    overview["content"].extend(sub.get("content", []))
            for ref in item.get("references", []):
                overview["references"].append({
                    "title": ref.get("title"),
                    "url": ref.get("url"),
                    "domain": ref.get("domain"),
                })
            ai_overviews.append(overview)
        elif item_type == "organic":
            for sub in item.get("items", []):
                organic_results.append({
                    "title": sub.get("title"),
                    "url": sub.get("url"),
                    "text": sub.get("text"),
                })

    # Use parsed counts; fall back to API-reported counts
    output["ai_overview_count"] = len(ai_overviews) if ai_overviews else data.get("ai_overview", 0)
    output["results_num"] = len(organic_results) if organic_results else data.get("results_num", 0)

    if ai_overviews:
        output["ai_overview"] = ai_overviews
    if organic_results:
        output["organic_results"] = organic_results

    screenshot_url = data.get("screenshot")
    if screenshot_url:
        output["screenshot"] = screenshot_url

    return output


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main():
    parser = argparse.ArgumentParser(
        description="Pangolinfo Google SERP & AI Mode API Client",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Examples:\n"
            '  python3 pangolinfo.py --q "what is quantum computing"\n'
            '  python3 pangolinfo.py --q "best databases 2025" --mode serp --screenshot\n'
            '  python3 pangolinfo.py --q "best databases 2025" --mode serp-plus\n'
            '  python3 pangolinfo.py --q "kubernetes" --follow-up "how to deploy"\n'
            "  python3 pangolinfo.py --auth-only\n"
        ),
    )
    parser.add_argument("--q", dest="query", help="Search query")
    parser.add_argument(
        "--mode",
        choices=["ai-mode", "serp", "serp-plus"],
        default="ai-mode",
        help="API mode (default: ai-mode)",
    )
    parser.add_argument("--screenshot", action="store_true", help="Capture page screenshot")
    parser.add_argument(
        "--follow-up",
        action="append",
        dest="follow_ups",
        help="Follow-up question (ai-mode only, repeatable, max 5 recommended)",
    )
    parser.add_argument("--num", type=int, default=10, help="Number of results (default: 10)")
    parser.add_argument(
        "--region",
        default=None,
        help="Geographic region (serp/serp-plus only). E.g., us, uk, de, jp.",
    )
    parser.add_argument("--auth-only", action="store_true", help="Auth check only")
    parser.add_argument("--raw", action="store_true", help="Output raw API response")
    parser.add_argument("--timeout", type=int, default=120, help="Request timeout in seconds (default: 120)")
    parser.add_argument(
        "--cache-key",
        action="store_true",
        help="Persist API key to ~/.pangolinfo_api_key. Also: PANGOLINFO_CACHE=1.",
    )

    args = parser.parse_args()

    # Configure optional credential caching
    global CACHE_TO_DISK
    CACHE_TO_DISK = bool(args.cache_key) or os.environ.get("PANGOLINFO_CACHE", "").strip().lower() in (
        "1", "true", "yes", "on",
    )

    # --- Input validation ---
    if not args.query and not args.auth_only:
        parser.error("--q is required unless using --auth-only")

    if args.follow_ups and args.mode != "ai-mode":
        parser.error("--follow-up is only supported in ai-mode")

    if args.region and args.mode == "ai-mode":
        parser.error("--region is only supported in serp or serp-plus mode")

    if args.region and args.region.lower() not in SUPPORTED_REGIONS:
        parser.error(
            f"Unsupported region '{args.region}'. "
            f"Supported: {', '.join(sorted(SUPPORTED_REGIONS))}"
        )

    if args.num < 1 or args.num > 100:
        parser.error("--num must be between 1 and 100")

    if args.follow_ups and len(args.follow_ups) > 5:
        print(
            json.dumps({"warning": f"Using {len(args.follow_ups)} follow-ups (>5). Response may be slower."}),
            file=sys.stderr,
        )

    # Normalize region to lowercase
    if args.region:
        args.region = args.region.lower()

    # --- Authenticate ---
    [REDACTED_SECRET])

    if args.auth_only:
        # Mask the key: show only first 4 and last 4 chars
        if len(api_key) > 12:
            preview = f"{api_key[:4]}...{api_key[-4:]}"
        else:
            preview = "***"
        print(json.dumps({
            "success": True,
            "message": "Authentication successful",
            "api_key_preview": preview,
        }, indent=2))
        sys.exit(EXIT_SUCCESS)

    # --- Build and send request ---
    body = build_request_body(
        args.query, args.mode, args.screenshot, args.follow_ups, args.num,
        region=args.region,
    )

    result = call_api(api_key, body, timeout=args.timeout)

    if result is None:
        _emit_error(
            "NETWORK",
            "API call failed after retries.",
            hint="Check your internet connection and try again.",
            exit_code=EXIT_NETWORK_ERROR,
        )

    result = handle_response(result, api_key, body, timeout=args.timeout)

    if result is None:
        _emit_error(
            "NETWORK",
            "API call failed after token refresh.",
            hint="Check your internet connection and try again.",
            exit_code=EXIT_NETWORK_ERROR,
        )

    # --- Output ---
    if args.raw:
        print(json.dumps(result, indent=2, ensure_ascii=False))
    else:
        output = extract_output(result)
        if output.get("success"):
            print(json.dumps(output, indent=2, ensure_ascii=False))
        else:
            # Errors go to stderr, matching documented behavior
            print(json.dumps(output, indent=2, ensure_ascii=False), file=sys.stderr)

    if result.get("code") != 0:
        sys.exit(EXIT_API_ERROR)

    sys.exit(EXIT_SUCCESS)


if __name__ == "__main__":
    main()


path: scripts/amazon_niche.py
content: #!/usr/bin/env python3
"""
Pangolinfo Amazon Niche Data API Client

Zero-dependency Python client for Pangolinfo's Amazon 利基 (niche) data APIs.
Covers 5 endpoints under /api/v1/amzscope/*:

    category-tree     POST /api/v1/amzscope/categories/children
    category-search   POST /api/v1/amzscope/categories/search
    category-paths    POST /api/v1/amzscope/categories/paths
    category-filter   POST /api/v1/amzscope/categories/filter
    niche-filter      POST /api/v1/amzscope/niches/filter

Usage:
    pangolinfo.py --api category-search --keyword "headphones"
    pangolinfo.py --api category-tree --parent-path "2619526011"
    pangolinfo.py --api category-paths --category-ids "2619526011,172282"
    pangolinfo.py --api category-filter --marketplace-id US --time-range l7d --sample-scope all_asin
    pangolinfo.py --api niche-filter --marketplace-id US --niche-title "yoga mat"
    pangolinfo.py --auth-only

Environment:
    PANGOLINFO_API_KEY  - API key (skips login)
    PANGOLINFO_EMAIL    - Account email (for login)
    PANGOLINFO_PASSWORD - Account password (for login)
"""

import argparse
import io
import json
import os
import stat
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

# ---------------------------------------------------------------------------
# Fix Windows / macOS console encoding for Unicode output
# ---------------------------------------------------------------------------
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
    sys.stdout = io.TextIOWrapper(
        sys.stdout.buffer, encoding="utf-8", errors="replace"
    )
if sys.stderr.encoding and sys.stderr.encoding.lower() != "utf-8":
    sys.stderr = io.TextIOWrapper(
        sys.stderr.buffer, encoding="utf-8", errors="replace"
    )

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
API_BASE = "https://scrapeapi.pangolinfo.com"
AUTH_ENDPOINT = f"{API_BASE}/api/v1/auth"
AMZSCOPE_BASE = f"{API_BASE}/api/v1/amzscope"
API_KEY_CACHE_PATH = Path.home() / ".pangolinfo_api_key"

# User-facing registration/top-up link (not the API endpoint)
REFERRER_TAG = "clawhub_niche"
PANGOLINFO_URL = f"https://pangolinfo.com/?referrer={REFERRER_TAG}"

CACHE_TO_DISK = False

EXIT_SUCCESS = 0
EXIT_API_ERROR = 1
EXIT_USAGE_ERROR = 2
EXIT_NETWORK_ERROR = 3
EXIT_AUTH_ERROR = 4

# API name -> (path suffix, display label)
API_ROUTES = {
    "category-tree":   ("/categories/children", "browseCategoryTreeAPI"),
    "category-search": ("/categories/search",   "searchCategoriesAPI"),
    "category-paths":  ("/categories/paths",    "batchCategoryPathsAPI"),
    "category-filter": ("/categories/filter",   "categoryFilterAPI"),
    "niche-filter":    ("/niches/filter",       "nicheFilterAPI"),
}

# Known Pangolinfo API error hints
API_ERROR_HINTS = {
    1002: "Invalid parameter. Check required fields for this API.",
    1004: "Invalid token. Will auto-retry with fresh credentials.",
    2001: f"Insufficient credits. Top up at {PANGOLINFO_URL}",
    2005: f"No active plan. Subscribe at {PANGOLINFO_URL}",
    2007: f"Account expired. Renew at {PANGOLINFO_URL}",
    2009: "Usage limit reached for current billing cycle. Contact support.",
    4029: "Rate limited by server. Reduce request frequency.",
    9100: "AmzScope service temporarily disabled. Retry later.",
    9101: "Data source temporarily unavailable. Retry later.",
    9102: "Service quota exceeded. Contact support.",
}


# ---------------------------------------------------------------------------
# Error helper
# ---------------------------------------------------------------------------
def _emit_error(code, message, hint=None, api_code=None, exit_code=None):
    """Print structured error JSON to stderr and optionally exit."""
    envelope = {"success": False, "error": {"code": code, "message": message}}
    if api_code is not None:
        envelope["error"]["api_code"] = api_code
    if hint:
        envelope["error"]["hint"] = hint
    print(json.dumps(envelope, ensure_ascii=False), file=sys.stderr)
    if exit_code is not None:
        sys.exit(exit_code)


def _is_ssl_error(exc):
    msg = str(exc)
    return "CERTIFICATE_VERIFY_FAILED" in msg or "SSL" in msg


def _emit_ssl_error():
    _emit_error(
        "SSL_CERT",
        "SSL certificate verification failed.",
        hint=(
            "macOS: run '/Applications/Python 3.x/Install Certificates.command' "
            "or set SSL_CERT_FILE. See: python3 -c \"import certifi; print(certifi.where())\""
        ),
        exit_code=EXIT_NETWORK_ERROR,
    )


# ---------------------------------------------------------------------------
# API key management
# ---------------------------------------------------------------------------
def load_cached_api_key():
    if API_KEY_CACHE_PATH.exists():
        [REDACTED_SECRET]).strip()
        if api_key and len(api_key.split(".")) == 3:
            return api_key
    return None


def save_cached_api_key(api_key):
    if not CACHE_TO_DISK:
        return
    try:
        fd = os.open(
            str(API_KEY_CACHE_PATH) + ".tmp",
            os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
            stat.S_IRUSR | stat.S_IWUSR,
        )
        with os.fdopen(fd, "w") as f:
            f.write(api_key)
        os.replace(str(API_KEY_CACHE_PATH) + ".tmp", str(API_KEY_CACHE_PATH))
    except OSError:
        API_KEY_CACHE_PATH.write_text(api_key)
        try:
            API_KEY_CACHE_PATH.chmod(0o600)
        except OSError:
            pass


# ---------------------------------------------------------------------------
# HTTP helper
# ---------------------------------------------------------------------------
def _http_post(url, body_dict, headers=None, timeout=30):
    """POST JSON and return parsed response."""
    payload = json.dumps(body_dict).encode()
    req_headers = {"Content-Type": "application/json"}
    if headers:
        req_headers.update(headers)
    req = urllib.request.Request(url, data=payload, headers=req_headers, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read()
    except urllib.error.URLError as e:
        if _is_ssl_error(e):
            _emit_ssl_error()
        raise
    try:
        return json.loads(raw)
    except (json.JSONDecodeError, ValueError):
        _emit_error(
            "PARSE_ERROR",
            "API returned invalid JSON.",
            hint="The API may be temporarily unavailable. Retry in a moment.",
            exit_code=EXIT_NETWORK_ERROR,
        )


# ---------------------------------------------------------------------------
# Authentication
# ---------------------------------------------------------------------------
def authenticate(email, password):
    try:
        result = _http_post(AUTH_ENDPOINT, {"email": email, "password": password})
    except urllib.error.URLError:
        _emit_error(
            "NETWORK", "Network error during authentication.",
            hint="Check your internet connection and try again.",
            exit_code=EXIT_NETWORK_ERROR,
        )

    if result.get("code") != 0:
        _emit_error(
            "AUTH_FAILED", "Authentication failed.",
            hint="Verify PANGOLINFO_EMAIL and PANGOLINFO_PASSWORD are correct.",
            api_code=result.get("code"),
            exit_code=EXIT_AUTH_ERROR,
        )

    [REDACTED_SECRET]"data"]
    save_cached_api_key(api_key)
    return api_key


def get_api_key():
    [REDACTED_SECRET]"PANGOLINFO_API_KEY")
    if [REDACTED_SECRET])
        return api_key

    [REDACTED_SECRET])
    if [REDACTED_SECRET] api_key

    email = os.environ.get("PANGOLINFO_EMAIL")
    [REDACTED_SECRET]"PANGOLINFO_PASSWORD")
    if not email or not [REDACTED_SECRET]
            "MISSING_ENV", "No authentication credentials found.",
            hint="Set PANGOLINFO_API_KEY, or both PANGOLINFO_EMAIL and PANGOLINFO_PASSWORD.",
            exit_code=EXIT_AUTH_ERROR,
        )
    return authenticate(email, password)


def refresh_api_key():
    email = os.environ.get("PANGOLINFO_EMAIL")
    [REDACTED_SECRET]"PANGOLINFO_PASSWORD")
    if not email or not [REDACTED_SECRET]
            "MISSING_ENV", "Cannot refresh API key without credentials.",
            hint="Set PANGOLINFO_EMAIL and PANGOLINFO_PASSWORD environment variables.",
            exit_code=EXIT_AUTH_ERROR,
        )
    return authenticate(email, password)


# ---------------------------------------------------------------------------
# Request body builders
# ---------------------------------------------------------------------------
def _parse_category_ids(raw):
    """Accept either a JSON array ('["a","b"]') or a comma-separated list."""
    raw = raw.strip()
    if raw.startswith("["):
        try:
            parsed = json.loads(raw)
        except json.JSONDecodeError as e:
            _emit_error(
                "USAGE_ERROR", f"Invalid --category-ids JSON: {e}",
                hint="Provide a JSON array or comma-separated IDs.",
                exit_code=EXIT_USAGE_ERROR,
            )
        if not isinstance(parsed, list):
            _emit_error(
                "USAGE_ERROR", "--category-ids JSON must be an array.",
                hint='Example: --category-ids \'["2619526011","172282"]\'',
                exit_code=EXIT_USAGE_ERROR,
            )
        return [str(x) for x in parsed]
    return [part.strip() for part in raw.split(",") if part.strip()]


def _apply_extras(body, extras):
    """Merge --extra key=value pairs into body. Values are JSON-parsed when possible."""
    for item in extras or []:
        if "=" not in item:
            _emit_error(
                "USAGE_ERROR", f"--extra must be key=value, got: {item}",
                hint="Example: --extra 'buyBoxPriceAvgMin=5000'",
                exit_code=EXIT_USAGE_ERROR,
            )
        key, _, value = item.partition("=")
        key = key.strip()
        value = value.strip()
        if not key:
            _emit_error(
                "USAGE_ERROR", f"--extra missing key: {item}",
                exit_code=EXIT_USAGE_ERROR,
            )
        try:
            body[key] = json.loads(value)
        except json.JSONDecodeError:
            body[key] = value
    return body


def _apply_pagination(body, page, size, is_filter=False):
    if page is not None:
        if is_filter and page > 10:
            _emit_error(
                "USAGE_ERROR",
                "Filter APIs limit page to 10 (got %d)." % page,
                hint="Use --page 1..10. Refine filters to narrow results.",
                exit_code=EXIT_USAGE_ERROR,
            )
        body["page"] = page
    if size is not None:
        body["size"] = size


def build_body(args):
    body = {}

    if args.api == "category-tree":
        if args.parent_path:
            body["parentBrowseNodeIdPath"] = args.parent_path
        _apply_pagination(body, args.page, args.size)

    elif args.api == "category-search":
        if not args.keyword:
            _emit_error(
                "USAGE_ERROR", "category-search requires --keyword.",
                hint="Provide --keyword <term>.",
                exit_code=EXIT_USAGE_ERROR,
            )
        body["keyword"] = args.keyword
        _apply_pagination(body, args.page, args.size)

    elif args.api == "category-paths":
        if not args.category_ids:
            _emit_error(
                "USAGE_ERROR", "category-paths requires --category-ids.",
                hint="Provide --category-ids '2619526011,172282' or a JSON array.",
                exit_code=EXIT_USAGE_ERROR,
            )
        body["categoryIds"] = _parse_category_ids(args.category_ids)

    elif args.api == "category-filter":
        missing = [
            name
            for name, val in (
                ("--marketplace-id", args.marketplace_id),
                ("--time-range", args.time_range),
                ("--sample-scope", args.sample_scope),
            )
            if not val
        ]
        if missing:
            _emit_error(
                "USAGE_ERROR",
                f"category-filter requires: {', '.join(missing)}.",
                hint="Example: --marketplace-id US --time-range l7d --sample-scope all_asin",
                exit_code=EXIT_USAGE_ERROR,
            )
        body["marketplaceId"] = args.marketplace_id
        body["timeRange"] = args.time_range
        body["sampleScope"] = args.sample_scope
        if args.category_id:
            body["categoryId"] = args.category_id
        _apply_pagination(body, args.page, args.size, is_filter=True)

    elif args.api == "niche-filter":
        if not args.marketplace_id:
            _emit_error(
                "USAGE_ERROR", "niche-filter requires --marketplace-id.",
                hint="Example: --marketplace-id US",
                exit_code=EXIT_USAGE_ERROR,
            )
        body["marketplaceId"] = args.marketplace_id
        if args.niche_id:
            body["nicheId"] = args.niche_id
        if args.niche_title:
            body["nicheTitle"] = args.niche_title
        _apply_pagination(body, args.page, args.size, is_filter=True)

    _apply_extras(body, args.extra)
    return body


# ---------------------------------------------------------------------------
# API call with retry
# ---------------------------------------------------------------------------
def call_api(api_key, body, endpoint, max_retries=3, timeout=120):
    headers = {
        "Authorization": f"Bearer {api_key}",
        "User-Agent": "Pangolinfo-CLI/2.0",
    }

    for attempt in range(max_retries):
        try:
            result = _http_post(endpoint, body, headers=headers, timeout=timeout)
            return result
        except urllib.error.HTTPError as e:
            error_body = ""
            try:
                error_body = e.read().decode() if e.fp else ""
            except Exception:
                pass

            if e.code == 429:
                if attempt < max_retries - 1:
                    time.sleep(2 ** attempt)
                    continue
                _emit_error(
                    "RATE_LIMIT", "Rate limited by API server.",
                    hint="Wait a moment and retry, or reduce request frequency.",
                    exit_code=EXIT_NETWORK_ERROR,
                )

            # 4xx errors are not retryable (except 429 above)
            if 400 <= e.code < 500:
                try:
                    parsed = json.loads(error_body) if error_body else {}
                    if isinstance(parsed, dict) and "code" in parsed:
                        return parsed
                except (json.JSONDecodeError, ValueError):
                    pass
                detail = ""
                if error_body:
                    try:
                        err_json = json.loads(error_body)
                        detail = f" Server: {err_json.get('message', error_body[:200])}"
                    except (json.JSONDecodeError, ValueError):
                        detail = f" Server: {error_body[:200]}"
                _emit_error(
                    "API_ERROR", f"HTTP {e.code} from API.{detail}",
                    hint="Check your request parameters and try again.",
                    exit_code=EXIT_NETWORK_ERROR,
                )

            if attempt < max_retries - 1:
                time.sleep(2 ** attempt)
                continue

            detail = ""
            if error_body:
                try:
                    err_json = json.loads(error_body)
                    detail = f" Server: {err_json.get('message', error_body[:200])}"
                except (json.JSONDecodeError, ValueError):
                    detail = f" Server: {error_body[:200]}"

            _emit_error(
                "API_ERROR", f"HTTP {e.code} from API.{detail}",
                hint="Check your request parameters and try again.",
                exit_code=EXIT_NETWORK_ERROR,
            )
        except urllib.error.URLError:
            if attempt < max_retries - 1:
                time.sleep(2 ** attempt)
                continue
            _emit_error(
                "NETWORK", "Network error communicating with API.",
                hint="Check your internet connection and try again.",
                exit_code=EXIT_NETWORK_ERROR,
            )

    _emit_error(
        "NETWORK", "API call failed after retries.",
        hint="Check your internet connection and try again.",
        exit_code=EXIT_NETWORK_ERROR,
    )


def handle_response(result, api_key, body, endpoint, timeout=120):
    if result.get("code") == 1004:
        new_api_key = refresh_api_key()
        return call_api(new_api_key, body, endpoint, timeout=timeout)
    return result


# ---------------------------------------------------------------------------
# Output extraction
# ---------------------------------------------------------------------------
def extract_output(result, api_name):
    code = result.get("code")
    if code != 0:
        hint = API_ERROR_HINTS.get(code, f"Pangolinfo API error code {code}. See references/error-codes.md.")
        return {
            "success": False,
            "error": {
                "code": "API_ERROR",
                "api_code": code,
                "message": result.get("message", "Unknown API error"),
                "hint": hint,
            },
        }

    data = result.get("data") or {}
    output = {"success": True, "api": api_name}

    # Upstream shapes:
    #   data: { items: { data: [...], total, page, size, totalPages } }  -- most APIs
    #   data: { items: [...] }                                           -- category-paths
    items_wrapper = data.get("items")
    if isinstance(items_wrapper, dict):
        output["items"] = items_wrapper.get("data", [])
        for key in ("total", "page", "size", "totalPages"):
            if key in items_wrapper:
                output[key] = items_wrapper[key]
    elif isinstance(items_wrapper, list):
        output["items"] = items_wrapper
    else:
        output["data"] = data

    if isinstance(output.get("items"), list):
        output["results_count"] = len(output["items"])

    return output


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main():
    parser = argparse.ArgumentParser(
        description="Pangolinfo Amazon Niche Data API Client",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Examples:\n"
            '  python3 pangolinfo.py --api category-search --keyword "headphones"\n'
            '  python3 pangolinfo.py --api category-tree --parent-path "2619526011"\n'
            '  python3 pangolinfo.py --api category-paths --category-ids "2619526011,172282"\n'
            "  python3 pangolinfo.py --api category-filter --marketplace-id US --time-range l7d --sample-scope all_asin\n"
            '  python3 pangolinfo.py --api niche-filter --marketplace-id US --niche-title "yoga mat"\n'
            "  python3 pangolinfo.py --auth-only\n"
        ),
    )
    parser.add_argument(
        "--api", choices=sorted(API_ROUTES.keys()),
        help="Which niche API to call",
    )

    # Pagination
    parser.add_argument("--page", type=int, help="1-based page number (max 10 for filter APIs)")
    parser.add_argument("--size", type=int, help="Items per page (max 10 for filter APIs)")

    # category-tree
    parser.add_argument("--parent-path", help="category-tree: parent browseNodeIdPath")

    # category-search
    parser.add_argument("--keyword", help="category-search: keyword (EN or CN)")

    # category-paths
    parser.add_argument("--category-ids", help="category-paths: comma-separated IDs or JSON array")

    # category-filter / niche-filter
    parser.add_argument("--marketplace-id", help="Marketplace code (e.g. US, UK, DE)")
    parser.add_argument("--time-range", help="Aggregation range (e.g. l7d, l30d, l90d)")
    parser.add_argument("--sample-scope", help="Sample scope (e.g. all_asin)")
    parser.add_argument("--category-id", help="category-filter: single-category detail")

    # niche-filter
    parser.add_argument("--niche-id", help="niche-filter: specific niche ID")
    parser.add_argument("--niche-title", help="niche-filter: keyword match on title")

    # Extra filters
    parser.add_argument(
        "--extra", action="append", default=[],
        help="Extra request field as key=value (JSON-parsed); repeatable",
    )

    # Common
    parser.add_argument("--auth-only", action="store_true", help="Auth check only")
    parser.add_argument("--raw", action="store_true", help="Output raw API response")
    parser.add_argument("--timeout", type=int, default=120, help="Timeout in seconds (default: 120)")
    parser.add_argument(
        "--cache-key", action="store_true",
        help="Persist API key to ~/.pangolinfo_api_key. Also: PANGOLINFO_CACHE=1.",
    )

    args = parser.parse_args()

    # Configure caching
    global CACHE_TO_DISK
    CACHE_TO_DISK = bool(args.cache_key) or os.environ.get("PANGOLINFO_CACHE", "").strip().lower() in (
        "1", "true", "yes", "on",
    )

    if not args.api and not args.auth_only:
        parser.error("--api is required unless using --auth-only")

    # Authenticate
    [REDACTED_SECRET])

    if args.auth_only:
        preview = f"{api_key[:4]}...{api_key[-4:]}" if len(api_key) > 12 else "***"
        print(json.dumps({
            "success": True,
            "message": "Authentication successful",
            "api_key_preview": preview,
        }, indent=2))
        sys.exit(EXIT_SUCCESS)

    # Build request
    path, label = API_ROUTES[args.api]
    endpoint = f"{AMZSCOPE_BASE}{path}"
    body = build_body(args)

    # Call API
    result = call_api(api_key, body, endpoint, timeout=args.timeout)

    if result is None:
        _emit_error(
            "NETWORK", "API call failed after retries.",
            hint="Check your internet connection and try again.",
            exit_code=EXIT_NETWORK_ERROR,
        )

    result = handle_response(result, api_key, body, endpoint, timeout=args.timeout)

    if result is None:
        _emit_error(
            "NETWORK", "API call failed after token refresh.",
            hint="Check your internet connection and try again.",
            exit_code=EXIT_NETWORK_ERROR,
        )

    # Output
    if args.raw:
        print(json.dumps(result, indent=2, ensure_ascii=False))
    else:
        output = extract_output(result, label)
        if output.get("success"):
            print(json.dumps(output, indent=2, ensure_ascii=False))
        else:
            print(json.dumps(output, indent=2, ensure_ascii=False), file=sys.stderr)

    if result.get("code") != 0:
        sys.exit(EXIT_API_ERROR)

    sys.exit(EXIT_SUCCESS)


if __name__ == "__main__":
    main()


path: scripts/self_test.sh
content: #!/usr/bin/env bash
# Consolidated self-test for flattened Pangolinfo toolkit.
# Runs auth + input validation for all 4 bundled scripts.
# Live API tests are skipped unless RUN_LIVE_TESTS=1.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

# Windows / MSYS: prefer `python`, others: `python3`
if python3 --version >/dev/null 2>&1; then
  PYTHON="python3"
else
  PYTHON="python"
fi

PASS=0
FAIL=0

run_test() {
  local name="$1"
  shift
  echo -n "  [$name] "
  if "$@"; then
    echo "PASS"
    PASS=$((PASS + 1))
  else
    echo "FAIL (exit=$?)"
    FAIL=$((FAIL + 1))
  fi
}

echo "=== Pangolinfo Toolkit Self-Test (flat layout) ==="
echo "Python: $PYTHON"
echo ""

for script in ai_serp amazon_scraper amazon_niche wipo; do
  SCRIPT_PATH="$SCRIPT_DIR/${script}.py"
  echo "--- ${script}.py ---"
  if [ ! -f "$SCRIPT_PATH" ]; then
    echo "  SKIP: $SCRIPT_PATH not found"
    continue
  fi
  # Auth check is the one universal test every client supports.
  # Expect non-zero exit when PANGOLINFO_API_KEY / credentials are unset.
  run_test "auth-check" bash -c "$PYTHON \"$SCRIPT_PATH\" --auth-only >/dev/null 2>&1 || true; true"
  # Invalid args test
  run_test "rejects-bad-args" bash -c "! $PYTHON \"$SCRIPT_PATH\" --definitely-not-a-real-flag 2>/dev/null"
  echo ""
done

# Live API tests (optional, consume credits)
if [ "${RUN_LIVE_TESTS:-0}" = "1" ]; then
  echo "Live API tests enabled (RUN_LIVE_TESTS=1)"
  run_test "ai_serp-live" bash -c "$PYTHON \"$SCRIPT_DIR/ai_serp.py\" --q 'python' --mode serp-plus >/dev/null 2>&1"
  run_test "amazon_scraper-live" bash -c "$PYTHON \"$SCRIPT_DIR/amazon_scraper.py\" --asin B0DYTF8L2W --site amz_us >/dev/null 2>&1"
else
  echo "Live API tests: SKIPPED (set RUN_LIVE_TESTS=1 to enable)"
fi

echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
[ "$FAIL" -eq 0 ] || exit 1


path: scripts/wipo.py
content: #!/usr/bin/env python3
"""
Pangolinfo WIPO API Client

Zero-dependency Python client for Pangolinfo's WIPO Global Design Database API.
Searches industrial design registrations by IRN, holder, product, Locarno class, etc.

Usage:
    pangolinfo.py --irn "000298" --ds AL
    pangolinfo.py --hol "Apple" --ds US
    pangolinfo.py --prod "chair" --ds US --status ACT
    pangolinfo.py --auth-only

Environment:
    PANGOLINFO_API_KEY  - API key (skips login)
    PANGOLINFO_EMAIL    - Account email (for login)
    PANGOLINFO_PASSWORD - Account password (for login)
"""

import argparse
import io
import json
import os
import stat
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

# ---------------------------------------------------------------------------
# Fix Windows / macOS console encoding for Unicode output
# ---------------------------------------------------------------------------
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
    sys.stdout = io.TextIOWrapper(
        sys.stdout.buffer, encoding="utf-8", errors="replace"
    )
if sys.stderr.encoding and sys.stderr.encoding.lower() != "utf-8":
    sys.stderr = io.TextIOWrapper(
        sys.stderr.buffer, encoding="utf-8", errors="replace"
    )

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
API_BASE = "https://scrapeapi.pangolinfo.com"
AUTH_ENDPOINT = f"{API_BASE}/api/v1/auth"
WIPO_ENDPOINT = f"{API_BASE}/api/v3/wipo"
API_KEY_CACHE_PATH = Path.home() / ".pangolinfo_api_key"

REFERRER_TAG = "clawhub_wipo"
PANGOLINFO_URL = f"https://pangolinfo.com/?referrer={REFERRER_TAG}"

CACHE_TO_DISK = False

EXIT_SUCCESS = 0
EXIT_API_ERROR = 1
EXIT_USAGE_ERROR = 2
EXIT_NETWORK_ERROR = 3
EXIT_AUTH_ERROR = 4


# ---------------------------------------------------------------------------
# Error helper
# ---------------------------------------------------------------------------
def _emit_error(code, message, hint=None, api_code=None, exit_code=None):
    """Print structured error JSON to stderr and optionally exit."""
    envelope = {"success": False, "error": {"code": code, "message": message}}
    if api_code is not None:
        envelope["error"]["api_code"] = api_code
    if hint:
        envelope["error"]["hint"] = hint
    print(json.dumps(envelope, ensure_ascii=False), file=sys.stderr)
    if exit_code is not None:
        sys.exit(exit_code)


def _is_ssl_error(exc):
    msg = str(exc)
    return "CERTIFICATE_VERIFY_FAILED" in msg or "SSL" in msg


def _emit_ssl_error():
    _emit_error(
        "SSL_CERT",
        "SSL certificate verification failed.",
        hint=(
            "macOS: run '/Applications/Python 3.x/Install Certificates.command' "
            "or set SSL_CERT_FILE. See: python3 -c \"import certifi; print(certifi.where())\""
        ),
        exit_code=EXIT_NETWORK_ERROR,
    )


# ---------------------------------------------------------------------------
# API key management
# ---------------------------------------------------------------------------
def load_cached_api_key():
    if API_KEY_CACHE_PATH.exists():
        [REDACTED_SECRET]).strip()
        if api_key and len(api_key.split(".")) == 3:
            return api_key
    return None


def save_cached_api_key(api_key):
    if not CACHE_TO_DISK:
        return
    try:
        fd = os.open(
            str(API_KEY_CACHE_PATH) + ".tmp",
            os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
            stat.S_IRUSR | stat.S_IWUSR,
        )
        with os.fdopen(fd, "w") as f:
            f.write(api_key)
        os.replace(str(API_KEY_CACHE_PATH) + ".tmp", str(API_KEY_CACHE_PATH))
    except OSError:
        API_KEY_CACHE_PATH.write_text(api_key)
        try:
            API_KEY_CACHE_PATH.chmod(0o600)
        except OSError:
            pass


# ---------------------------------------------------------------------------
# HTTP helper
# ---------------------------------------------------------------------------
def _http_post(url, body_dict, headers=None, timeout=30):
    """POST JSON and return parsed response."""
    payload = json.dumps(body_dict).encode()
    req_headers = {"Content-Type": "application/json"}
    if headers:
        req_headers.update(headers)
    req = urllib.request.Request(url, data=payload, headers=req_headers, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read()
    except urllib.error.URLError as e:
        if _is_ssl_error(e):
            _emit_ssl_error()
        raise
    try:
        return json.loads(raw)
    except (json.JSONDecodeError, ValueError):
        _emit_error(
            "PARSE_ERROR",
            "API returned invalid JSON.",
            hint="The API may be temporarily unavailable. Retry in a moment.",
            exit_code=EXIT_NETWORK_ERROR,
        )


# ---------------------------------------------------------------------------
# Authentication
# ---------------------------------------------------------------------------
def authenticate(email, password):
    try:
        result = _http_post(AUTH_ENDPOINT, {"email": email, "password": password})
    except urllib.error.URLError:
        _emit_error(
            "NETWORK", "Network error during authentication.",
            hint="Check your internet connection and try again.",
            exit_code=EXIT_NETWORK_ERROR,
        )

    if result.get("code") != 0:
        _emit_error(
            "AUTH_FAILED", "Authentication failed.",
            hint="Verify PANGOLINFO_EMAIL and PANGOLINFO_PASSWORD are correct.",
            api_code=result.get("code"),
            exit_code=EXIT_AUTH_ERROR,
        )

    [REDACTED_SECRET]"data"]
    save_cached_api_key(api_key)
    return api_key


def get_api_key():
    [REDACTED_SECRET]"PANGOLINFO_API_KEY")
    if [REDACTED_SECRET])
        return api_key

    [REDACTED_SECRET])
    if [REDACTED_SECRET] api_key

    email = os.environ.get("PANGOLINFO_EMAIL")
    [REDACTED_SECRET]"PANGOLINFO_PASSWORD")
    if not email or not [REDACTED_SECRET]
            "MISSING_ENV", "No authentication credentials found.",
            hint="Set PANGOLINFO_API_KEY, or both PANGOLINFO_EMAIL and PANGOLINFO_PASSWORD.",
            exit_code=EXIT_AUTH_ERROR,
        )
    return authenticate(email, password)


def refresh_api_key():
    email = os.environ.get("PANGOLINFO_EMAIL")
    [REDACTED_SECRET]"PANGOLINFO_PASSWORD")
    if not email or not [REDACTED_SECRET]
            "MISSING_ENV", "Cannot refresh API key without credentials.",
            hint="Set PANGOLINFO_EMAIL and PANGOLINFO_PASSWORD environment variables.",
            exit_code=EXIT_AUTH_ERROR,
        )
    return authenticate(email, password)


# ---------------------------------------------------------------------------
# WIPO request builder
# ---------------------------------------------------------------------------
def build_wipo_body(args):
    """Build the WIPO API request body from CLI args."""
    body = {}

    if args.irn:
        body["irn"] = args.irn
    if args.ds:
        body["ds"] = args.ds
    if args.hol:
        body["hol"] = args.hol
    if args.prod:
        body["prod"] = args.prod
    if args.lcs:
        body["lcs"] = args.lcs
    if args.status:
        body["status"] = args.status
    if args.record_id:
        body["id"] = args.record_id
    if args.id_search:
        body["id_search"] = args.id_search
    if args.source:
        body["source"] = args.source
    if args.rd:
        body["rd"] = args.rd
    if args.ed:
        body["ed"] = args.ed

    body["from"] = args.offset
    body["num"] = args.num

    return body


# ---------------------------------------------------------------------------
# API call with retry
# ---------------------------------------------------------------------------
def call_api(api_key, body, max_retries=3, timeout=120):
    headers = {
        "Authorization": f"Bearer {api_key}",
        "User-Agent": "Pangolinfo-CLI/2.0",
    }

    for attempt in range(max_retries):
        try:
            result = _http_post(WIPO_ENDPOINT, body, headers=headers, timeout=timeout)
            return result
        except urllib.error.HTTPError as e:
            error_body = ""
            try:
                error_body = e.read().decode() if e.fp else ""
            except Exception:
                pass

            if e.code == 429:
                if attempt < max_retries - 1:
                    time.sleep(2 ** attempt)
                    continue
                _emit_error(
                    "RATE_LIMIT", "Rate limited by API server.",
                    hint="Wait a moment and retry, or reduce request frequency.",
                    exit_code=EXIT_NETWORK_ERROR,
                )

            if attempt < max_retries - 1:
                time.sleep(2 ** attempt)
                continue

            detail = ""
            if error_body:
                try:
                    err_json = json.loads(error_body)
                    detail = f" Server: {err_json.get('message', error_body[:200])}"
                except (json.JSONDecodeError, ValueError):
                    detail = f" Server: {error_body[:200]}"

            _emit_error(
                "API_ERROR", f"HTTP {e.code} from API.{detail}",
                hint="Check your request parameters and try again.",
                exit_code=EXIT_NETWORK_ERROR,
            )
        except urllib.error.URLError:
            if attempt < max_retries - 1:
                time.sleep(2 ** attempt)
                continue
            _emit_error(
                "NETWORK", "Network error communicating with API.",
                hint="Check your internet connection and try again.",
                exit_code=EXIT_NETWORK_ERROR,
            )

    _emit_error(
        "NETWORK", "API call failed after retries.",
        hint="Check your internet connection and try again.",
        exit_code=EXIT_NETWORK_ERROR,
    )


# ---------------------------------------------------------------------------
# Output extraction
# ---------------------------------------------------------------------------
def extract_wipo_output(result):
    """Transform raw WIPO API response into clean output."""
    data = result.get("data")
    if data is None:
        # Check for error code in top-level response
        code = result.get("code")
        if code is not None and code != 0:
            return {
                "success": False,
                "error": {
                    "code": "API_ERROR",
                    "api_code": code,
                    "message": result.get("message", "Unknown API error"),
                    "hint": f"Pangolinfo API error code {code}. Retry or check request.",
                },
            }
        return {
            "success": False,
            "error": {
                "code": "API_ERROR",
                "message": "No data returned from WIPO API.",
                "hint": "Check your search parameters and try again.",
            },
        }

    total = data.get("total", 0)
    hits = data.get("hits", [])

    results = []
    for hit in hits:
        record = {
            "irn": hit.get("IRN", ""),
            "status": hit.get("STATUS", ""),
            "registration_date": hit.get("RD", ""),
            "expiry_date": hit.get("ED", ""),
            "holder": hit.get("HOL", []),
            "product": hit.get("PROD", []),
            "locarno_class": hit.get("LCS", []),
            "designated_states": hit.get("DS", []),
            "source": hit.get("SOURCE", ""),
            "id": hit.get("ID", ""),
            "doc": hit.get("DOC", ""),
            "dc": hit.get("DC", ""),
        }
        img_data = hit.get("IMG_DATA", [])
        if img_data:
            record["images"] = img_data
        detail_url = hit.get("DETAIL_URL")
        if detail_url:
            record["detail_url"] = detail_url
        detail_data = hit.get("DETAIL_DATA")
        if detail_data:
            record["detail_data"] = detail_data
        results.append(record)

    return {
        "success": True,
        "total": total,
        "results_count": len(results),
        "results": results,
    }


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main():
    parser = argparse.ArgumentParser(
        description="Pangolinfo WIPO Global Design Database API Client",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Examples:\n"
            '  python3 pangolinfo.py --irn "000298" --ds AL\n'
            '  python3 pangolinfo.py --hol "Apple" --ds US\n'
            '  python3 pangolinfo.py --prod "chair" --ds US --status ACT\n'
            '  python3 pangolinfo.py --lcs "23-01" --ds AL\n'
            "  python3 pangolinfo.py --auth-only\n"
        ),
    )
    parser.add_argument("--irn", help="International registration number")
    parser.add_argument("--ds", help="Designated state (country code, e.g. US, AL, CN)")
    parser.add_argument("--hol", help="Holder / rights owner name")
    parser.add_argument("--prod", help="Product name")
    parser.add_argument("--lcs", help="Locarno classification code (e.g. 23-01)")
    parser.add_argument("--status", help="Legal status filter (e.g. ACT for active)")
    parser.add_argument("--id", dest="record_id", help="Unique record identifier")
    parser.add_argument("--id-search", dest="id_search", help="ID variant search")
    parser.add_argument("--source", help="Data source filter")
    parser.add_argument("--rd", help="Registration date filter (e.g. 2022-01-01)")
    parser.add_argument("--ed", help="Expiry date filter")
    parser.add_argument(
        "--from", dest="offset", type=int, default=0,
        help="Pagination offset, 0-based (default: 0)",
    )
    parser.add_argument(
        "--num", type=int, default=10,
        help="Results per page (default: 10)",
    )
    parser.add_argument("--auth-only", action="store_true", help="Auth check only")
    parser.add_argument("--raw", action="store_true", help="Output raw API response")
    parser.add_argument("--timeout", type=int, default=120, help="Timeout in seconds (default: 120)")
    parser.add_argument(
        "--cache-key", action="store_true",
        help="Persist API key to ~/.pangolinfo_api_key. Also: PANGOLINFO_CACHE=1.",
    )

    args = parser.parse_args()

    # Configure caching
    global CACHE_TO_DISK
    CACHE_TO_DISK = bool(args.cache_key) or os.environ.get("PANGOLINFO_CACHE", "").strip().lower() in (
        "1", "true", "yes", "on",
    )

    # Validate: need at least one search parameter (unless auth-only)
    has_search_param = any([
        args.irn, args.ds, args.hol, args.prod, args.lcs,
        args.status, args.record_id, args.id_search, args.source,
        args.rd, args.ed,
    ])
    if not args.auth_only and not has_search_param:
        parser.error(
            "At least one search parameter is required (e.g. --irn, --hol, --prod, --lcs, --ds). "
            "Use --auth-only for auth check."
        )

    # Authenticate
    [REDACTED_SECRET])

    if args.auth_only:
        preview = f"{api_key[:4]}...{api_key[-4:]}" if len(api_key) > 12 else "***"
        print(json.dumps({
            "success": True,
            "message": "Authentication successful",
            "api_key_preview": preview,
        }, indent=2))
        sys.exit(EXIT_SUCCESS)

    # Build request body
    body = build_wipo_body(args)

    # Call API
    result = call_api(api_key, body, timeout=args.timeout)

    if result is None:
        _emit_error(
            "NETWORK", "API call failed after retries.",
            hint="Check your internet connection and try again.",
            exit_code=EXIT_NETWORK_ERROR,
        )

    # Handle token refresh on 1004
    if isinstance(result, dict) and result.get("code") == 1004:
        new_api_key = refresh_api_key()
        result = call_api(new_api_key, body, timeout=args.timeout)
        if result is None:
            _emit_error(
                "NETWORK", "API call failed after token refresh.",
                hint="Check your internet connection and try again.",
                exit_code=EXIT_NETWORK_ERROR,
            )

    # Output
    if args.raw:
        print(json.dumps(result, indent=2, ensure_ascii=False))
    else:
        output = extract_wipo_output(result)
        if output.get("success"):
            print(json.dumps(output, indent=2, ensure_ascii=False))
        else:
            print(json.dumps(output, indent=2, ensure_ascii=False), file=sys.stderr)

    # Exit code based on success
    if isinstance(result, dict):
        code = result.get("code")
        if code is not None and code != 0:
            sys.exit(EXIT_API_ERROR)
        if result.get("data") is None:
            sys.exit(EXIT_API_ERROR)

    sys.exit(EXIT_SUCCESS)


if __name__ == "__main__":
    main()


path: references/ai-serp-error-codes.md
content: # Error Codes and Troubleshooting

## Pangolinfo API Error Codes

| Code | Meaning | Resolution |
|------|---------|------------|
| 0 | Success | No action needed |
| 1001 | Parameter is empty | Check required fields |
| 1002 | Invalid parameter | Verify request format |
| 1004 | Invalid token | Auto-retried by script. If persistent, re-authenticate. |
| 1009 | Invalid parser name | Check `--mode` value |
| 2001 | Insufficient credits | Top up at [pangolinfo.com](https://pangolinfo.com/?referrer=clawhub_serp) |
| 2005 | No active plan | Subscribe at [pangolinfo.com](https://pangolinfo.com/?referrer=clawhub_serp) |
| 2007 | Account expired | Renew at [pangolinfo.com](https://pangolinfo.com/?referrer=clawhub_serp) |
| 2009 | Usage limit reached | Wait for next billing cycle or contact support |
| 2010 | Bill day not configured | Contact support |
| 4029 | Rate limited | Reduce request frequency |
| 10000 | Task execution failed | Retry. Check query format. |
| 10001 | Task execution failed | Retry. Likely a temporary server issue. |

## Authentication

### Token Lifecycle

- Tokens are **permanent** and do not expire
- A token becomes invalid only if the account is deactivated
- Error code `1004` triggers automatic token refresh

### Token Resolution Order

1. `PANGOLINFO_API_KEY` environment variable
2. Cached API key at `~/.pangolinfo_api_key` (if the file exists from a prior `--cache-key` run)
3. Fresh login using `PANGOLINFO_EMAIL` + `PANGOLINFO_PASSWORD`

### Auth Endpoint

```
POST https://scrapeapi.pangolinfo.com/api/v1/auth
Body: {"email": "<email>", "password": "<password>"}
Response: {"code": 0, "message": "ok", "data": "<token>"}
```

## Credit Costs

| Mode | Credits per request |
|------|---------------------|
| AI Mode (`googleAiSearch`) | 2 |
| SERP (`googleSearch`) | 2 |
| SERP Plus (`googleSearchPlus`) | 1 |

Credits are only consumed on successful requests (code 0).

## Common Issues

**"No authentication credentials" error**
Set environment variables: `export PANGOLINFO_API_KEY=...`

**Empty AI overview in response**
Not all queries trigger an AI overview. Try a more informational query.

**Timeout or network errors**
The script retries 3 times with exponential backoff. Check your network connection.

**Screenshot URL not returned**
Ensure `--screenshot` flag is passed.


path: references/amazon-niche-setup-guide.md
content: # First-Time Setup Guide

When authentication fails (error code `MISSING_ENV`), walk the user through setup interactively.

## Step 1: Explain

> To use this skill, you need a Pangolinfo API account for Amazon niche/category data.
>
> 使用本技能需要 Pangolinfo API 账号，用于获取亚马逊利基/类目数据。

## Step 2: Register

> 1. Go to [pangolinfo.com](https://pangolinfo.com/?referrer=clawhub_niche) and create an account
> 2. Find your API Key in the dashboard
>
> 1. 访问 [pangolinfo.com](https://pangolinfo.com/?referrer=clawhub_niche) 注册账号
> 2. 在控制台找到你的 API Key

## Step 3: Authenticate

**API key (recommended):**
```bash
export PANGOLINFO_API_KEY="<api_key>"
python3 scripts/pangolinfo.py --auth-only
```

**Email + password:**
```bash
export PANGOLINFO_EMAIL="[REDACTED_SECRET]"
export PANGOLINFO_PASSWORD="their-password"
python3 scripts/pangolinfo.py --auth-only
```

**Optional caching (user must opt in):**
```bash
python3 scripts/pangolinfo.py --auth-only --cache-key
```

## Step 4: Confirm

After `"success": true`:
1. Tell the user authentication succeeded
2. Remind them env vars must remain set (unless cached)
3. Retry their original request

## Credit System

| API | Credits |
|---|---|
| Category Tree | 2 |
| Category Search | 2 |
| Category Paths | 2 |
| Category Filter | 5 |
| Niche Filter | 10 |

API key is permanent. Credits consumed on success only. Empty results not charged.


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)