litescrape-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1,21 @@
1
+ name: ci
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ test:
7
+ runs-on: ${{ matrix.os }}
8
+ strategy:
9
+ fail-fast: false
10
+ matrix:
11
+ os: [ubuntu-latest, windows-latest]
12
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v6
16
+ with:
17
+ python-version: ${{ matrix.python-version }}
18
+ - run: uv sync --frozen
19
+ - run: uv run ruff check .
20
+ - run: uv run ruff format --check .
21
+ - run: uv run pytest -q
@@ -0,0 +1,34 @@
1
+ name: publish
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: astral-sh/setup-uv@v6
13
+ - name: Check that the tag matches the package version
14
+ run: |
15
+ version=$(python -c "import re, pathlib; print(re.search(r'\"([^\"]+)\"', pathlib.Path('src/litescrape_sdk/_version.py').read_text()).group(1))")
16
+ test "v$version" = "$GITHUB_REF_NAME" || { echo "tag $GITHUB_REF_NAME does not match version $version"; exit 1; }
17
+ - run: uv build
18
+ - uses: actions/upload-artifact@v4
19
+ with:
20
+ name: dist
21
+ path: dist/
22
+
23
+ publish:
24
+ needs: build
25
+ runs-on: ubuntu-latest
26
+ environment: pypi
27
+ permissions:
28
+ id-token: write
29
+ steps:
30
+ - uses: actions/download-artifact@v4
31
+ with:
32
+ name: dist
33
+ path: dist/
34
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .env
@@ -0,0 +1 @@
1
+ 3.11
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Litescrape
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.5
2
+ Name: litescrape-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Litescrape API: validated, batched, retrying calls to search, maps, and reviews endpoints.
5
+ Project-URL: Homepage, https://litescrape.com
6
+ Project-URL: Source, https://github.com/litescrape/litescrape-sdk
7
+ Author-email: Litescraper <support@litescrape.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: api,google,litescrape,maps,reviews,scraping,serp
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: httpx<1,>=0.27
17
+ Requires-Dist: pydantic<3,>=2.7
18
+ Requires-Dist: tqdm<5,>=4.66
19
+ Description-Content-Type: text/markdown
20
+
21
+ # litescrape-sdk
22
+
23
+ Python SDK for the [Litescrape API](https://litescrape.com): validated, concurrent, retrying calls to the
24
+ Google, Bing, DuckDuckGo, Yelp, Tripadvisor, and Apple Maps endpoints, with results returned in input order.
25
+
26
+ ```
27
+ pip install litescrape-sdk
28
+ ```
29
+
30
+ ```python
31
+ import os
32
+ from litescrape_sdk import GoogleMaps, scrape
33
+
34
+ os.environ["LITESCRAPE_API_KEY"] = "ls_live_..."
35
+
36
+ results = scrape(
37
+ [
38
+ {"endpoint": "google_search", "q": "coffee grinders", "gl": "us"},
39
+ GoogleMaps(q="coffee", type="search", ll="@40.745,-73.988,14z"),
40
+ ]
41
+ )
42
+ for result in results:
43
+ print(result.ok, result.data or result.error)
44
+ ```
45
+
46
+ `help(litescrape_sdk.scrape)` documents every argument, the retry and concurrency rules, and the error
47
+ types. `ascrape` is the same function for asyncio code, and `litescrape_sdk.REQUEST_TYPES` maps each
48
+ `endpoint` slug to its request class.
49
+
50
+ Development: `uv sync`, then `uv run pytest`, `uv run ruff check .`, and `uv run ruff format .`.
@@ -0,0 +1,30 @@
1
+ # litescrape-sdk
2
+
3
+ Python SDK for the [Litescrape API](https://litescrape.com): validated, concurrent, retrying calls to the
4
+ Google, Bing, DuckDuckGo, Yelp, Tripadvisor, and Apple Maps endpoints, with results returned in input order.
5
+
6
+ ```
7
+ pip install litescrape-sdk
8
+ ```
9
+
10
+ ```python
11
+ import os
12
+ from litescrape_sdk import GoogleMaps, scrape
13
+
14
+ os.environ["LITESCRAPE_API_KEY"] = "ls_live_..."
15
+
16
+ results = scrape(
17
+ [
18
+ {"endpoint": "google_search", "q": "coffee grinders", "gl": "us"},
19
+ GoogleMaps(q="coffee", type="search", ll="@40.745,-73.988,14z"),
20
+ ]
21
+ )
22
+ for result in results:
23
+ print(result.ok, result.data or result.error)
24
+ ```
25
+
26
+ `help(litescrape_sdk.scrape)` documents every argument, the retry and concurrency rules, and the error
27
+ types. `ascrape` is the same function for asyncio code, and `litescrape_sdk.REQUEST_TYPES` maps each
28
+ `endpoint` slug to its request class.
29
+
30
+ Development: `uv sync`, then `uv run pytest`, `uv run ruff check .`, and `uv run ruff format .`.
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "litescrape-sdk"
7
+ dynamic = ["version"]
8
+ description = "Python SDK for the Litescrape API: validated, batched, retrying calls to search, maps, and reviews endpoints."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Litescraper", email = "support@litescrape.com" }]
14
+ keywords = ["litescrape", "serp", "google", "maps", "reviews", "scraping", "api"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ "httpx>=0.27,<1",
23
+ "pydantic>=2.7,<3",
24
+ "tqdm>=4.66,<5",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://litescrape.com"
29
+ Source = "https://github.com/litescrape/litescrape-sdk"
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "pytest>=8,<9",
34
+ "ruff>=0.12,<1",
35
+ ]
36
+
37
+ [tool.hatch.version]
38
+ path = "src/litescrape_sdk/_version.py"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/litescrape_sdk"]
42
+
43
+ [tool.ruff]
44
+ line-length = 110
45
+ target-version = "py310"
46
+
47
+ [tool.ruff.lint]
48
+ select = ["E", "F", "I", "UP", "B"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ addopts = "-q"
@@ -0,0 +1,91 @@
1
+ """Python SDK for the Litescrape API. Start with ``help(litescrape_sdk.scrape)``."""
2
+
3
+ from ._version import __version__
4
+ from .client import Result, akey_status, ascrape, key_status, scrape
5
+ from .errors import (
6
+ APIError,
7
+ AuthenticationError,
8
+ LitescrapeError,
9
+ NotFoundError,
10
+ PaymentRequiredError,
11
+ RateLimitError,
12
+ TransportError,
13
+ ValidationError,
14
+ )
15
+ from .models import (
16
+ REQUEST_TYPES,
17
+ AnyRequest,
18
+ AppleMapsPlaces,
19
+ AppleMapsReviews,
20
+ BingMaps,
21
+ BingSearch,
22
+ DuckDuckGoMaps,
23
+ DuckDuckGoSearch,
24
+ GoogleAds,
25
+ GoogleAiMode,
26
+ GoogleAiOverview,
27
+ GoogleContributorReviews,
28
+ GoogleLocal,
29
+ GoogleMaps,
30
+ GoogleMapsLiveFootTraffic,
31
+ GoogleMapsPhoto,
32
+ GoogleMapsPosts,
33
+ GoogleMapsWebResults,
34
+ GoogleReviews,
35
+ GoogleSearch,
36
+ GoogleShopping,
37
+ GoogleShoppingProduct,
38
+ KeyStatus,
39
+ ScrapeRequest,
40
+ TripadvisorPlace,
41
+ TripadvisorReviews,
42
+ TripadvisorSearch,
43
+ YelpReviews,
44
+ YelpSearch,
45
+ )
46
+
47
+ __all__ = [
48
+ "__version__",
49
+ "scrape",
50
+ "ascrape",
51
+ "key_status",
52
+ "akey_status",
53
+ "Result",
54
+ "KeyStatus",
55
+ "ScrapeRequest",
56
+ "AnyRequest",
57
+ "REQUEST_TYPES",
58
+ "LitescrapeError",
59
+ "ValidationError",
60
+ "TransportError",
61
+ "APIError",
62
+ "AuthenticationError",
63
+ "PaymentRequiredError",
64
+ "NotFoundError",
65
+ "RateLimitError",
66
+ "GoogleSearch",
67
+ "GoogleAiOverview",
68
+ "GoogleAiMode",
69
+ "GoogleAds",
70
+ "GoogleShopping",
71
+ "GoogleShoppingProduct",
72
+ "GoogleLocal",
73
+ "GoogleMaps",
74
+ "GoogleMapsLiveFootTraffic",
75
+ "GoogleMapsPosts",
76
+ "GoogleMapsPhoto",
77
+ "GoogleMapsWebResults",
78
+ "GoogleReviews",
79
+ "GoogleContributorReviews",
80
+ "BingSearch",
81
+ "BingMaps",
82
+ "DuckDuckGoSearch",
83
+ "DuckDuckGoMaps",
84
+ "YelpSearch",
85
+ "YelpReviews",
86
+ "TripadvisorSearch",
87
+ "TripadvisorPlace",
88
+ "TripadvisorReviews",
89
+ "AppleMapsPlaces",
90
+ "AppleMapsReviews",
91
+ ]
@@ -0,0 +1,201 @@
1
+ """Event loop, HTTP client, concurrency gate, and retry loop behind the public functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import concurrent.futures
7
+ import contextlib
8
+ import random
9
+ import sys
10
+ import threading
11
+ from collections.abc import Callable, Coroutine
12
+ from dataclasses import dataclass
13
+ from typing import Any, TypeVar
14
+
15
+ import httpx
16
+
17
+ from ._version import __version__
18
+ from .errors import APIError, LitescrapeError, TransportError, api_error_from_response
19
+
20
+ DEFAULT_BASE_URL = "https://api.litescrape.com"
21
+ DEFAULT_CONCURRENCY = 25
22
+ SELECTOR_LOOP_CAP = 500
23
+ BACKOFF_CAP = 30.0
24
+ CONNECT_TIMEOUT = 10.0
25
+
26
+ _RETRY_CODES = frozenset(
27
+ {"proxy_capacity_unavailable", "upstream_session_unavailable", "service_unavailable"}
28
+ )
29
+ _RETRY_TRANSPORT = (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError)
30
+
31
+ T = TypeVar("T")
32
+ _LoopEntry = tuple[asyncio.AbstractEventLoop, dict[Any, Any]]
33
+
34
+ _clients: dict[int, _LoopEntry] = {}
35
+ _semaphores: dict[int, _LoopEntry] = {}
36
+ _sleep = asyncio.sleep
37
+
38
+
39
+ @dataclass(slots=True)
40
+ class Outcome:
41
+ data: dict[str, Any] | None = None
42
+ error: LitescrapeError | None = None
43
+ status_code: int | None = None
44
+ request_id: str = ""
45
+ attempts: int = 0
46
+
47
+
48
+ def _per_loop(registry: dict[int, _LoopEntry]) -> dict[Any, Any]:
49
+ loop = asyncio.get_running_loop()
50
+ for key, (known, _) in list(registry.items()):
51
+ if known.is_closed():
52
+ del registry[key]
53
+ return registry.setdefault(id(loop), (loop, {}))[1]
54
+
55
+
56
+ def _make_client(base_url: str) -> httpx.AsyncClient:
57
+ return httpx.AsyncClient(
58
+ base_url=base_url,
59
+ headers={"Accept": "application/json", "User-Agent": f"litescrape-sdk/{__version__}"},
60
+ limits=httpx.Limits(max_connections=None, max_keepalive_connections=None),
61
+ timeout=httpx.Timeout(120.0, connect=CONNECT_TIMEOUT),
62
+ )
63
+
64
+
65
+ def client_for(base_url: str) -> httpx.AsyncClient:
66
+ clients = _per_loop(_clients)
67
+ client = clients.get(base_url)
68
+ if client is None:
69
+ client = clients[base_url] = _make_client(base_url)
70
+ return client
71
+
72
+
73
+ def semaphore_for(api_key: str, base_url: str, limit: int) -> asyncio.Semaphore:
74
+ semaphores = _per_loop(_semaphores)
75
+ entry = semaphores.get((api_key, base_url))
76
+ if entry is None or entry[0] != limit:
77
+ entry = semaphores[(api_key, base_url)] = (limit, asyncio.Semaphore(limit))
78
+ return entry[1]
79
+
80
+
81
+ def is_selector_limited(loop: asyncio.AbstractEventLoop) -> bool:
82
+ return sys.platform == "win32" and isinstance(loop, asyncio.SelectorEventLoop)
83
+
84
+
85
+ def effective_limit(status_limit: int | None, override: int | None, *, selector_limited: bool) -> int:
86
+ limit = status_limit or DEFAULT_CONCURRENCY
87
+ if override is not None:
88
+ limit = min(limit, override)
89
+ if selector_limited:
90
+ limit = min(limit, SELECTOR_LOOP_CAP)
91
+ return max(1, limit)
92
+
93
+
94
+ def _should_retry(error: LitescrapeError) -> bool:
95
+ if isinstance(error, TransportError):
96
+ return error.retryable
97
+ if isinstance(error, APIError):
98
+ status = error.status_code or 0
99
+ return error.retryable or status == 429 or status >= 500 or error.error_code in _RETRY_CODES
100
+ return False
101
+
102
+
103
+ def _delay(attempt: int, retry_after: float | None) -> float:
104
+ if retry_after is not None:
105
+ return min(max(retry_after, 0.0), BACKOFF_CAP)
106
+ ceiling = min(BACKOFF_CAP, float(2 ** (attempt - 1)))
107
+ return random.uniform(ceiling / 2, ceiling)
108
+
109
+
110
+ async def _attempt(
111
+ client: httpx.AsyncClient,
112
+ path: str,
113
+ params: dict[str, str],
114
+ headers: dict[str, str],
115
+ timeout: float,
116
+ outcome: Outcome,
117
+ ) -> LitescrapeError | None:
118
+ outcome.status_code, outcome.request_id = None, ""
119
+ try:
120
+ response = await client.get(
121
+ path,
122
+ params=params,
123
+ headers=headers,
124
+ timeout=httpx.Timeout(timeout, connect=min(CONNECT_TIMEOUT, timeout)),
125
+ )
126
+ except _RETRY_TRANSPORT as exc:
127
+ return TransportError(f"{type(exc).__name__}: {exc}", retryable=True, cause=exc)
128
+ except Exception as exc:
129
+ return TransportError(f"{type(exc).__name__}: {exc}", retryable=False, cause=exc)
130
+ outcome.status_code = response.status_code
131
+ outcome.request_id = response.headers.get("x-request-id", "")
132
+ if response.status_code != 200:
133
+ error = api_error_from_response(response)
134
+ outcome.request_id = error.request_id or outcome.request_id
135
+ return error
136
+ try:
137
+ data = response.json()
138
+ except ValueError as exc:
139
+ return TransportError("Response body was not JSON", retryable=True, cause=exc)
140
+ if not isinstance(data, dict):
141
+ return TransportError("Response body was not a JSON object", retryable=True)
142
+ outcome.data = data
143
+ return None
144
+
145
+
146
+ async def request_with_retries(
147
+ client: httpx.AsyncClient,
148
+ path: str,
149
+ params: dict[str, str],
150
+ *,
151
+ headers: dict[str, str],
152
+ attempts: int,
153
+ timeout: float,
154
+ semaphore: asyncio.Semaphore | None = None,
155
+ gate: Callable[[], LitescrapeError | None] | None = None,
156
+ ) -> Outcome:
157
+ outcome = Outcome()
158
+ slot = semaphore if semaphore is not None else contextlib.nullcontext()
159
+ for attempt in range(1, attempts + 1):
160
+ async with slot:
161
+ blocked = gate() if gate is not None else None
162
+ if blocked is not None:
163
+ outcome.error = blocked
164
+ outcome.status_code = getattr(blocked, "status_code", None)
165
+ return outcome
166
+ outcome.attempts = attempt
167
+ error = await _attempt(client, path, params, headers, timeout, outcome)
168
+ outcome.error = error
169
+ if error is None:
170
+ return outcome
171
+ if attempt == attempts or not _should_retry(error):
172
+ return outcome
173
+ await _sleep(_delay(attempt, getattr(error, "retry_after", None)))
174
+ return outcome
175
+
176
+
177
+ _loop_lock = threading.Lock()
178
+ _loop: asyncio.AbstractEventLoop | None = None
179
+
180
+
181
+ def background_loop() -> asyncio.AbstractEventLoop:
182
+ global _loop
183
+ with _loop_lock:
184
+ if _loop is None or _loop.is_closed():
185
+ loop = asyncio.ProactorEventLoop() if sys.platform == "win32" else asyncio.new_event_loop()
186
+ threading.Thread(target=loop.run_forever, name="litescrape-sdk", daemon=True).start()
187
+ _loop = loop
188
+ return _loop
189
+
190
+
191
+ def run_sync(coro: Coroutine[Any, Any, T]) -> T:
192
+ future = asyncio.run_coroutine_threadsafe(coro, background_loop())
193
+ try:
194
+ while True:
195
+ try:
196
+ return future.result(timeout=0.25)
197
+ except concurrent.futures.TimeoutError:
198
+ continue
199
+ except BaseException:
200
+ future.cancel()
201
+ raise
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"