Skill: pangolinfo/pangolinfo-ai-serp (version 2.0.2)

--- name: pangolinfo-ai-serp description: > Search Google programmatically and extract structured SERP data, Google AI Overviews (SGE), citations, screenshots, and AI Mode follow-up results. Use when you need a Google SERP API, AI Overview scraper, SGE monitoring, brand visibility tracking, citation extraction, SEO research, competitor analysis, zero-click search monitoring, or grounded web results for AI agents. Helpful for requests like "search Google and return exact sources", "track AI Overview citations for my brand", "monitor ranking changes", "capture a SERP screenshot", "compare organic results vs AI Overview", or "my crawler gets blocked by Google". Built for OpenClaw, Claude Code, Cursor, LangChain, LangGraph, AutoGen, CrewAI, and research agents that need anti-bot search extraction instead of hallucinated answers. homepage: https://pangolinfo.com/?referrer=clawhub_ai_serp metadata: openclaw: emoji: "🔎" os: ["darwin", "linux"] requires: env: - PANGOLINFO_API_KEY - PANGOLINFO_EMAIL - PANGOLINFO_PASSWORD primaryEnv: PANGOLINFO_API_KEY bins: ["python3"] notes: "Auth: set PANGOLINFO_API_KEY (recommended) OR PANGOLINFO_EMAIL + PANGOLINFO_PASSWORD." tags: [seo, serp, serp-api, google-serp, sge, ai-overview, citation-extraction, rank-tracking, brand-monitoring, competitor-research, screenshot, research-agent] version: 2.0.2 --- ## 🌟 Why Use This Skill? (User Stories) This skill acts as the ultimate **Sensing Layer** for AI Agents, resolving the common pain points of real-time web search: - **Zero-Click Search Analysis**: Automatically track and scrape Google AI Overviews (SGE) for branded keywords. - **Hallucination-Free Agents**: Retrieve structured citations and source links to ground your LLM responses. ## 🤖 Compatible Agent Frameworks - **OpenClaw** (Native CLI & Terminal) - **LangGraph / Dify** (As a robust web-search tool node) ## 🌐 多语言适配 (Multi-language Support) - **🇨🇳 中文适用场景**: Google SGE/AI Overviews 结构化数据抓取，精准追踪引文来源，彻底消除大模型搜索幻觉。 - **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). # Pangolinfo AI SERP (Google SERP + AI Overviews) Search Google programmatically via **Pangolinfo** APIs. Extract **Google AI Overviews**, run **AI Mode** searches with **multi-turn follow-ups**, and optionally capture **screenshots**. ## Use when - You need a **Google SERP API** for agents or automations. - You want to **extract AI Overviews / SGE citations** instead of relying on hallucinated summaries. - You need **SEO monitoring**, **brand visibility tracking**, or **competitor research** from live Google results. - You want to compare **organic results vs AI Overview coverage** for a keyword. - Your current Google scraping flow gets blocked by **IP limits, CAPTCHA, or unstable selectors**. ## Migration note (Amazon) Amazon scraping is now a separate skill: **Pangolinfo Amazon Scraper**. If you previously used Amazon features from the combined skill, install the new Amazon skill and switch your `--mode` usage accordingly. ## Prerequisites - **Python 3.6+** (standard library only) - A **Pangolinfo account**: https://pangolinfo.com/?referrer=clawhub_serp - Auth env vars (choose one): - `PANGOLINFO_API_KEY` (recommended) - or `PANGOLINFO_EMAIL` + `PANGOLINFO_PASSWORD` ```bash export PANGOLINFO_API_KEY="..." # or export PANGOLINFO_EMAIL="..." export PANGOLINFO_PASSWORD="..." ``` ## Minimal examples AI Mode search: ```bash python3 scripts/pangolinfo.py --q "what is quantum computing" --mode ai-mode ``` Standard SERP + AI Overview extraction (+ optional screenshot): ```bash python3 scripts/pangolinfo.py --q "openclaw" --mode serp --screenshot ``` Multi-turn dialogue (AI Mode follow-ups): ```bash python3 scripts/pangolinfo.py --q "python web frameworks" --mode ai-mode \ --follow-up "compare flask vs django" \ --follow-up "which is better for beginners" ``` ## Output The script prints structured JSON to stdout. Key output fields: - `organic_results[]` - `ai_overview[]` (only when returned) - `screenshot` (only when requested and available) - `task_id`, `success` ## Links - Homepage: https://pangolinfo.com/?referrer=clawhub_serp - Legacy skill page (will redirect): https://clawhub.ai/tammy-hash/pangolinfo-scrape ## Deep-dive references - [references/setup-guide.md](references/setup-guide.md) - [references/output-schema.md](references/output-schema.md) - [references/error-codes.md](references/error-codes.md) ## 🎯 Quick Start Prompts (Copy-Paste) - *"Search Google for 'best AI agents 2026', extract the AI Overview (SGE) text, and list the exact citations used."* - *"Monitor the top 10 SERP results for 'Pangolinfo' and generate a markdown table of titles and URLs."* ## 🛑 Boundaries & Error Handling **When NOT to use:** Do not use this tool for deep-crawling internal site pages. It is strictly for Google Search Engine Results Pages. **Agent Instructions for Errors:** - **401 Unauthorized**: Missing/invalid `PANGOLINFO_API_KEY`. - **429 Rate Limit**: Wait 5 seconds and retry. Do not immediately fail. - **Missing AI Overview**: SGE is not triggered for every query. If missing, fallback gracefully to standard organic results.

