ChatGPT Claude Codex CLI Cohere Cursor DeepSeek Gemini GitHub Copilot GLM Grok Kimi Llama MiniMax Mistral OpenAI opencode Skill

hasdata

Use HasData APIs for web scraping and structured web data extraction.

LLM Mart · 0 points · 22 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download sickn33-agentic-awesome-skills-skills_hasdata-286166a.zip · 27 KB
Part of sickn33/agentic-awesome-skills — 427 skills
This skill couldn't be refreshed from GitHub on the last check — you're seeing the last imported snapshot.

Install

skills CLI npx skills add https://github.com/sickn33/agentic-awesome-skills/tree/main/skills/hasdata
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install sickn33-agentic-awesome-skills@llmmart
Git git clone https://github.com/sickn33/agentic-awesome-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole sickn33/agentic-awesome-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

HasData

Cloud platform for extracting public web data. One API key, three execution modes. All endpoints sit under https://api.hasdata.com and authenticate with x-api-key.

curl -G 'https://api.hasdata.com/scrape/google/serp' \
  --data-urlencode 'q=coffee' \
  -H 'x-api-key: <your-api-key>'

401 invalid key, 403 quota exhausted, 429 concurrency cap, 500 server error (retry).

When to Use

Use this skill when:

  • The user needs web scraping.
  • The user needs search engine results.
  • The user needs structured data extraction.
  • The user needs ecommerce, travel, jobs, or local business data.
  • The user explicitly asks about HasData.

Three execution modes

Mode Latency When Endpoint
Web Scraping API seconds Arbitrary URL — JS rendering, CSS/AI extraction, screenshots POST /scrape/web
Scraper APIs (sync) seconds Pre-parsed JSON for known platforms (Google, Amazon, Zillow, …) GET /scrape/<vertical>/<resource>
Scraper Jobs (async) minutes–hours Bulk extraction, recursive crawling, webhook fan-out POST /scrapers/<slug>/jobs

Decision rule. Default to a Scraper API when one exists for the platform (pre-parsed JSON, no selector maintenance). Use Web Scraping for arbitrary URLs not covered by an API. Reach for a Scraper Job only when no API equivalent exists — crawler, contacts, sec-edgar, amazon-bestsellers, amazon-product-reviews — or when async fan-out + webhooks save engineering time over a paginated client loop.

Always-true response shape

{ "requestMetadata": { "id": "…", "status": "ok", "url": "…" }, "...": "endpoint-specific" }

Treat data as valid only if requestMetadata.status === "ok". HTTP 200 alone isn't enough.

High-leverage patterns

  • SERP-first enrichment. Google SERP can surface public snippets for company and professional-profile lookup. Use it for business or authorized research, avoid unnecessary direct scraping, and treat personal email/phone lookup as allowed only with a legitimate purpose and user authorization.
  • AI Mode + verify. /scrape/google/ai-mode for the answer + references → /scrape/web (markdown) on each reference URL → cited RAG context, no vector DB.
  • Maps → leads. /scrape/google-maps/search returns business websites and phones; collect contact details only from public, permitted sources and apply opt-out, rate, and privacy-law constraints before any outreach use.
  • Crawler → corpus. crawler Scraper Job with outputFormat: ["markdown"] + includePaths: "/docs/.+" produces an LLM-ready corpus in one submission.
  • Pre-extracted via SERP rich snippets. knowledgeGraph, localResults, inlineShoppingResults, relatedQuestions carry pre-parsed public facts. Always check them before considering direct page access.

When to call from code (the wiring)

  • Auth: x-api-key header on every request. Read from HASDATA_API_KEY env. Never hardcode, never log.
  • Timeouts: set client timeout ≥ 300 s. HasData's own deadline is 300 s; shorter clients produce phantom failures while still being billed on completion.
  • Retries: 429 and 5xx only — exponential backoff, jitter. Never retry 4xx (auth, validation).
  • Concurrency: cap at your plan limit. The free tier is 1; anything higher just generates 429s.
  • Async jobs: the submit response handle is body.id (integer), not jobId. Persist it immediately. Poll GET /scrapers/jobs/<id> every 10–30 s with backoff; treat webhooks as best-effort and always pair with polling. On finished the status carries data: {csv, json, xlsx} short-lived URLs — download immediately.

See references/code-recipes.md for ready-to-paste Python and TypeScript clients with retry, backoff, bounded concurrency, and the full job lifecycle.

Common gotchas

  • 300 s server deadline. Match client timeout.
  • Disable jsRendering first, enable only if the page needs it — most static pages parse fine without a headless browser.
  • No cookies parameter — cookies go through headers["Cookie"].
  • includePaths regex is case-sensitive. /blog/.+ won't match /Blog/....
  • Scraper Job data is double-wrapped. Each row is body.data[i].data; outer wraps with id, jobId, dataId, createdAt, updatedAt.
  • requestMetadata.status === "ok" is the only success signal. HTTP 200 alone isn't enough.
  • Webhooks are best-effort with 3 retries. Always have a polling fallback.

References

Resources

Limitations

  • Requires access to HasData services and valid credentials.
  • Data quality and available fields depend on the target website and extraction method used.
  • JavaScript-heavy websites may require rendering, which can affect performance and cost.
  • Use only for public data or content the user is authorized to access; respect site terms, robots/access controls, privacy law, and rate limits.
  • Rate limits, quotas, and account restrictions may apply depending on the endpoint and subscription plan.
