1313import time
1414import urllib .error
1515import urllib .request
16+ from urllib .parse import quote
1617from 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}``."""
0 commit comments