path: scripts/self_test.sh
content: #!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PANGOLINFO="$SCRIPT_DIR/pangolinfo.py"

# Cross-platform Python detection
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 code $?)"
    FAIL=$((FAIL + 1))
  fi
}

echo "=== Pangolinfo AI SERP Self-Test ==="

# --- Auth tests (no credits consumed) ---
echo "Auth tests:"
run_test "auth-check" $PYTHON "$PANGOLINFO" --auth-only

# --- Input validation tests (no credits consumed) ---
echo "Input validation tests:"

run_test "missing-query" bash -c "! $PYTHON \"$PANGOLINFO\" 2>/dev/null"
run_test "follow-up-in-serp" bash -c "! $PYTHON \"$PANGOLINFO\" --q test --mode serp --follow-up q 2>/dev/null"
run_test "region-in-ai-mode" bash -c "! $PYTHON \"$PANGOLINFO\" --q test --mode ai-mode --region us 2>/dev/null"
run_test "invalid-region" bash -c "! $PYTHON \"$PANGOLINFO\" --q test --mode serp --region xx 2>/dev/null"
run_test "invalid-num-zero" bash -c "! $PYTHON \"$PANGOLINFO\" --q test --num 0 2>/dev/null"

# --- Live API tests (consumes credits) ---
if [ "${RUN_LIVE_TESTS:-0}" = "1" ]; then
  echo "Live API tests (credits will be consumed):"

  run_test "serp-query" bash -c "$PYTHON \"$PANGOLINFO\" --q openclaw --mode serp 2>/dev/null | $PYTHON -c \"import sys,json; d=json.load(sys.stdin); assert d['success']==True; print(f'Got {d.get(\\\"results_num\\\",0)} results')\""

  run_test "ai-mode-query" bash -c "$PYTHON \"$PANGOLINFO\" --q 'what is python' 2>/dev/null | $PYTHON -c \"import sys,json; d=json.load(sys.stdin); assert d['success']==True\""
else
  echo "Live API tests: SKIPPED (set RUN_LIVE_TESTS=1 to enable -- consumes credits)"
fi

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


path: scripts/pangolinfo.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: references/setup-guide.md
content: # First-Time Setup Guide

## Step 1: Explain what's needed

> To use this skill, you need a Pangolinfo API account. Pangolin provides Google search and AI Overview data through its APIs.
>
> 使用本技能需要 Pangolinfo API 账号。Pangolinfo 提供 Google 搜索和 AI 概览数据的 API 服务。

