Skip to content

Commit 4ef7c1a

Browse files
committed
Fix Python audit findings: configurable http_timeout, scrape id guard, urllib.parse.quote, non-iterable keywords, CI installs wheel + twine check
1 parent ac93667 commit 4ef7c1a

3 files changed

Lines changed: 44 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,9 @@ jobs:
1616
- uses: actions/setup-python@v5
1717
with:
1818
python-version: ${{ matrix.python }}
19-
- run: python -m unittest discover -s tests -v
19+
- run: pip install build twine
20+
- run: python -m build
21+
- run: twine check dist/*
22+
- run: pip install dist/*.whl
23+
# Run tests against the installed wheel, not the source tree
24+
- run: mv src src_moved && python -m unittest discover -s tests -v

src/gmapsscraper/__init__.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import time
1414
import urllib.error
1515
import urllib.request
16+
from urllib.parse import quote
1617
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union
1718

1819
__version__ = "0.1.0"
@@ -41,15 +42,17 @@ def __init__(self, message: str, status: Optional[int] = None, body: Any = None)
4142
self.body = body
4243

4344

44-
def _default_http(method: str, url: str, headers: Dict[str, str], body: Optional[str]) -> Tuple[int, str]:
45+
def _default_http(
46+
method: str, url: str, headers: Dict[str, str], body: Optional[str], timeout: float = 120.0
47+
) -> Tuple[int, str]:
4548
request = urllib.request.Request(
4649
url,
4750
data=body.encode("utf-8") if body is not None else None,
4851
headers=headers,
4952
method=method,
5053
)
5154
try:
52-
with urllib.request.urlopen(request, timeout=120) as response:
55+
with urllib.request.urlopen(request, timeout=timeout) as response:
5356
return response.getcode(), response.read().decode("utf-8")
5457
except urllib.error.HTTPError as exc:
5558
return exc.code, exc.read().decode("utf-8", "replace")
@@ -61,6 +64,8 @@ class GMapsScraper:
6164
Args:
6265
api_key: Your API key — get one at https://gmapsscraper.io/dashboard
6366
base_url: API base URL (default: https://gmapsscraper.io/api/v1).
67+
http_timeout: Per-request HTTP timeout in seconds for the default
68+
transport (default: 120). Independent of the job-polling ``timeout``.
6469
http: Optional transport override for testing:
6570
``(method, url, headers, body) -> (status_code, response_text)``.
6671
"""
@@ -70,13 +75,16 @@ def __init__(
7075
api_key: str,
7176
*,
7277
base_url: str = DEFAULT_BASE_URL,
78+
http_timeout: float = 120.0,
7379
http: Optional[HttpCallable] = None,
7480
):
7581
if not isinstance(api_key, str) or not api_key.strip():
7682
raise TypeError("api_key is required — get one at https://gmapsscraper.io/dashboard")
7783
self._api_key = api_key
7884
self.base_url = base_url.rstrip("/")
79-
self._http = http or _default_http
85+
self._http = http or (
86+
lambda method, url, headers, body: _default_http(method, url, headers, body, timeout=http_timeout)
87+
)
8088

8189
def __repr__(self) -> str: # never expose the API key in logs/repr
8290
return f"GMapsScraper(base_url={self.base_url!r})"
@@ -127,7 +135,13 @@ def create_job(self, keywords: Union[str, Iterable[str]], **options: Any) -> Dic
127135
Returns:
128136
``{"id": "job_xxx", "credits_remaining": 8}``
129137
"""
130-
kw = [keywords] if isinstance(keywords, str) else list(keywords)
138+
try:
139+
kw = [keywords] if isinstance(keywords, str) else list(keywords)
140+
except TypeError:
141+
raise TypeError(
142+
'keywords must be a non-empty string or an iterable of non-empty strings, '
143+
'e.g. "coffee shop in Austin TX"'
144+
) from None
131145
if not kw or any(not isinstance(k, str) or not k.strip() for k in kw):
132146
raise TypeError(
133147
'keywords must be a non-empty string or an iterable of non-empty strings, '
@@ -137,7 +151,7 @@ def create_job(self, keywords: Union[str, Iterable[str]], **options: Any) -> Dic
137151

138152
def get_job(self, job_id: str) -> Job:
139153
"""Get job status: ``{"id", "status": "running"|"complete"|"failed", "name"}``."""
140-
return self._request(f"/jobs/{urllib.request.quote(job_id, safe='')}")
154+
return self._request(f"/jobs/{quote(job_id, safe='')}")
141155

142156
def wait_for_job(
143157
self,
@@ -170,8 +184,11 @@ def download_csv(self, job_id: str) -> str:
170184
171185
Columns: title, address, phone, email, website, rating, reviews_count,
172186
category, latitude, longitude, google_maps_url, opening_hours.
187+
188+
Note: the full CSV is buffered in memory; typical result sets are a few
189+
hundred KB at most.
173190
"""
174-
return self._request(f"/jobs/{urllib.request.quote(job_id, safe='')}/download", raw=True)
191+
return self._request(f"/jobs/{quote(job_id, safe='')}/download", raw=True)
175192

176193
def download_records(self, job_id: str) -> List[BusinessRecord]:
177194
"""Download job results parsed into a list of dicts (one per business)."""
@@ -188,8 +205,11 @@ def scrape(
188205
) -> List[BusinessRecord]:
189206
"""One call: create a job, wait for completion, return parsed records."""
190207
job = self.create_job(keywords, **options)
191-
self.wait_for_job(job["id"], poll_interval=poll_interval, timeout=timeout, on_progress=on_progress)
192-
return self.download_records(job["id"])
208+
job_id = job.get("id") if isinstance(job, dict) else None
209+
if not job_id:
210+
raise GMapsScraperError("Malformed response from /scrape: missing job id", body=job)
211+
self.wait_for_job(job_id, poll_interval=poll_interval, timeout=timeout, on_progress=on_progress)
212+
return self.download_records(job_id)
193213

194214
def credits(self) -> Dict[str, Any]:
195215
"""Get remaining credit balance: ``{"credits": 8}``."""

tests/test_client.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,17 @@ def http(method, url, headers, body):
134134
return 200, csv_text
135135
return 200, json.dumps({"id": "job_9", "status": "complete"})
136136

137-
client = GMapsScraper("key_123", http=http)
137+
bodies = []
138+
139+
def recording_http(method, url, headers, body):
140+
if body is not None:
141+
bodies.append(json.loads(body))
142+
return http(method, url, headers, body)
143+
144+
client = GMapsScraper("key_123", http=recording_http)
138145
records = client.scrape(["pizza in NYC"], poll_interval=0.001, email=True)
146+
# Client-side polling options must never leak into the API request body
147+
self.assertEqual(bodies, [{"email": True, "keywords": ["pizza in NYC"]}])
139148
self.assertEqual(len(records), 2)
140149
self.assertEqual(records[0]["title"], 'Joe\'s "Best" Pizza, Inc')
141150
self.assertEqual(records[0]["email"], "joe@example.com")

0 commit comments

Comments
 (0)