Files (agentic-awesome-skills)
  • references
    • code-recipes.md 5.4 KB
      # Code recipes — wiring HasData into your code
      
      ## Ground rules
      
      - **Base URL:** `https://api.hasdata.com`. Header `x-api-key` on every request.
      - **Methods:** Scraper APIs are `GET`; Web Scraping is `POST`; Scraper Jobs use `POST` (submit) + `GET` (status/results) + `DELETE` (stop).
      - **Key handling:** read from env (`HASDATA_API_KEY`). Never hardcode, never log.
      - **Timeouts:** **client timeout ≥ 300 s.** HasData's deadline is 300 s; shorter clients get phantom failures while still being billed.
      - **Retries:** `429` and `5xx` only with exponential backoff + jitter. Never retry `4xx`.
      - **Concurrency:** cap at plan limit. Free tier = 1.
      - **Success signal:** sync APIs require `body.requestMetadata.status === "ok"`. HTTP 200 alone isn't enough.
      
      ## Status codes
      
      | Code | Meaning | Action |
      |---|---|---|
      | 200 + `status:"ok"` | OK | Use body |
      | 401 | Bad/missing key | Fix — don't retry |
      | 403 | Quota exhausted | Don't retry |
      | 429 | Concurrency cap | Backoff + retry |
      | 500 | Server error | Retry |
      
      ## Python — minimal client
      
      ```python
      import os, requests
      
      class HasData:
          BASE = "https://api.hasdata.com"
      
          def __init__(self, api_key=None, timeout=300):
              self.s = requests.Session()
              self.s.headers["x-api-key"] = api_key or os.environ["HASDATA_API_KEY"]
              self.timeout = timeout
      
          def get(self, path, **params):
              r = self.s.get(f"{self.BASE}{path}", params=params, timeout=self.timeout)
              r.raise_for_status()
              body = r.json()
              if body.get("requestMetadata", {}).get("status") != "ok":
                  raise RuntimeError(f"hasdata not-ok: {body.get('requestMetadata')}")
              return body
      
          def post(self, path, body):
              r = self.s.post(f"{self.BASE}{path}", json=body, timeout=self.timeout)
              r.raise_for_status()
              return r.json()
      
      hd = HasData()
      serp = hd.get("/scrape/google/serp", q="coffee", num=20)["organicResults"]
      md   = hd.post("/scrape/web", {"url": "https://example.com", "outputFormat": ["markdown"]})["markdown"]
      ```
      
      ## Python — retry + bounded concurrency
      
      ```python
      import time, random
      from concurrent.futures import ThreadPoolExecutor, as_completed
      from requests import HTTPError
      
      def with_retry(fn, attempts=5, base=1.0, cap=60.0):
          for i in range(attempts):
              try:
                  return fn()
              except HTTPError as e:
                  code = e.response.status_code
                  if code == 429 or 500 <= code < 600:
                      time.sleep(min(cap, base * 2 ** i) + random.random())
                      continue
                  raise
          raise RuntimeError("retry exhausted")
      
      def scrape_many(urls, workers=5):
          out = {}
          with ThreadPoolExecutor(max_workers=workers) as ex:
              futs = {ex.submit(lambda u=u: hd.post("/scrape/web", {"url": u, "outputFormat": ["markdown"]})): u
                      for u in urls}
              for f in as_completed(futs):
                  try:
                      out[futs[f]] = f.result().get("markdown")
                  except Exception as e:
                      out[futs[f]] = e
          return out
      ```
      
      Cap `workers` at your plan's concurrency — anything higher just generates `429`s.
      
      ## TypeScript — minimal client
      
      ```typescript
      const BASE = "https://api.hasdata.com";
      const KEY  = process.env.HASDATA_API_KEY!;
      
      async function get<T = any>(path: string, params: Record<string, string | number> = {}): Promise<T> {
        const qs = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)]));
        const r = await fetch(`${BASE}${path}?${qs}`, {
          headers: { "x-api-key": KEY },
          signal:  AbortSignal.timeout(300_000),
        });
        if (!r.ok) throw new Error(`HasData ${r.status} ${await r.text()}`);
        const body = await r.json() as any;
        if (body?.requestMetadata?.status && body.requestMetadata.status !== "ok") {
          throw new Error(`HasData not-ok: ${JSON.stringify(body.requestMetadata)}`);
        }
        return body as T;
      }
      
      async function post<T = any>(path: string, body: unknown): Promise<T> {
        const r = await fetch(`${BASE}${path}`, {
          method:  "POST",
          headers: { "x-api-key": KEY, "Content-Type": "application/json" },
          body:    JSON.stringify(body),
          signal:  AbortSignal.timeout(300_000),
        });
        if (!r.ok) throw new Error(`HasData ${r.status} ${await r.text()}`);
        return r.json() as Promise<T>;
      }
      
      // Bounded concurrency, no deps
      async function pool<T, R>(items: T[], n: number, fn: (x: T) => Promise<R>) {
        const out: R[] = []; let i = 0;
        await Promise.all(Array.from({ length: n }, async () => {
          while (i < items.length) { const k = i++; out[k] = await fn(items[k]); }
        }));
        return out;
      }
      ```
      
      ## Pagination cheat sheet
      
      | Endpoint family | Pagination |
      |---|---|
      | Google SERP / Light SERP / Bing | `start` + `num` (max 100) |
      | Google Maps Search | `start` (steps of 20) |
      | Yelp Search | `start` (steps of 10) |
      | Google Maps Reviews / Glassdoor / Airbnb | `nextPageToken` |
      | Indeed / YellowPages / Amazon Search | `start` or `page` |
      | Shopify Products | `page` (with `limit` ≤ 250) |
      | Scraper-Job results | `page` + `limit` (max 100) until `meta.currentPage >= meta.lastPage` |
      
      ## Pre-ship checklist
      
      - [ ] Key from env, never logged.
      - [ ] All HTTP timeouts ≥ 300 s.
      - [ ] `requestMetadata.status === "ok"` checked on every sync response.
      - [ ] Backoff on 429 + 5xx; never on 4xx.
      - [ ] Concurrency capped at plan limit.
      - [ ] Job `id` (from submit response) persisted to durable storage immediately.
      - [ ] Webhooks paired with polling fallback.
      - [ ] Result files downloaded immediately on `scraper.job.finished`.
      
    • ecommerce.md 4 KB
      # E-commerce APIs — Amazon & Shopify
      
      | Endpoint | Returns |
      |---|---|
      | `/scrape/amazon/product` | Single product (price, ratings, variants, other sellers, A+) |
      | `/scrape/amazon/search` | Search results (sponsored + organic) |
      | `/scrape/amazon/seller` | Seller profile |
      | `/scrape/amazon/seller-products` | Seller catalog |
      | `/scrape/shopify/products` | Products from any Shopify store |
      | `/scrape/shopify/collections` | Collections from any Shopify store |
      
      All synchronous `GET`.
      
      ## Amazon Product
      
      ```python
      import requests
      
      resp = requests.get(
          "https://api.hasdata.com/scrape/amazon/product",
          headers={"x-api-key": API_KEY},
          params={"asin": "B0DHJ7SBDR", "domain": "www.amazon.com", "otherSellers": "true"},
          timeout=300,
      )
      ```
      
      | Param | Notes |
      |---|---|
      | `asin` | **Required.**. |
      | `domain` | `www.amazon.com` (default), `.co.uk`, `.de`, `.co.jp`, … |
      | `language` | Locale per domain. |
      | `deliveryZip` | Affects shipping/availability fields. |
      | `shippingLocation` | 2-letter country code. |
      | `otherSellers` | `true` (default) to include other-seller block. |
      
      Response: top-level `requestMetadata` + `product`. The `product` object's keys (verified live): `asin`, `url`, `title`, `brand`, `isAvailable`, `primaryFeatures`, `features`, `featureBullets`, `description`, `badges`, `breadcrumbs`, `whatIsInTheBox`, `variants`, `totalImages`, `primaryImage`, `images`, `descriptionImages`, `totalVideos`, `primaryVideo`, `videos`, `specification`, `reviewsInfo` (rating + count + sample reviews live here, not at the root). Pricing fields are surfaced via `variants` and `specification`.
      
      ## Amazon Search
      
      ```python
      params = {"q": "mechanical keyboard", "domain": "www.amazon.com", "page": 1}
      ```
      
      Params: `q` (required), `domain`, `language`, `page`, `deliveryZip`, `shippingLocation`, `sortBy`.
      
      ## Amazon Seller / Seller Products
      
      ```python
      profile = requests.get(
          "https://api.hasdata.com/scrape/amazon/seller",
          headers={"x-api-key": API_KEY},
          params={"sellerId": "A1MNOPQR", "domain": "www.amazon.com"},
          timeout=300,
      ).json()
      
      catalog = requests.get(
          "https://api.hasdata.com/scrape/amazon/seller-products",
          headers={"x-api-key": API_KEY},
          params={"sellerId": "A1MNOPQR", "page": 1},
          timeout=300,
      ).json()
      ```
      
      Use cases: counterfeit detection, MAP enforcement, competitor catalog mirroring.
      
      ## Shopify Products
      
      Works on **any** Shopify storefront with no authentication.
      
      ```python
      def shopify_all(store_url):
          page, out = 1, []
          while True:
              batch = requests.get(
                  "https://api.hasdata.com/scrape/shopify/products",
                  headers={"x-api-key": API_KEY},
                  params={"url": store_url, "page": page, "limit": 250},
                  timeout=300,
              ).json().get("products", [])
              if not batch:
                  return out
              out.extend(batch)
              page += 1
      ```
      
      | Param | Notes |
      |---|---|
      | `url` | **Required.** Storefront URL. |
      | `limit` | 1–250, default `1`. **Bump to 250** for catalog work. |
      | `page` | 1-indexed. |
      | `collection` | Collection handle filter. |
      
      `/scrape/shopify/collections` has the same shape and returns the collection list.
      
      ## Patterns
      
      ### Cross-merchant price comparison
      
      ```python
      a = requests.get("https://api.hasdata.com/scrape/amazon/search",
                       headers={"x-api-key": API_KEY},
                       params={"q": query}, timeout=300).json()
      g = requests.get("https://api.hasdata.com/scrape/google/shopping",
                       headers={"x-api-key": API_KEY},
                       params={"q": query, "gl": "us"}, timeout=300).json()
      ```
      
      ### Reviews & bestsellers go through Scraper Jobs
      
      The Product API only includes a sample of reviews. For all reviews use the `amazon-product-reviews` Scraper Job. For bestseller ranks use `amazon-bestsellers` — there's no synchronous API. See `scraper-jobs.md`.
      
      ## Gotchas
      
      - **Same ASIN ≠ same product across `domain`s.** `.com` vs `.co.uk` can differ.
      - **`deliveryZip` changes availability.** Pass it when stock matters; omit for spec-only scrapes.
      - **Shopify `limit` defaults to 1** — always set 250 for catalog crawls.
      
    • jobs.md 3.1 KB
      # Jobs APIs — Indeed & Glassdoor
      
      | Endpoint | Returns |
      |---|---|
      | `/scrape/indeed/listing` | Indeed search results |
      | `/scrape/indeed/job` | Single Indeed job detail |
      | `/scrape/glassdoor/listing` | Glassdoor search results |
      | `/scrape/glassdoor/job` | Single Glassdoor job (incl. salary band, company snippet) |
      
      All synchronous `GET`.
      
      ## Indeed Listing
      
      ```python
      import requests
      
      resp = requests.get(
          "https://api.hasdata.com/scrape/indeed/listing",
          headers={"x-api-key": API_KEY},
          params={
              "keyword":  "software engineer",
              "location": "New York, NY",
              "sort":     "date",
              "domain":   "www.indeed.com",
              "start":    0,
          },
          timeout=300,
      )
      ```
      
      | Param | Notes |
      |---|---|
      | `keyword` | **Required.** |
      | `location` | **Required.** |
      | `sort` | `date`, `relevance` (default). |
      | `domain` | Country site — `www.indeed.com`, `uk.indeed.com`, `de.indeed.com`. |
      | `start` | Offset, **steps of 10**. |
      
      Response: `jobs` array with `title`, `company`, `location`, `salary`, `description`, `postedAt`, `link`, `jobKey`. Salary is free-form string — parse with regex.
      
      ## Indeed Job
      
      Pass `jobKey` from listing → returns full description, requirements, benefits, company URL.
      
      ## Glassdoor Listing & Job
      
      ```python
      params = {"keyword": "software engineer", "location": "New York, NY", "sort": "recent"}
      # pagination: pass back nextPageToken
      ```
      
      | Param | Notes |
      |---|---|
      | `keyword`, `location` | **Required.** |
      | `sort` | `recent` (default), `relevant`. |
      | `domain` | Country site. |
      | `nextPageToken` | Cursor pagination. |
      
      ## Patterns
      
      ### Salary band
      
      ```python
      import re, statistics
      
      def salary_band(role, location):
          page = requests.get(
              "https://api.hasdata.com/scrape/indeed/listing",
              headers={"x-api-key": API_KEY},
              params={"keyword": role, "location": location}, timeout=300,
          ).json()
          nums = [int(m.replace(",", ""))
                  for j in page.get("jobs", [])
                  for m in re.findall(r"\$([\d,]+)", j.get("salary") or "")]
          if not nums: return None
          return {"n": len(nums), "median": statistics.median(nums)}
      ```
      
      ### Hiring velocity by company
      
      ```python
      from collections import Counter
      
      page = indeed_listing(role, loc, sort="date")
      Counter(j.get("company") for j in page.get("jobs", []))
      ```
      
      Run weekly; sustained increases often precede earnings/PR signals.
      
      ### Pagination differs
      
      ```python
      # Indeed: numeric start
      for p in range(10):
          page = indeed_listing(kw, loc, start=p * 10)
      
      # Glassdoor: cursor token
      out, token = [], None
      while True:
          page = glassdoor_listing(kw, loc, next_token=token)
          out.extend(page.get("jobs", []))
          token = page.get("nextPageToken")
          if not token: break
      ```
      
      ## Gotchas
      
      - **Salary is free-form string.** Always regex-parse.
      - **Indeed = numeric start (10), Glassdoor = token.** Don't mix.
      - **`domain` matters for non-US.** `uk.indeed.com`, `ca.indeed.com`, etc.
      - **Prefer the API + pagination for bulk.** Reach for the matching Scraper Job only when you want webhook-driven fan-out across many keyword × location pairs without managing the polling loop yourself.
      
    • local-business.md 5.4 KB
      # Local Business APIs — Google Maps, Yelp, YellowPages
      
      | Endpoint | Returns |
      |---|---|
      | `/scrape/google-maps/search` | Search results in a viewport |
      | `/scrape/google-maps/place` | Single place details |
      | `/scrape/google-maps/reviews` | Reviews for a place, paginated |
      | `/scrape/google-maps/photos` | Photo gallery |
      | `/scrape/google-maps/posts` | Owner-published posts (offers, events, announcements) |
      | `/scrape/google-maps/contributor-reviews` | All reviews by a Google reviewer |
      | `/scrape/yelp/search` | Yelp search |
      | `/scrape/yelp/place` | Yelp business detail |
      | `/scrape/yellowpages/search` | YellowPages search |
      | `/scrape/yellowpages/place` | YellowPages business detail |
      
      All synchronous `GET`.
      
      ## Google Maps Search
      
      ```python
      import requests
      
      resp = requests.get(
          "https://api.hasdata.com/scrape/google-maps/search",
          headers={"x-api-key": API_KEY},
          params={"q": "Pizza", "ll": "@40.7455,-74.0083,14z"},
          timeout=300,
      )
      ```
      
      | Param | Notes |
      |---|---|
      | `q` | **Required.** Free-form query. |
      | `ll` | `@LAT,LNG,ZOOMz` viewport — **lat/lng + zoom, not a city name**. Required for tight pagination. |
      | `domain`, `gl`, `hl` | Standard. |
      | `start` | Pagination offset, **steps of 20**. |
      
      Response: `localResults` — each entry has `position`, `title`, `placeId`, `dataId`, `kgmid`, `thumbnail`, `phone`, `address`, `website`, `description`, `workingHours` (object with `timezone` + `days[]`), `openState`, `rating`, `reviews`, `type` + `types[]` (categories), `price`, `priceDescription`, `gpsCoordinates`, `serviceOptions[]`, `extensions` (offerings, accessibility, payments, …), `menu`. Feed `placeId`/`dataId` into `/place` and `/reviews`.
      
      ## Google Maps Place
      
      ```python
      params = {"placeId": "ChIJFU2bda4SM4cRKSCRyb6pOB8"}
      ```
      
      Returns full place detail — coordinates, hours by day, phone, website, popular times, attributes (delivery, dine-in), photo summary.
      
      ## Google Maps Reviews
      
      ```python
      def reviews(place_id=None, data_id=None, sort_by="newestFirst", token=None):
          params = {}
          if place_id: params["placeId"] = place_id
          if data_id:  params["dataId"]  = data_id
          if sort_by:  params["sortBy"]  = sort_by
          if token:    params["nextPageToken"] = token
          return requests.get(
              "https://api.hasdata.com/scrape/google-maps/reviews",
              headers={"x-api-key": API_KEY},
              params=params, timeout=300,
          ).json()
      ```
      
      | Param | Notes |
      |---|---|
      | `placeId` / `dataId` | Pass one. `dataId` is the hex pair from Maps results. |
      | `sortBy` | `newestFirst`, `highestRating`, `lowestRating`, `mostRelevant`. |
      | `topicId` | Filter by review topic. |
      | `nextPageToken` | Cursor pagination. |
      
      ## Google Maps Posts
      
      ```python
      resp = requests.get(
          "https://api.hasdata.com/scrape/google-maps/posts",
          headers={"x-api-key": API_KEY},
          params={"placeId": "ChIJ..."},      # or dataId="0x...:0x..."
          timeout=300,
      )
      for p in resp.json().get("posts", []):
          print(p["postedAt"], p["description"][:120], p.get("cta", {}).get("url"))
      ```
      
      Either `placeId` **or** `dataId` is required. Optional: `hl` (UI language), `nextPageToken` (cursor pagination). 10 credits/call.
      
      Per-post fields (verified live): `postId`, `locationId`, `title`, `description`, `image`, `cta` (`label` + `url`), `createdAt` (ISO), `postedAt` (human-readable), `shareUrl`, `postUrl`. Response top-level: `posts`, `pagination`, `source`, `requestMetadata`.
      
      Posts surface current offers, holiday hours, events, and product launches the business is actively promoting. Cheaper signal than the homepage scrape, and `cta.url` is the canonical landing page.
      
      ## Yelp & YellowPages
      
      ```python
      # Yelp
      params = {"keyword": "McDonald's", "location": "New York, NY", "start": 0}  # steps of 10
      # YellowPages
      params = {"keyword": "Plumbers", "location": "New York, NY", "page": 1}
      ```
      
      YellowPages is US-only — EU/APAC searches return nothing useful.
      
      ## Patterns
      
      ### Lead-gen with emails (Maps + Web Scraping)
      
      Maps results have website + phone but **not email**. Combine with the Web Scraping API's `extractEmails` only for public business contact pages, legitimate outreach, and workflows that honor opt-out, privacy-law, rate, and terms-of-service constraints:
      
      ```python
      leads = []
      for biz in maps_results.get("localResults", []):
          site = biz.get("website")
          if not site: continue
          page = requests.post(
              "https://api.hasdata.com/scrape/web",
              headers={"x-api-key": API_KEY},
              json={"url": site, "extractEmails": True},
              timeout=300,
          ).json()
          leads.append({
              "name":    biz["title"],
              "phone":   biz.get("phone"),
              "website": site,
              "emails":  page.get("extractedEmails") or [],
          })
      ```
      
      For higher volume, switch to the `contacts` Scraper Job (see `scraper-jobs.md`) only when you have a legitimate purpose, a compliant outreach process, and rate/opt-out controls.
      
      ### New-business discovery
      
      Filter Maps by review count `< 5` — usually means recently opened.
      
      ```python
      new = [b for b in localResults if (b.get("reviews") or 0) < 5]
      ```
      
      ### Multi-location chain mapping
      
      Search the brand name; every `localResults` entry is a branch.
      
      ## Gotchas
      
      - **`ll` is a viewport, not a city.** `@lat,lng,zoom`. Pasting "Brooklyn" fails.
      - **Pagination steps differ.** Maps `start` = +20, Yelp `start` = +10, Maps Reviews uses `nextPageToken`.
      - **`placeId` vs `dataId`** — Place prefers `placeId`; Reviews accepts either.
      - **YellowPages is US-only.**
      
    • real-estate.md 2.7 KB
      # Real Estate APIs — Zillow, Redfin
      
      | Endpoint | Returns |
      |---|---|
      | `/scrape/zillow/listing` | Search results by area + filters |
      | `/scrape/zillow/property` | Single home (history, agent, schools, taxes) |
      | `/scrape/redfin/listing` | Redfin search results |
      | `/scrape/redfin/property` | Single Redfin home |
      
      All synchronous `GET`. 5 credits each.
      
      For short-term rentals (Airbnb), hotels (Booking), and flights, see `travel.md`.
      
      ## Zillow Listing
      
      Filter params use **bracketed** keys (`price[min]`, `beds[max]`).
      
      ```python
      import requests
      
      def zillow_search(keyword, listing_type="forSale", **filters):
          r = requests.get(
              "https://api.hasdata.com/scrape/zillow/listing",
              headers={"x-api-key": API_KEY},
              params={"keyword": keyword, "type": listing_type, **filters},
              timeout=300,
          )
          return r.json()
      
      zillow_search("Brooklyn, NY", price={"min": 800000, "max": 2000000})
      zillow_search("33321", "sold", daysOnZillow="6m")  # recent comps
      ```
      
      `requests` + `axios` serialize nested dicts as `price[min]=…&price[max]=…` automatically. With raw `URLSearchParams`, build the bracketed keys yourself.
      
      | Param | Notes |
      |---|---|
      | `keyword` | **Required.** Area string ("New York, NY", zip, neighborhood). |
      | `type` | **Required.** `forSale`, `forRent`, `sold`. |
      | `price[min/max]`, `beds[min/max]`, `baths[min/max]`, `sqft[min/max]` | Range filters. |
      | `daysOnZillow` | `24h`, `7d`, `14d`, `30d`, `90d`, `6m`, `12m`. |
      | `page` | Pagination. |
      
      Response: `requestMetadata`, `searchInformation`, **`properties`** (the listings array — not `listings`), `pagination`.
      
      ## Zillow Property
      
      ```python
      requests.get(
          "https://api.hasdata.com/scrape/zillow/property",
          headers={"x-api-key": API_KEY},
          params={"url": url, "extractAgentEmails": "true"},
          timeout=300,
      )
      ```
      
      Takes a full Zillow URL (not zpid). Returns address, lot/sqft/beds/baths, price + tax history, schools, agent block, photos. Agent emails are best-effort.
      
      ## Redfin
      
      ```python
      # Listing
      params = {"keyword": "33321", "type": "forSale", "page": 1}
      # Property
      params = {"url": "https://www.redfin.com/FL/Tamarac/9...html"}
      ```
      
      Same bracketed `price[min]`, `beds[min]`, etc. as Zillow. Zip codes work best for `keyword`.
      
      ## Patterns
      
      ### Sold comps for ROI
      
      ```python
      sold = zillow_search(zip_code, "sold", daysOnZillow="6m").get("properties", [])
      ppsf = [(l["price"] / l["livingArea"]) for l in sold if l.get("livingArea")]
      ```
      
      ## Gotchas
      
      - **Bracketed query keys** — work with `requests`/`axios`, not raw `URLSearchParams`.
      - **`type=sold` + `daysOnZillow` = comps recipe.** Without `daysOnZillow`, history is unbounded.
      - **Property endpoints take URLs**, not IDs.
      - **Agent emails are best-effort.**
      
    • scraper-jobs.md 8.4 KB
      # Scraper Jobs — async, bulk
      
      Use only when there's no Scraper-API equivalent (`crawler`, `contacts`, `sec-edgar`, `amazon-bestsellers`, `amazon-product-reviews`) or when you want webhook-driven fan-out without managing your own polling loop. Otherwise the matching Scraper API + paginated client loop is simpler.
      
      | Slug | Notes |
      |---|---|
      | `crawler` | Recursive site crawl. Accepts every Web Scraping API parameter. |
      | `contacts` | URL list → emails / phones / social profiles. |
      | `sec-edgar` | Bulk SEC filings by CIK / ticker / company name. |
      | `google-serp`, `google-maps`, `google-maps-reviews`, `google-trends` | Bulk Google. |
      | `amazon-search`, `amazon-product`, `amazon-product-reviews`, `amazon-seller-products`, `amazon-bestsellers` | Bulk Amazon. |
      | `shopify` | Multi-store crawl. |
      | `zillow`, `redfin`, `airbnb` | Bulk real estate. |
      | `yelp`, `yellow-pages` | Bulk local. |
      | `indeed`, `glassdoor` | Bulk jobs. |
      
      ## Lifecycle
      
      1. `POST /scrapers/<slug>/jobs` → returns the full job record. **The handle is `body.id` (numeric integer), not `jobId`** despite older doc snippets — store this. Status starts as `pending`.
      2. `GET /scrapers/jobs/<id>` — poll status.
      3. `GET /scrapers/jobs/<id>/results?page=…&limit=100` — once `status === "finished"`.
      4. `DELETE /scrapers/jobs/<id>` — stop early (rows produced before stop are kept).
      
      Status values: `pending` → `in_progress` → `finished` (or `stopped` if cancelled).
      
      **Shortcut for finished jobs:** the status response on a `finished` job carries a `data` object with direct download URLs:
      
      ```json
      "data": {
        "csv":  "https://f005.backblazeb2.com/file/.../{uuid}.csv",
        "json": "https://f005.backblazeb2.com/file/.../{uuid}.json",
        "xlsx": "https://f005.backblazeb2.com/file/.../{uuid}.xlsx"
      }
      ```
      
      For one-shot ingestion, fetch `data.json` directly instead of paging `/results`. **These URLs are short-lived** — download immediately on `finished`.
      
      ## End-to-end (Python)
      
      ```python
      import os, time, requests
      
      API_KEY = os.environ["HASDATA_API_KEY"]
      H = {"x-api-key": API_KEY, "Content-Type": "application/json"}
      BASE = "https://api.hasdata.com"
      
      def submit(slug, body):
          r = requests.post(f"{BASE}/scrapers/{slug}/jobs", headers=H, json=body, timeout=60)
          r.raise_for_status()
          return r.json()["id"]                            # numeric job id — not "jobId"
      
      def wait(job_id, poll=10, cap=60, timeout=3600):
          deadline = time.time() + timeout
          while time.time() < deadline:
              s = requests.get(f"{BASE}/scrapers/jobs/{job_id}", headers=H, timeout=60).json()
              if s["status"] in ("finished", "stopped"):
                  return s
              time.sleep(poll)
              poll = min(poll * 1.5, cap)
          raise TimeoutError(job_id)
      
      def results(job_id):
          page = 1
          while True:
              body = requests.get(
                  f"{BASE}/scrapers/jobs/{job_id}/results",
                  headers=H, params={"page": page, "limit": 100}, timeout=120,
              ).json()
              for row in body["data"]:
                  yield row["data"]                       # double-wrapped — see below
              if body["meta"]["currentPage"] >= body["meta"]["lastPage"]:
                  return
              page += 1
      ```
      
      ### Response shapes
      
      Submit (live):
      ```json
      {
        "id": 416349,                        // ← the job handle, integer
        "scraperId": 26,
        "status": "pending",
        "creditsSpent": 0,
        "dataRowsCount": 0,
        "input": { ... },
        "createdAt": "...", "updatedAt": "...",
        "scraper": { "slug": "contacts", ... },
        "columns": [ ... ]
      }
      ```
      
      Status (live; numeric fields arrive as **strings** when populated):
      ```json
      {
        "id": 416349,
        "status": "finished",
        "creditsSpent": "5",                 // string!
        "dataRowsCount": "1",                // string!
        "input": { ... },
        "data": {
          "csv":  "https://f005.backblazeb2.com/.../{uuid}.csv",
          "json": "https://f005.backblazeb2.com/.../{uuid}.json",
          "xlsx": "https://f005.backblazeb2.com/.../{uuid}.xlsx"
        }
      }
      ```
      
      Results page:
      ```json
      {
        "meta": {
          "total": 1, "perPage": 100,
          "currentPage": 1, "lastPage": 1,
          "firstPage": 1, "firstPageUrl": "/?page=1",
          "lastPageUrl": "/?page=1",
          "nextPageUrl": null, "previousPageUrl": null
        },
        "data": [
          {
            "id": "...", "jobId": 416349, "dataId": "...",
            "data": { /* the actual scraped row */ },
            "createdAt": "...", "updatedAt": "..."
          }
        ]
      }
      ```
      
      **Double `data`** — the row is `body["data"][i]["data"]`; the outer wraps with `id`, `jobId`, `dataId`, `createdAt`, `updatedAt`.
      
      ## Common body fields
      
      - `limit` (int) — max rows. `0` = no cap.
      - `webhook.url` (string, https), `webhook.events` (any subset of `scraper.job.started`, `scraper.data.scraped`, `scraper.job.finished`), `webhook.headers` (sent on every callback — pin a shared secret here).
      
      ## Webhooks
      
      ```python
      # Submit with webhook
      submit("indeed", {
          "keywords":  ["software engineer", "data scientist"],
          "locations": ["New York, NY", "Remote"],
          "limit":     500,
          "webhook":   {
              "url":     "https://your.app/hasdata-hook",
              "events":  ["scraper.data.scraped", "scraper.job.finished"],
              "headers": {"x-shared-secret": SHARED_SECRET},
          },
      })
      ```
      
      ```python
      from flask import Flask, request, abort
      app = Flask(__name__)
      
      @app.post("/hasdata-hook")
      def hook():
          if request.headers.get("x-shared-secret") != SHARED_SECRET:
              abort(401)
          e = request.json
          if e["event"] == "scraper.data.scraped":
              save_row(e["jobId"], e["data"])
          elif e["event"] == "scraper.job.finished":
              finalize(e["jobId"])
          return "", 200                  # 2xx prevents retry
      ```
      
      - Async with **3 retries** on non-2xx. **Order not guaranteed** — payload is the source of truth.
      - **No documented HMAC.** Pin a shared secret via `webhook.headers`, or just fetch results via the API on `scraper.job.finished` and ignore per-row deliveries.
      - **Always pair webhooks with polling.** A long quiet period probably means missed callbacks.
      
      ## Per-scraper bodies
      
      ### `crawler` — recursive site crawl
      
      Accepts every Web Scraping API parameter applied to **every page**.
      
      | Field | Notes |
      |---|---|
      | `urls` | **Required.** Seed URLs. |
      | `maxDepth` | Hops from seed. |
      | `includePaths` / `excludePaths` | Regex. **Case-sensitive.** |
      | `limit` | Cap on pages. `0` = unlimited. |
      
      ```python
      job = submit("crawler", {
          "urls":         ["https://docs.example.com"],
          "maxDepth":     5,
          "includePaths": "/docs/.+",
          "outputFormat": ["markdown"],
          "excludeTags":  ["script", "style", "nav", "footer"],
          "limit":        2000,
      })
      ```
      
      ### `contacts` — URLs → contact info
      
      ```python
      submit("contacts", {"urls": ["https://example.com/about", "https://example.com/team"]})
      ```
      
      Verified row schema (one row per input URL):
      
      ```json
      {
        "url": "https://example.com/about",
        "emails":       ["..."],
        "phoneNumbers": ["..."],
        "linkedin":     ["..."],
        "xcom":         ["..."],          // X / Twitter — note key is "xcom"
        "facebook":     ["..."],
        "instagram":    ["..."],
        "dribbble":     ["..."],
        "clutch":       ["..."]
      }
      ```
      
      Empty arrays for missing categories — never null. If you only have a domain, discover URLs first via SERP `site:example.com`.
      
      ### `sec-edgar` — bulk SEC filings
      
      ```python
      submit("sec-edgar", {
          "limit":       100,
          "ciks":        ["AAPL", "789019", "Alphabet Inc."],
          "filingTypes": "10-K, 10-Q, 8-K",
          "startDate":   "2024-01-01",
          "endDate":     "2025-12-31",
      })
      ```
      
      `ciks` accepts CIKs, tickers, or company names mixed.
      
      ### Bulk-API equivalents
      
      `google-serp`, `google-maps`, `amazon-search`, `indeed`, `glassdoor`, etc. Jobs accept arrays of inputs (`keywords[]`, `locations[]`, etc.). Use them when you want webhook fan-out; otherwise the synchronous Scraper API + paginated client loop is simpler.
      
      ### Crawler vs Contacts vs Web Scraping batch
      
      - **crawler** — unknown URL set, recursive discovery.
      - **contacts** — known URL list, want extracted contact fields.
      - **`/scrape/batch/web`** — known URL list, want full HTML/markdown/AI extraction at >1k scale.
      
      ## Gotchas
      
      - **Persist the job `id` immediately** (the integer from the submit response — *not* `jobId`). Only handle to status, results, stop.
      - **Result file retention is short.** Download right after `finished`.
      - **Webhooks are best-effort.** Always poll as a backup.
      - **`includePaths` regex is case-sensitive.**
      - **Status `stopped` is terminal.** Rows already produced remain available.
      - **Don't poll faster than every 10 s** — wastes concurrency cap.
      - **Double-wrapped results** — `body["data"][i]["data"]`, not `body["data"][i]`.
      
    • search.md 5.2 KB
      # Search & SERP APIs
      
      Pre-parsed JSON for Google, AI Mode, Bing, and the specialized Google panels. Synchronous `GET` under `https://api.hasdata.com`.
      
      | Endpoint | Returns |
      |---|---|
      | `/scrape/google/serp` | Full SERP — organic + every rich-snippet block |
      | `/scrape/google-light/serp` | Organic only |
      | `/scrape/google/ai-mode` | Gemini answer + references |
      | `/scrape/google/ai-overview` | AI Overview block |
      | `/scrape/google/news` | News articles |
      | `/scrape/google/shopping` | Shopping carousel |
      | `/scrape/google/images` | Image search |
      | `/scrape/google/events` | Local events |
      | `/scrape/google/short-videos` | Short-video panel |
      | `/scrape/google/immersive-product` | Expanded product pop-up |
      | `/scrape/google-trends/search` | Trends + related queries |
      | `/scrape/bing/serp` | Bing SERP |
      
      For `/scrape/google/flights`, see `travel.md`.
      
      ## Google SERP
      
      ```python
      import requests
      
      resp = requests.get(
          "https://api.hasdata.com/scrape/google/serp",
          headers={"x-api-key": API_KEY},
          params={"q": "coffee beans", "gl": "us", "hl": "en", "num": 100},
          timeout=300,
      )
      for hit in resp.json().get("organicResults", []):
          print(hit["position"], hit["title"], hit["link"])
      ```
      
      ### Query parameters
      
      | Param | Default | Notes |
      |---|---|---|
      | `q` | — | **Required.** |
      | `location` | — | Canonical, e.g. `"Austin,Texas,United States"`. Hyper-local. |
      | `uule` | — | Pre-encoded location (mutually exclusive with `location`). |
      | `domain` | `google.com` | `google.co.uk`, `google.de`, … |
      | `gl` | — | 2-letter country (`us`, `de`, `jp`). |
      | `hl` | — | 2-letter UI language. |
      | `lr` | — | Content-language filter (`lang_en`). |
      | `tbs` | — | Filters — `qdr:d|w|m|y` for time, `li:1` verbatim, sort, image type. |
      | `safe` | — | `active` / `off`. |
      | `start` | `0` | Pagination offset. |
      | `num` | `10` | Results/page. **Max 100** |
      | `tbm` | — | `isch` images, `vid`, `nws`, `shop`, `lcl`. |
      | `deviceType` | — | `desktop`, `mobile`, `tablet`. |
      
      ### Response keys
      
      ```
      requestMetadata, searchInformation, organicResults, knowledgeGraph, answerBox,
      aiOverview, topStories, newsResults, localResults, inlineShoppingResults,
      inlineVideos, inlineImages, recipesResults, perspectives, discussionsAndForums,
      relatedQuestions, relatedSearches, adResults, pagination
      ```
      
      Rich-snippet keys appear **only when the SERP shows that block** — always `data.get(key, default)`.
      
      ### Tips
      
      - `gl`/`hl` change ranking, not just localization. Run the same `q` with different `gl` to study geo-bias.
      - `location="Austin,Texas,United States"` produces hyperlocal results that differ from `gl=us` alone.
      
      ## Google Light SERP
      
      Same params as full SERP, but the response is trimmed to a few keys — typically `requestMetadata`, `searchInformation`, `organicResults`, `relatedSearches`, and `pagination` when present. Use for crawler seeding and link discovery when you don't need the heavier rich-snippet blocks.
      
      ## Google AI Mode
      
      ```python
      resp = requests.get(
          "https://api.hasdata.com/scrape/google/ai-mode",
          headers={"x-api-key": API_KEY},
          params={"q": "is coffee good for health?", "location": "Austin,Texas,United States"},
          timeout=300,
      )
      ```
      
      Params: `q` (required), `location`, `uule`, `gl`. Response:
      
      ```json
      {
        "requestMetadata": {...},
        "textBlocks": [
          {"type":"heading","snippet":"..."},
          {"type":"paragraph","snippet":"...","snippetHighlightedWords":["..."]},
          {"type":"list","list":[{"snippet":"..."}]},
          {"type":"table","table":{...}},
          {"type":"code","code":"..."}
        ],
        "references": [{"index":1,"link":"...","title":"...","snippet":"...","source":"..."}]
      }
      ```
      
      Block types observed in practice: `heading`, `paragraph`, `list`, `table`, `code`. Always switch on `type` rather than assuming a fixed set.
      
      Pattern: AI Mode for the answer → `/scrape/web` (markdown) on each `references[].link` → cited RAG context.
      
      ## Google News / Shopping / Bing
      
      Same shape: `q` + `gl`/`hl`/`location`. News supports `tbs=qdr:d|w|m|y` for time windows. Bing returns the same key set as Google SERP — useful for cross-engine consensus (disagreement = contested topic).
      
      ## Patterns
      
      ### Pagination
      
      ```python
      def all_organic(q, target=300):
          out, start = [], 0
          while len(out) < target:
              page = requests.get(
                  "https://api.hasdata.com/scrape/google-light/serp",
                  headers={"x-api-key": API_KEY},
                  params={"q": q, "num": 100, "start": start},
                  timeout=300,
              ).json().get("organicResults", [])
              if not page:
                  break
              out.extend(page)
              start += 100
          return out[:target]
      ```
      
      ### Reverse lookup (email / phone / domain → identity)
      
      ```python
      requests.get(
          "https://api.hasdata.com/scrape/google/serp",
          headers={"x-api-key": API_KEY},
          params={"q": f'"{literal}"', "num": 20},
          timeout=300,
      ).json().get("organicResults", [])
      ```
      
      Quoted literals (emails, phones, error strings) usually surface the canonical mention.
      
      ### Indexation check
      
      ```python
      def is_indexed(url):
          r = requests.get(
              "https://api.hasdata.com/scrape/google-light/serp",
              headers={"x-api-key": API_KEY},
              params={"q": f"site:{url}", "num": 1}, timeout=300,
          )
          return bool(r.json().get("organicResults"))
      ```
      
    • travel.md 8.2 KB
      # Travel APIs — Airbnb, Booking, Google Flights
      
      | Endpoint | Returns |
      |---|---|
      | `/scrape/airbnb/listing` | Airbnb search results |
      | `/scrape/airbnb/property` | Single Airbnb listing |
      | `/scrape/booking/search` | Booking.com search results (hotels, apartments) |
      | `/scrape/booking/place` | Single Booking.com property with room/rate list |
      | `/scrape/google/flights` | Google Flights prices and itineraries |
      
      All synchronous `GET`. Airbnb is 5 credits; Booking is 10; Google Flights is 15.
      
      For activities at the destination see `/scrape/google/events` (in `search.md`); for ground transport, scrape the operator's site with `POST /scrape/web`.
      
      ## Airbnb
      
      ```python
      import requests
      
      def airbnb_search(location, check_in, check_out, **kwargs):
          return requests.get(
              "https://api.hasdata.com/scrape/airbnb/listing",
              headers={"x-api-key": API_KEY},
              params={"location": location, "checkIn": check_in, "checkOut": check_out, **kwargs},
              timeout=300,
          ).json()
      ```
      
      | Param | Notes |
      |---|---|
      | `location` | **Required.** Free-form. |
      | `checkIn` | **Required.** `YYYY-MM-DD`. |
      | `checkOut`, `adults`, `children`, `infants`, `pets` | Optional. |
      | `nextPageToken` | Pagination cursor. |
      
      ### Token pagination
      
      ```python
      def airbnb_all(location, check_in, check_out):
          out, token = [], None
          while True:
              page = airbnb_search(location, check_in, check_out,
                                   **({"nextPageToken": token} if token else {}))
              out.extend(page.get("listings", []))
              token = page.get("nextPageToken")
              if not token:
                  return out
      ```
      
      ### Airbnb Property
      
      ```python
      requests.get(
          "https://api.hasdata.com/scrape/airbnb/property",
          headers={"x-api-key": API_KEY},
          params={"url": "https://www.airbnb.com/rooms/12345678"},
          timeout=300,
      )
      ```
      
      ## Booking Search
      
      ```python
      import json, requests
      
      def booking_search(keyword, check_in, check_out, *, adults=2, children=0,
                         children_ages=None, rooms=1, **filters):
          params = {
              "keyword":      keyword,
              "checkInDate":  check_in,
              "checkOutDate": check_out,
              "adults":       adults,
              "children":     children,
              "rooms":        rooms,
              **filters,
          }
          if children and children_ages:
              params["childrenAgesJson"] = json.dumps(children_ages)
          return requests.get(
              "https://api.hasdata.com/scrape/booking/search",
              headers={"x-api-key": API_KEY},
              params=params, timeout=300,
          ).json()
      ```
      
      | Param | Notes |
      |---|---|
      | `keyword` | **Required.** City, neighborhood, or property name. |
      | `checkInDate` / `checkOutDate` | **Required.** `YYYY-MM-DD`. |
      | `adults`, `children`, `rooms` | **Required.** Pass `children=0` explicitly when none. |
      | `childrenAgesJson` | Required iff `children > 0` — JSON array of ages (0–17), one per child. |
      | `price[min]` / `price[max]` | `>= 10` / `>= 20`. Bracketed — `requests`/`axios` serialize nested dicts as `price[min]=…`. |
      | `rating[]`, `reviewScore[]`, `propertyType[]`, `facilities[]`, `meals[]`, `bedPreference[]`, `roomFacilities[]`, `propertyAccessibility[]`, `roomAccessibility[]`, `distanceFromCenter[]`, `travelGroup[]`, `onlinePayment[]`, `reservationPolicy[]` | Multi-value filters (OR). |
      | `bedrooms`, `bathrooms` | Minimum count. |
      | `sort` | `ourTopPicks`, `homesAndApartmentsFirst`, `priceLowestFirst`, `priceHighestFirst`, `bestReviewedAndLowestPrice`, `ratingHighToLow`, `ratingLowToHigh`, `ratingAndPrice`, `distanceFromDowntown`, `topReviewed`. |
      | `page` | 1-indexed, 25 results per page. |
      | `currency` | ISO code or `hotelCurrency` to keep native. |
      | `language` | UI locale. |
      
      Top-level response (verified live): `requestMetadata`, `searchInformation`, `pagination`, `results`. Per-result keys: `hotelId`, `roomId`, `title`, `url`, `location`, `rating`, `reviews`, `price` (object with `total` / `nightly` / `currency`), `room`, `beds`, `bedTypes`, `policies`, `photo`.
      
      ## Booking Place
      
      ```python
      resp = requests.get(
          "https://api.hasdata.com/scrape/booking/place",
          headers={"x-api-key": API_KEY},
          params={
              "url":           "https://www.booking.com/hotel/fr/le-bristol-paris.html",
              "checkInDate":   "2026-07-10",
              "checkOutDate":  "2026-07-13",
              "adults":         2,
              "children":       0,
              "rooms":          1,
          },
          timeout=300,
      ).json()
      ```
      
      `url` must be `booking.com` / `www.booking.com`. The remaining stay/guest parameters share the same rules as `booking-search` (including `childrenAgesJson` when `children > 0`).
      
      Response top-level keys: `requestMetadata`, `overview`, `bookingDetails`, `rooms`, `facilities`, `houseRules`, `ratings`, `reviews`, `restaurants`, `breadcrumbs`, `questionsAndAnswers`.
      
      - `overview` → `id`, `title`, `address`, `description`, `propertyType`, `photos`, `highlights`, `mostPopularFacilities`.
      - `rooms[i]` → `roomId`, `name`, `bedTypes`, `beds`, `facilities`, `otherFacilities`, `variants[]` (per-rate price/availability). Variants are the actual buyable units; `rooms[i]` is the floor-plan.
      
      ## Google Flights
      
      ```python
      resp = requests.get(
          "https://api.hasdata.com/scrape/google/flights",
          headers={"x-api-key": API_KEY},
          params={
              "departureId":  "JFK",
              "arrivalId":    "LAX",
              "outboundDate": "2026-06-15",
              "returnDate":   "2026-06-22",     # omit for one-way
              "currency":     "USD",
          },
          timeout=300,
      ).json()
      ```
      
      | Param | Notes |
      |---|---|
      | `departureId` / `arrivalId` | **Required.** IATA airport codes (`JFK`, `LAX`). |
      | `outboundDate` | **Required.** `YYYY-MM-DD`. |
      | `returnDate` | Optional — omit for one-way. |
      | `currency` | ISO code. |
      | `gl`, `hl` | Country / language. |
      | `travelClass` | `1` economy, `2` premium economy, `3` business, `4` first. |
      | `stops` | `0` any, `1` non-stop, `2` ≤1 stop, `3` ≤2 stops. |
      | `adults`, `children`, `infantsInSeat`, `infantsOnLap` | Passenger counts. |
      
      ## Patterns
      
      ### STR yield estimate
      
      ```python
      rentals = airbnb_search(area, ci, co).get("listings", [])           # Airbnb → "listings"
      # pair with /scrape/zillow/listing (see real-estate.md) for purchase price
      night   = sum(r.get("price", 0) for r in rentals) / max(len(rentals), 1)
      ```
      
      ### Hotel-vs-rental price diff
      
      ```python
      b = booking_search(city, ci, co, adults=2, children=0, rooms=1, sort="priceLowestFirst")
      a = airbnb_search(city, ci, co, adults=2)
      def median(xs): xs = sorted(xs); return xs[len(xs)//2] if xs else None
      median_hotel = median([r["price"]["nightly"] for r in b.get("results", []) if r.get("price")])
      median_str   = median([r["price"]            for r in a.get("listings", []) if r.get("price")])
      ```
      
      ### Full trip cost
      
      ```python
      flight = requests.get(
          "https://api.hasdata.com/scrape/google/flights",
          headers={"x-api-key": API_KEY},
          params={"departureId": origin, "arrivalId": dest_iata,
                  "outboundDate": dep, "returnDate": ret, "currency": "USD"},
          timeout=300,
      ).json()
      cheapest_flight = min((f["price"] for f in flight.get("best_flights", [])), default=None)
      
      stay = booking_search(city, dep, ret, adults=2, children=0, rooms=1, sort="priceLowestFirst")
      cheapest_stay = stay.get("results", [{}])[0].get("price", {}).get("total")
      
      total = (cheapest_flight or 0) + (cheapest_stay or 0)
      ```
      
      ## Gotchas
      
      - **Airbnb requires `checkIn`** and uses **token** pagination — store `nextPageToken`, not page numbers.
      - **Airbnb property endpoints take URLs**, not IDs.
      - **Booking requires `children` even when zero.** Pass `children=0`. When `children > 0`, also pass `childrenAgesJson` with exactly that many ages.
      - **Booking `price[min]` / `price[max]`** are bracketed — use a nested dict with `requests`/`axios`.
      - **Booking `rooms[i].variants[]` is where prices live** — the parent `rooms[i]` describes the floor-plan, variants are the buyable rates with `priceBreakdown` / `cancellationPolicy` / `mealPlan`.
      - **`bookingDetails` carries the resolved stay context** the response was priced for — echo it back when persisting results so future comparisons use the same dates / occupancy.
      - **Google Flights uses IATA codes**, not city names. `JFK` not `New York`.
      - **Round-trip vs one-way** is determined by `returnDate` presence — pass it for round-trip, omit for one-way.
      
    • web-scraping.md 6.6 KB
      # Web Scraping API — `POST /scrape/web`
      
      One endpoint to fetch any URL, optionally with JS rendering, proxies, AI extraction, and screenshots. Synchronous.
      
      > Reach for this only when the user gave you a specific URL, or when no Scraper API covers the field. Otherwise the platform-specific APIs return pre-extracted JSON without direct page access. Use only for public pages or content the user is authorized to access.
      
      ## Minimal request
      
      ```python
      import requests
      
      # Multiple outputs (or include "json") → response is a JSON object
      resp = requests.post(
          "https://api.hasdata.com/scrape/web",
          headers={"x-api-key": API_KEY},
          json={"url": "https://example.com", "outputFormat": ["markdown", "json"]},
          timeout=300,
      )
      data = resp.json()
      assert data["requestMetadata"]["status"] == "ok"
      print(data["markdown"])
      
      # Single non-JSON output → response IS the raw content (markdown/html/text bytes)
      resp = requests.post(
          "https://api.hasdata.com/scrape/web",
          headers={"x-api-key": API_KEY},
          json={"url": "https://example.com", "outputFormat": ["markdown"]},
          timeout=300,
      )
      print(resp.text)            # raw markdown — no JSON parsing
      ```
      
      ## Body parameters
      
      | Parameter | Type | Notes |
      |---|---|---|
      | `url` | string | **Required.** Absolute URL. |
      | `outputFormat` | string[] | `html`, `text`, `markdown`, `json`. **Single non-JSON format → raw content as the body** (not JSON-wrapped); multiple formats → JSON object with one key per format. Always include `"json"` (or another format) when you also need `requestMetadata`. |
      | `proxyType` | enum | `datacenter` (default) or `residential` — use residential only for authorized geo/availability testing where terms and access controls permit it. |
      | `proxyCountry` | string | ISO 3166-1 alpha-2 — `US`, `UK`, `DE`, `FR`, `IT`, `SE`, `BR`, `CA`, `JP`, `SG`, `IN`, `ID`, `IE`. |
      | `jsRendering` | bool | Headless browser — required for SPAs and dynamically-injected content. |
      | `wait` / `waitFor` | int (ms) / CSS string | Fixed delay vs. wait-until-selector. Prefer `waitFor`. |
      | `jsScenario` | array | Sequence of click/fill/wait/scroll/evaluate. Requires `jsRendering`. |
      | `headers` | object | Custom headers. **Cookies go here too — no separate `cookies` parameter.** |
      | `screenshot` | bool | Returns a CDN URL in the response. |
      | `extractRules` | object | CSS selectors → field text. `@attr` for attributes. **First match only**, missing → `null`. |
      | `aiExtractRules` | object | Typed LLM extraction. Types: `string`, `number`, `boolean`, `list`, `item`. |
      | `extractEmails` / `extractLinks` | bool | Quick helpers. |
      | `blockResources` / `blockAds` | bool | Skip images/CSS/ads — speeds text-only scrapes. |
      | `blockUrls` | string[] | Glob patterns to block subresources. |
      | `removeBase64Images` | bool | Strip inline base64 from response. |
      | `includeOnlyTags` / `excludeTags` | string[] | Trim DOM before serialization. |
      
      ## CSS extraction (`extractRules`)
      
      ```python
      "extractRules": {
          "title": "h1",
          "links": "a @href",   # @attr extracts attribute
          "price": ".price-now",
      }
      ```
      
      First match per selector. For lists of records, use `aiExtractRules` with `type: "list"`.
      
      ## AI extraction (`aiExtractRules`)
      
      ```python
      "aiExtractRules": {
          "title":    {"type": "string"},
          "price":    {"type": "number"},
          "in_stock": {"type": "boolean"},
          "tags":     {"type": "list", "description": "category tags"},
          "author":   {"type": "item", "output": {
              "name":     {"type": "string"},
              "verified": {"type": "boolean"},
          }},
          "reviews":  {"type": "list", "output": {
              "rating": {"type": "number"},
              "text":   {"type": "string"},
          }},
      }
      ```
      
      Use when layout varies across pages; otherwise prefer `extractRules` for determinism and predictability.
      
      ## JS scenarios
      
      ```python
      "jsScenario": [
          {"fill": ["#email", "user@example.com"]},
          {"fill": ["#password", PASSWORD]},
          {"click": "#login"},
          {"waitFor": ".dashboard"},
          {"scrollY": 2000},
          {"waitForAndClick": ".load-more"},
          {"evaluate": "window.__APP_STATE__"},
      ]
      ```
      
      Actions: `click`, `fill: [sel, val]`, `wait: ms`, `waitFor: sel`, `waitForAndClick: sel`, `scrollX/scrollY: px`, `evaluate: "JS"`. Sequential. Missing element on `click`/`fill` fails the request — wrap with `waitFor` first.
      
      ## Auth via cookies
      
      ```python
      "headers": {
          "User-Agent": "Mozilla/5.0 ...",
          "Cookie": "session=abc; csrf=xyz",
          "Accept-Language": "en-US,en;q=0.9",
      }
      ```
      
      Capture cookies once in a real browser (devtools → Storage → Cookies), forward via the `Cookie` header. Only with explicit user permission and authority to access that account/content; never use cookies to bypass someone else's access controls.
      
      ## Slim response & speed
      
      ```python
      {
          "blockResources": True,                       # skip images/CSS/fonts
          "blockAds": True,                             # skip ad/tracking
          "blockUrls": ["**.googletagmanager.com/**", "**.doubleclick.net/**"],
          "removeBase64Images": True,
          "excludeTags": ["script", "style", "nav", "footer"],
      }
      ```
      
      Reduces response size 60–90% on noisy pages.
      
      ## Response shape
      
      The wrapper is JSON **only when** the response is JSON-wrapped — i.e. multiple `outputFormat` values, or a single value that includes `"json"`. With a single non-JSON format the response body is the raw content (`text/markdown`, `text/html`, `text/plain`).
      
      ```json
      {
        "requestMetadata": { "id": "uuid", "status": "ok", "url": "..." },
        "headers": { "content-type": "text/html" },
        "screenshot": "https://...jpeg",
        "content": "<!DOCTYPE html>...",     // outputFormat: html
        "markdown": "# Title\n...",           // outputFormat: markdown
        "text":     "Title\n...",
        "extractRules":    { ... },           // present iff sent
        "aiExtractRules":  { ... },           // present iff sent
        "extractedEmails": [ ... ],           // iff extractEmails: true
        "extractedLinks":  [ ... ]            // iff extractLinks: true
      }
      ```
      
      ## Batch (`POST /scrape/batch/web`)
      
      Async wrapper for >1k URLs running the same extraction. Returns `jobId`; poll status, page `/results`. Per-batch cap **10,000 URLs**. For small workloads loop the sync endpoint at concurrency = plan limit.
      
      ## Gotchas
      
      - **Disable `jsRendering` first**, enable only when the page needs it — most static pages parse fine without a headless browser.
      - **`waitFor` > `wait`.** Selector-based waits adapt to network speed.
      - **Cookies via `headers["Cookie"]` only.**
      - **`extractRules` returns first match** — for arrays use `aiExtractRules` `type: "list"`.
      - **Set client timeout ≥ 300 s** to match the server deadline.
      - **`requestMetadata.status === "ok"` is the only success signal.**
      
    • youtube.md 7 KB
      # YouTube APIs
      
      | Endpoint | Returns |
      |---|---|
      | `/scrape/youtube/search` | Search results — videos, shorts, channels, playlists |
      | `/scrape/youtube/video` | Single video metadata (stats, captions, related) |
      | `/scrape/youtube/channel` | Channel home / videos / shorts / playlists / community |
      | `/scrape/youtube/transcript` | Full transcript with millisecond offsets |
      
      All synchronous `GET`. 10 credits each.
      
      ## YouTube Search
      
      ```python
      import requests
      
      resp = requests.get(
          "https://api.hasdata.com/scrape/youtube/search",
          headers={"x-api-key": API_KEY},
          params={"q": "anthropic claude", "sortBy": "views", "date": "month"},
          timeout=300,
      )
      for v in resp.json().get("videoResults", []):
          print(v["title"], v.get("extractedViews"), v["link"])
      ```
      
      ### Query parameters
      
      | Param | Notes |
      |---|---|
      | `q` | **Required.** Free-text query. |
      | `sortBy` | `relevance` (default), `date`, `views`, `rating`, `popularity`. |
      | `date` | Upload window: `hour`, `today`, `week`, `month`, `year`. |
      | `length` | Duration bucket: `under4`, `between420`, `plus20`. |
      | `videoType` | `video`, `shorts`, `channel`, `playlist`, `movie`. |
      | `filters[]` | Feature flags ANDed: `hd`, `k4`, `hdr`, `subtitles`, `cc`, `d3`, `d360`, `vr180`, `live`, `bought`, `location`. |
      | `gl` / `hl` | Two-letter country / language codes. |
      | `deviceType` | `desktop`, `mobile`. |
      | `paginationToken` | Opaque cursor from the previous `pagination.nextPageToken`. |
      | `sp` | Raw YouTube `sp=` token (overrides `sortBy`, `date`, `videoType`, `length`, `filters[]`). |
      
      Response: `videoResults`, `shortsResults`, `channelResults`, `playlistResults`, `adsResults`, `sponsoredResults`, `searchInformation`, `pagination`.
      
      Per-video result keys (verified live): `videoId`, `title`, `link`, `channel`, `description`, `length`, `views`, `viewsOriginal`, `publishedDate`, `thumbnail`, `positionOnPage`. `channel` is an object — read `.channel.name` and `.channel.link`.
      
      ## YouTube Video
      
      ```python
      resp = requests.get(
          "https://api.hasdata.com/scrape/youtube/video",
          headers={"x-api-key": API_KEY},
          params={"v": "dQw4w9WgXcQ"},
          timeout=300,
      )
      ```
      
      | Param | Notes |
      |---|---|
      | `v` | **Required.** 11-character YouTube video ID — the `v=` query value. |
      | `gl` / `hl` | Country / language. |
      | `deviceType` | `desktop` / `mobile`. |
      
      Top-level keys: `videoId`, `title`, `description`, `channel`, `views`, `extractedViews`, `likes`, `extractedLikes`, `lengthSeconds`, `publishedDate`, `keywords`, `captions`, `socialLinks`, `music`, `category`, `thumbnail`, `isFamilySafe`, `isUnlisted`, `relatedVideos`, `relatedShorts`, `endScreenVideos`, `requestMetadata`.
      
      Use `extractedViews` / `extractedLikes` (integers) for math; `views` / `likes` are the formatted strings.
      
      ## YouTube Channel
      
      ```python
      resp = requests.get(
          "https://api.hasdata.com/scrape/youtube/channel",
          headers={"x-api-key": API_KEY},
          params={"channelId": "@MrBeast", "tab": "videos"},
          timeout=300,
      )
      ```
      
      | Param | Notes |
      |---|---|
      | `channelId` | **Required.** `@handle`, canonical `UC…` ID, or legacy `/c/<custom>` / `/user/<name>` slug. |
      | `tab` | `featured` (default), `videos`, `shorts`, `streams`, `playlists`, `posts` / `community`, `podcasts`, `releases`, `about`, `store`. |
      | `paginationToken` | Cursor for tabs that paginate. |
      | `gl` / `hl` / `deviceType` | Standard. |
      
      Response: `channelInfo`, `featuredVideo`, `sections[]`.
      
      `channelInfo` (verified live): `name`, `handle`, `channelId`, `channelUrl`, `avatar`, `banner`, `description`, `subscribers`, `extractedSubscribers`, `videosCount`, `extractedVideosCount`, `keywords[]`, `availableTabs[]`, `verified`, `websiteUrl`, `rssUrl`, `isFamilySafe`.
      
      `sections[]` each have `title` + `items[]` — shape depends on the tab. Iterate generically:
      
      ```python
      for sec in resp.json().get("sections", []):
          for item in sec.get("items", []):
              ...
      ```
      
      ## YouTube Transcript
      
      ```python
      resp = requests.get(
          "https://api.hasdata.com/scrape/youtube/transcript",
          headers={"x-api-key": API_KEY},
          params={"v": "dQw4w9WgXcQ", "languageCode": "en"},
          timeout=300,
      )
      text = " ".join(seg["snippet"] for seg in resp.json().get("transcript", []))
      ```
      
      | Param | Notes |
      |---|---|
      | `v` | **Required.** 11-character video ID. |
      | `languageCode` | BCP-47 / YouTube code (`en`, `de`, `en-US`, `pt-BR`). Must exist on the video. |
      | `type` | `asr` to fetch the auto-generated speech-recognition track when no human captions exist. |
      
      Response: `transcript[]` and `availableTranscripts[]`.
      
      Each `transcript[]` entry: `startMs`, `endMs`, `snippet`, `startTimeText` (e.g. `"0:18"`).
      
      ## Patterns
      
      ### Search → video → transcript fan-out
      
      ```python
      def topic_corpus(query, k=5):
          search = requests.get(
              "https://api.hasdata.com/scrape/youtube/search",
              headers={"x-api-key": API_KEY},
              params={"q": query, "sortBy": "views"}, timeout=300,
          ).json()
          docs = []
          for v in search.get("videoResults", [])[:k]:
              tr = requests.get(
                  "https://api.hasdata.com/scrape/youtube/transcript",
                  headers={"x-api-key": API_KEY},
                  params={"v": v["videoId"]}, timeout=300,
              ).json()
              docs.append({
                  "videoId": v["videoId"],
                  "title":   v["title"],
                  "url":     v["link"],
                  "text":    " ".join(s["snippet"] for s in tr.get("transcript", [])),
              })
          return docs
      ```
      
      ### Channel velocity
      
      ```python
      def channel_velocity(handle):
          page = requests.get(
              "https://api.hasdata.com/scrape/youtube/channel",
              headers={"x-api-key": API_KEY},
              params={"channelId": handle, "tab": "videos"}, timeout=300,
          ).json()
          return [
              {"date": it.get("publishedDate"),
               "views": it.get("extractedViews"),
               "title": it.get("title")}
              for sec in page.get("sections", [])
              for it in sec.get("items", [])
          ]
      ```
      
      ### Timestamp search inside a transcript
      
      ```python
      def mentions(video_id, needle):
          tr = requests.get(
              "https://api.hasdata.com/scrape/youtube/transcript",
              headers={"x-api-key": API_KEY},
              params={"v": video_id}, timeout=300,
          ).json()
          return [(s["startTimeText"], s["snippet"])
                  for s in tr.get("transcript", [])
                  if needle.lower() in s["snippet"].lower()]
      ```
      
      ## Gotchas
      
      - **`v` is the 11-char ID, not the URL.** Strip the `v=` value first.
      - **`languageCode` must exist on the video.** Inspect `availableTranscripts[]` if a fetch fails, then retry.
      - **`type=asr` is required when no human-authored caption track exists.** Otherwise the API errors on videos with auto-only captions.
      - **`@handle` resolves to the same channel as the canonical `UC…` ID.** Prefer handles for readability.
      - **Pagination tokens are opaque** — pass them back verbatim via `paginationToken`.
      - **`extractedViews` / `extractedLikes`** are integers; `views` / `likes` are formatted strings. Use the integer fields for arithmetic.
      - **`channelInfo.rssUrl`** is the canonical RSS feed for the channel — use it to subscribe in podcast clients without scraping.
      
  • SKILL.md 6.7 KB
    ---
    name: hasdata
    description: Use HasData APIs for web scraping and structured web data extraction.
    risk: safe
    source: official
    source_type: official
    source_repo: HasData/hasdata-cli
    license: MIT
    license_source: "https://github.com/HasData/hasdata-cli/blob/main/LICENSE"
    date_added: "2026-06-04"
    ---
    
    # HasData
    
    Cloud platform for extracting public web data. One API key, three execution modes. All endpoints sit under `https://api.hasdata.com` and authenticate with `x-api-key`.
    
    ```bash
    curl -G 'https://api.hasdata.com/scrape/google/serp' \
      --data-urlencode 'q=coffee' \
      -H 'x-api-key: <your-api-key>'
    ```
    
    `401` invalid key, `403` quota exhausted, `429` concurrency cap, `500` server error (retry).
    
    ## When to Use
    
    Use this skill when:
    
    - The user needs web scraping.
    - The user needs search engine results.
    - The user needs structured data extraction.
    - The user needs ecommerce, travel, jobs, or local business data.
    - The user explicitly asks about HasData.
    
    ## Three execution modes
    
    | Mode | Latency | When | Endpoint |
    |---|---|---|---|
    | **Web Scraping API** | seconds | Arbitrary URL — JS rendering, CSS/AI extraction, screenshots | `POST /scrape/web` |
    | **Scraper APIs** (sync) | seconds | Pre-parsed JSON for known platforms (Google, Amazon, Zillow, …) | `GET /scrape/<vertical>/<resource>` |
    | **Scraper Jobs** (async) | minutes–hours | Bulk extraction, recursive crawling, webhook fan-out | `POST /scrapers/<slug>/jobs` |
    
    **Decision rule.** Default to a **Scraper API** when one exists for the platform (pre-parsed JSON, no selector maintenance). Use **Web Scraping** for arbitrary URLs not covered by an API. Reach for a **Scraper Job** only when no API equivalent exists — `crawler`, `contacts`, `sec-edgar`, `amazon-bestsellers`, `amazon-product-reviews` — *or* when async fan-out + webhooks save engineering time over a paginated client loop.
    
    ## Always-true response shape
    
    ```json
    { "requestMetadata": { "id": "…", "status": "ok", "url": "…" }, "...": "endpoint-specific" }
    ```
    
    Treat data as valid only if `requestMetadata.status === "ok"`. HTTP 200 alone isn't enough.
    
    ## High-leverage patterns
    
    - **SERP-first enrichment.** Google SERP can surface public snippets for company and professional-profile lookup. Use it for business or authorized research, avoid unnecessary direct scraping, and treat personal email/phone lookup as allowed only with a legitimate purpose and user authorization.
    - **AI Mode + verify.** `/scrape/google/ai-mode` for the answer + references → `/scrape/web` (markdown) on each reference URL → cited RAG context, no vector DB.
    - **Maps → leads.** `/scrape/google-maps/search` returns business websites and phones; collect contact details only from public, permitted sources and apply opt-out, rate, and privacy-law constraints before any outreach use.
    - **Crawler → corpus.** `crawler` Scraper Job with `outputFormat: ["markdown"]` + `includePaths: "/docs/.+"` produces an LLM-ready corpus in one submission.
    - **Pre-extracted via SERP rich snippets.** `knowledgeGraph`, `localResults`, `inlineShoppingResults`, `relatedQuestions` carry pre-parsed public facts. Always check them before considering direct page access.
    
    ## When to call from code (the wiring)
    
    - **Auth:** `x-api-key` header on every request. Read from `HASDATA_API_KEY` env. Never hardcode, never log.
    - **Timeouts:** **set client timeout ≥ 300 s.** HasData's own deadline is 300 s; shorter clients produce phantom failures while still being billed on completion.
    - **Retries:** `429` and `5xx` only — exponential backoff, jitter. Never retry `4xx` (auth, validation).
    - **Concurrency:** cap at your plan limit. The free tier is 1; anything higher just generates `429`s.
    - **Async jobs:** the submit response handle is `body.id` (integer), **not `jobId`**. Persist it immediately. Poll `GET /scrapers/jobs/<id>` every 10–30 s with backoff; treat webhooks as best-effort and always pair with polling. On `finished` the status carries `data: {csv, json, xlsx}` short-lived URLs — download immediately.
    
    See `references/code-recipes.md` for ready-to-paste Python and TypeScript clients with retry, backoff, bounded concurrency, and the full job lifecycle.
    
    ## Common gotchas
    
    - **300 s server deadline.** Match client timeout.
    - **Disable `jsRendering` first**, enable only if the page needs it — most static pages parse fine without a headless browser.
    - **No `cookies` parameter** — cookies go through `headers["Cookie"]`.
    - **`includePaths` regex is case-sensitive.** `/blog/.+` won't match `/Blog/...`.
    - **Scraper Job `data` is double-wrapped.** Each row is `body.data[i].data`; outer wraps with `id`, `jobId`, `dataId`, `createdAt`, `updatedAt`.
    - **`requestMetadata.status === "ok"` is the only success signal.** HTTP 200 alone isn't enough.
    - **Webhooks are best-effort with 3 retries.** Always have a polling fallback.
    
    ## References
    
    - [`references/web-scraping.md`](references/web-scraping.md) — `POST /scrape/web` parameters, JS scenarios, AI extraction, cookie auth.
    - [`references/search.md`](references/search.md) — Google SERP / Light / AI Mode / News / Shopping / Bing / Trends + pagination.
    - [`references/ecommerce.md`](references/ecommerce.md) — Amazon (product, search, seller, seller-products) and Shopify.
    - [`references/real-estate.md`](references/real-estate.md) — Zillow, Redfin (bracketed filters).
    - [`references/travel.md`](references/travel.md) — Airbnb, Booking, Google Flights (occupancy rules, token pagination, IATA codes).
    - [`references/local-business.md`](references/local-business.md) — Maps (search/place/reviews/photos/posts), Yelp, YellowPages.
    - [`references/jobs.md`](references/jobs.md) — Indeed and Glassdoor.
    - [`references/youtube.md`](references/youtube.md) — YouTube search / video / channel / transcript.
    - [`references/scraper-jobs.md`](references/scraper-jobs.md) — async submit/poll/results, Crawler, Contacts, SEC EDGAR, webhook receiver.
    - [`references/code-recipes.md`](references/code-recipes.md) — Python / TypeScript clients with retry, backoff, concurrency, polling.
    
    ## Resources
    
    - Sitemap: <https://docs.hasdata.com/llms.txt>
    - API status codes: <https://docs.hasdata.com/api-codes>
    - Credits & concurrency: <https://docs.hasdata.com/credits-and-concurrency>
    - Dashboard: <https://app.hasdata.com>
    
    ## Limitations
    
    * Requires access to HasData services and valid credentials.
    * Data quality and available fields depend on the target website and extraction method used.
    * JavaScript-heavy websites may require rendering, which can affect performance and cost.
    * Use only for public data or content the user is authorized to access; respect site terms, robots/access controls, privacy law, and rate limits.
    * Rate limits, quotas, and account restrictions may apply depending on the endpoint and subscription plan.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related