## Step 2: Guide registration

> 1. Go to [pangolinfo.com](https://pangolinfo.com/?referrer=clawhub_serp) and create an account
> 2. After login, find your API Key in the dashboard
>
> 1. 访问 [pangolinfo.com](https://pangolinfo.com/?referrer=clawhub_serp) 注册账号
> 2. 登录后在控制台找到你的 API Key

## Step 3: Collect credentials and authenticate

**If user provides an API key (recommended):**
```bash
export PANGOLINFO_API_KEY="<api_key>"
python3 scripts/pangolinfo.py --auth-only
```

**If user provides email + password:**
```bash
export PANGOLINFO_EMAIL="[REDACTED_SECRET]"
export PANGOLINFO_PASSWORD="their-password"
python3 scripts/pangolinfo.py --auth-only
```

**Optional caching (only if the user explicitly asks for it):**
```bash
python3 scripts/pangolinfo.py --auth-only --cache-key
```
This persists the API key to `~/.pangolinfo_api_key`. Revoke by deleting that file.

## Step 4: Confirm and proceed

After auth returns `"success": true`:
1. Tell the user: "Authentication successful!"
2. Remind them env vars must remain set for future calls (unless cached).
3. Immediately retry their original request.

## Credit System

- **AI Mode:** 2 credits per request
- **SERP:** 2 credits per request
- **SERP Plus:** 1 credit per request
- Credits are only consumed on success (API code 0)
- Auth checks do not consume credits
- API key is permanent and does not expire unless account is deactivated


path: references/output-schema.md
content: # Output Schema Reference

## Envelope Structure

### Success Envelope (stdout)

```json
{
  "success": true,
  "task_id": "<string>",
  "results_num": "<int>",
  "ai_overview_count": "<int>",
  "ai_overview": [ ... ],
  "organic_results": [ ... ],
  "screenshot": "<string>"
}
```

### Error Envelope (stderr)

```json
{
  "success": false,
  "error": {
    "code": "<string>",
    "api_code": "<int>",
    "message": "<string>",
    "hint": "<string>"
  }
}
```

## Success Fields

| Field | Type | Guaranteed | Description |
|-------|------|------------|-------------|
| `success` | boolean | Yes | Always `true` |
| `task_id` | string | Yes | Unique Pangolinfo task identifier |
| `results_num` | int | Yes | Number of organic results (0 if none) |
| `ai_overview_count` | int | Yes | Number of AI overview blocks (0 if none) |
| `ai_overview` | array | No | Present only if AI overviews exist |
| `organic_results` | array | No | Present only if organic results exist |
| `screenshot` | string | No | Screenshot URL; present only if `--screenshot` was used |

### `ai_overview[]` Item

| Field | Type | Description |
|-------|------|-------------|
| `content` | string[] | AI-generated text paragraphs |
| `references` | array | Source references (may be empty) |

### `ai_overview[].references[]` Item

| Field | Type | Description |
|-------|------|-------------|
| `title` | string | Source page title |
| `url` | string | Source page URL |
| `domain` | string | Source domain name |

### `organic_results[]` Item

| Field | Type | Description |
|-------|------|-------------|
| `title` | string | Result page title |
| `url` | string | Result page URL |
| `text` | string | Result snippet text (may be null) |

## Error Fields

| Field | Type | Guaranteed | Description |
|-------|------|------------|-------------|
| `success` | boolean | Yes | Always `false` |
| `error.code` | string | Yes | Machine-readable error code |
| `error.api_code` | int | No | Pangolinfo API error code (when applicable) |
| `error.message` | string | Yes | Human-readable description |
| `error.hint` | string | No | Suggested resolution |

## Auth-Only Output (stdout)

```json
{
  "success": true,
  "message": "Authentication successful",
  "api_key_preview": "eyJh...ab1c"
}
```

## Raw Mode

When using `--raw`, the unprocessed Pangolinfo API response is output. The envelope structure above does **not** apply.


path: references/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.


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