google-maps-scraper 0.1.0__py3-none-any.whl

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,33 @@
1
+ """Google Maps Reviews Scraper - Scrape place details without API key.
2
+
3
+ Usage:
4
+ # Async API
5
+ async with GoogleMapsScraper() as scraper:
6
+ result = await scraper.scrape("https://www.google.com/maps/search/...")
7
+ print(result.place.name, result.place.rating)
8
+
9
+ # Sync convenience
10
+ from gmaps_scraper import scrape_place
11
+ result = scrape_place("https://www.google.com/maps/search/...")
12
+
13
+ # Batch processing
14
+ from gmaps_scraper import scrape_batch, ScrapeConfig
15
+ config = ScrapeConfig(concurrency=5)
16
+ results = asyncio.run(scrape_batch(urls, config))
17
+ """
18
+
19
+ from gmaps_scraper.batch import scrape_batch
20
+ from gmaps_scraper.models import PlaceDetails, Review, ScrapeConfig, ScrapeResult
21
+ from gmaps_scraper.scraper import GoogleMapsScraper, scrape_place
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ __all__ = [
26
+ "GoogleMapsScraper",
27
+ "scrape_place",
28
+ "scrape_batch",
29
+ "PlaceDetails",
30
+ "Review",
31
+ "ScrapeConfig",
32
+ "ScrapeResult",
33
+ ]
gmaps_scraper/batch.py ADDED
@@ -0,0 +1,263 @@
1
+ """Async batch processor for high-throughput Google Maps scraping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import csv
7
+ import json
8
+ import logging
9
+ import random
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Callable, Optional
13
+
14
+ from gmaps_scraper.models import ScrapeConfig, ScrapeResult
15
+ from gmaps_scraper.scraper import GoogleMapsScraper
16
+
17
+ logger = logging.getLogger("gmaps_scraper")
18
+
19
+
20
+ async def scrape_batch(
21
+ urls: list[str],
22
+ config: ScrapeConfig | None = None,
23
+ output_path: str | Path | None = None,
24
+ on_result: Optional[Callable[[ScrapeResult, int, int], None]] = None,
25
+ resume: bool = True,
26
+ ) -> list[ScrapeResult]:
27
+ """Scrape a batch of Google Maps URLs concurrently.
28
+
29
+ Args:
30
+ urls: List of Google Maps URLs to scrape.
31
+ config: Scraper configuration (concurrency, delays, etc.).
32
+ output_path: Path to save results incrementally (JSON or CSV).
33
+ on_result: Callback called after each result (result, index, total).
34
+ resume: If True, skip URLs already present in the output file.
35
+
36
+ Returns:
37
+ List of ScrapeResult objects.
38
+
39
+ Example:
40
+ >>> import asyncio
41
+ >>> from gmaps_scraper import scrape_batch, ScrapeConfig
42
+ >>> urls = ["https://www.google.com/maps/search/...", ...]
43
+ >>> config = ScrapeConfig(concurrency=5, headless=True)
44
+ >>> results = asyncio.run(scrape_batch(urls, config))
45
+ """
46
+ config = config or ScrapeConfig()
47
+ output_path = Path(output_path) if output_path else None
48
+
49
+ # Load already scraped URLs for resume
50
+ completed_urls: set[str] = set()
51
+ existing_results: list[ScrapeResult] = []
52
+ if resume and output_path and output_path.exists():
53
+ existing_results, completed_urls = _load_existing_results(output_path)
54
+ if completed_urls:
55
+ logger.info(
56
+ "Resuming: %d URLs already scraped, %d remaining",
57
+ len(completed_urls),
58
+ len(urls) - len(completed_urls),
59
+ )
60
+
61
+ # Filter out already completed URLs
62
+ remaining_urls = [u for u in urls if u.strip() not in completed_urls]
63
+ total = len(urls)
64
+ completed_count = len(completed_urls)
65
+
66
+ if not remaining_urls:
67
+ logger.info("All %d URLs already scraped!", total)
68
+ return existing_results
69
+
70
+ results: list[ScrapeResult] = list(existing_results)
71
+ semaphore = asyncio.Semaphore(config.concurrency)
72
+ save_lock = asyncio.Lock()
73
+
74
+ async with GoogleMapsScraper(config) as scraper:
75
+
76
+ async def _scrape_one(url: str) -> ScrapeResult:
77
+ nonlocal completed_count
78
+ async with semaphore:
79
+ # Random delay to avoid rate limiting
80
+ delay = random.uniform(config.delay_min, config.delay_max)
81
+ await asyncio.sleep(delay)
82
+
83
+ try:
84
+ result = await scraper.scrape(url)
85
+ except Exception as e:
86
+ result = ScrapeResult(
87
+ input_url=url,
88
+ success=False,
89
+ error=str(e),
90
+ )
91
+
92
+ completed_count += 1
93
+
94
+ # Callback
95
+ if on_result:
96
+ try:
97
+ on_result(result, completed_count, total)
98
+ except Exception:
99
+ pass
100
+
101
+ # Periodic save
102
+ async with save_lock:
103
+ results.append(result)
104
+ if (
105
+ output_path
106
+ and completed_count % config.save_interval == 0
107
+ ):
108
+ _save_results(results, output_path)
109
+ logger.info(
110
+ "Progress saved: %d/%d (%.1f%%)",
111
+ completed_count,
112
+ total,
113
+ completed_count / total * 100,
114
+ )
115
+
116
+ return result
117
+
118
+ # Run all tasks with concurrency control
119
+ tasks = [_scrape_one(url) for url in remaining_urls]
120
+ await asyncio.gather(*tasks, return_exceptions=True)
121
+
122
+ # Final save
123
+ if output_path:
124
+ _save_results(results, output_path)
125
+ logger.info("Final results saved to %s", output_path)
126
+
127
+ return results
128
+
129
+
130
+ def _load_existing_results(
131
+ path: Path,
132
+ ) -> tuple[list[ScrapeResult], set[str]]:
133
+ """Load existing results for resume capability."""
134
+ results: list[ScrapeResult] = []
135
+ urls: set[str] = set()
136
+
137
+ try:
138
+ if path.suffix == ".json":
139
+ with open(path, "r", encoding="utf-8") as f:
140
+ data = json.load(f)
141
+ for item in data:
142
+ result = ScrapeResult(**item)
143
+ results.append(result)
144
+ urls.add(result.input_url)
145
+ elif path.suffix == ".csv":
146
+ with open(path, "r", encoding="utf-8") as f:
147
+ reader = csv.DictReader(f)
148
+ for row in reader:
149
+ urls.add(row.get("input_url", ""))
150
+ # For CSV resume, we just track URLs, not full reconstruction
151
+ except Exception as e:
152
+ logger.warning("Failed to load existing results from %s: %s", path, e)
153
+
154
+ return results, urls
155
+
156
+
157
+ def _save_results(results: list[ScrapeResult], path: Path) -> None:
158
+ """Save results to JSON or CSV file."""
159
+ path.parent.mkdir(parents=True, exist_ok=True)
160
+
161
+ if path.suffix == ".csv":
162
+ _save_csv(results, path)
163
+ else:
164
+ _save_json(results, path)
165
+
166
+
167
+ def _save_json(results: list[ScrapeResult], path: Path) -> None:
168
+ """Save results as JSON."""
169
+ data = []
170
+ for r in results:
171
+ item = r.model_dump(mode="json")
172
+ data.append(item)
173
+
174
+ with open(path, "w", encoding="utf-8") as f:
175
+ json.dump(data, f, ensure_ascii=False, indent=2, default=str)
176
+
177
+
178
+ def _save_csv(results: list[ScrapeResult], path: Path) -> None:
179
+ """Save results as flat CSV (place details only, no reviews)."""
180
+ if not results:
181
+ return
182
+
183
+ fieldnames = [
184
+ "input_url",
185
+ "success",
186
+ "error",
187
+ "scraped_at",
188
+ "name",
189
+ "place_id",
190
+ "address",
191
+ "rating",
192
+ "review_count",
193
+ "phone",
194
+ "website",
195
+ "category",
196
+ "price_level",
197
+ "latitude",
198
+ "longitude",
199
+ "plus_code",
200
+ "url",
201
+ "google_maps_url",
202
+ "description",
203
+ "photos_count",
204
+ "permanently_closed",
205
+ "temporarily_closed",
206
+ ]
207
+
208
+ with open(path, "w", encoding="utf-8", newline="") as f:
209
+ writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
210
+ writer.writeheader()
211
+
212
+ for r in results:
213
+ row = {
214
+ "input_url": r.input_url,
215
+ "success": r.success,
216
+ "error": r.error,
217
+ "scraped_at": str(r.scraped_at),
218
+ }
219
+ if r.place:
220
+ place_dict = r.place.model_dump()
221
+ # Flatten hours to a single string
222
+ if place_dict.get("hours"):
223
+ place_dict["hours"] = " | ".join(place_dict["hours"])
224
+ row.update(place_dict)
225
+ writer.writerow(row)
226
+
227
+
228
+ def load_urls_from_file(path: str | Path) -> list[str]:
229
+ """Load URLs from a text file or CSV file.
230
+
231
+ Supports:
232
+ - Plain text file (one URL per line)
233
+ - CSV file (looks for 'url' column, or uses first column)
234
+
235
+ Args:
236
+ path: Path to the input file.
237
+
238
+ Returns:
239
+ List of URL strings.
240
+ """
241
+ path = Path(path)
242
+
243
+ if path.suffix == ".csv":
244
+ urls = []
245
+ with open(path, "r", encoding="utf-8") as f:
246
+ reader = csv.DictReader(f)
247
+ # Try 'url' column first, then first column
248
+ for row in reader:
249
+ url = row.get("url") or row.get("URL") or row.get("link")
250
+ if not url:
251
+ # Use first column value
252
+ url = next(iter(row.values()), None)
253
+ if url and url.strip():
254
+ urls.append(url.strip())
255
+ return urls
256
+ else:
257
+ # Plain text, one URL per line
258
+ with open(path, "r", encoding="utf-8") as f:
259
+ return [
260
+ line.strip()
261
+ for line in f
262
+ if line.strip() and not line.startswith("#")
263
+ ]
gmaps_scraper/cli.py ADDED
@@ -0,0 +1,238 @@
1
+ """CLI entry point for gmaps-scraper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import logging
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from gmaps_scraper.batch import load_urls_from_file, scrape_batch
13
+ from gmaps_scraper.models import ScrapeConfig, ScrapeResult
14
+ from gmaps_scraper.scraper import GoogleMapsScraper
15
+
16
+
17
+ def main() -> None:
18
+ """Main CLI entry point."""
19
+ parser = argparse.ArgumentParser(
20
+ prog="gmaps-scraper",
21
+ description="Scrape Google Maps place details without API key",
22
+ )
23
+ parser.add_argument(
24
+ "-v", "--verbose",
25
+ action="store_true",
26
+ help="Enable verbose logging",
27
+ )
28
+
29
+ subparsers = parser.add_subparsers(dest="command", required=True)
30
+
31
+ # --- scrape command ---
32
+ scrape_parser = subparsers.add_parser(
33
+ "scrape",
34
+ help="Scrape a single Google Maps URL",
35
+ )
36
+ scrape_parser.add_argument(
37
+ "url",
38
+ help="Google Maps URL to scrape",
39
+ )
40
+ scrape_parser.add_argument(
41
+ "--lang",
42
+ default=None,
43
+ help="Language code (e.g., zh-TW, en, ja). Default: none",
44
+ )
45
+ scrape_parser.add_argument(
46
+ "--no-headless",
47
+ action="store_true",
48
+ help="Show browser window (for debugging)",
49
+ )
50
+ scrape_parser.add_argument(
51
+ "--reviews",
52
+ action="store_true",
53
+ help="Also scrape individual reviews",
54
+ )
55
+ scrape_parser.add_argument(
56
+ "--max-reviews",
57
+ type=int,
58
+ default=20,
59
+ help="Max reviews to scrape (default: 20)",
60
+ )
61
+
62
+ # --- batch command ---
63
+ batch_parser = subparsers.add_parser(
64
+ "batch",
65
+ help="Batch scrape URLs from a file",
66
+ )
67
+ batch_parser.add_argument(
68
+ "input",
69
+ help="Input file (CSV or text, one URL per line)",
70
+ )
71
+ batch_parser.add_argument(
72
+ "-o", "--output",
73
+ required=True,
74
+ help="Output file path (.json or .csv)",
75
+ )
76
+ batch_parser.add_argument(
77
+ "--concurrency",
78
+ type=int,
79
+ default=5,
80
+ help="Number of concurrent scrapers (default: 5)",
81
+ )
82
+ batch_parser.add_argument(
83
+ "--lang",
84
+ default=None,
85
+ help="Language code (e.g., zh-TW, en, ja). Default: none",
86
+ )
87
+ batch_parser.add_argument(
88
+ "--no-headless",
89
+ action="store_true",
90
+ help="Show browser window (for debugging)",
91
+ )
92
+ batch_parser.add_argument(
93
+ "--proxy",
94
+ help="Proxy server URL (e.g., http://proxy:8080)",
95
+ )
96
+ batch_parser.add_argument(
97
+ "--delay-min",
98
+ type=float,
99
+ default=2.0,
100
+ help="Minimum delay between requests in seconds (default: 2.0)",
101
+ )
102
+ batch_parser.add_argument(
103
+ "--delay-max",
104
+ type=float,
105
+ default=5.0,
106
+ help="Maximum delay between requests in seconds (default: 5.0)",
107
+ )
108
+ batch_parser.add_argument(
109
+ "--no-resume",
110
+ action="store_true",
111
+ help="Don't resume from existing output file",
112
+ )
113
+ batch_parser.add_argument(
114
+ "--reviews",
115
+ action="store_true",
116
+ help="Also scrape individual reviews",
117
+ )
118
+ batch_parser.add_argument(
119
+ "--max-reviews",
120
+ type=int,
121
+ default=20,
122
+ help="Max reviews per place (default: 20)",
123
+ )
124
+ batch_parser.add_argument(
125
+ "--save-interval",
126
+ type=int,
127
+ default=50,
128
+ help="Save results every N items (default: 50)",
129
+ )
130
+
131
+ args = parser.parse_args()
132
+
133
+ # Setup logging
134
+ log_level = logging.DEBUG if args.verbose else logging.INFO
135
+ logging.basicConfig(
136
+ level=log_level,
137
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
138
+ datefmt="%Y-%m-%d %H:%M:%S",
139
+ )
140
+
141
+ if args.command == "scrape":
142
+ _cmd_scrape(args)
143
+ elif args.command == "batch":
144
+ _cmd_batch(args)
145
+
146
+
147
+ def _cmd_scrape(args: argparse.Namespace) -> None:
148
+ """Handle `gmaps-scraper scrape` command."""
149
+ config = ScrapeConfig(
150
+ headless=not args.no_headless,
151
+ language=args.lang,
152
+ scrape_reviews=args.reviews,
153
+ max_reviews=args.max_reviews,
154
+ )
155
+
156
+ async def _run() -> ScrapeResult:
157
+ async with GoogleMapsScraper(config) as scraper:
158
+ return await scraper.scrape(args.url)
159
+
160
+ result = asyncio.run(_run())
161
+
162
+ # Output as JSON
163
+ output = result.model_dump(mode="json")
164
+ print(json.dumps(output, ensure_ascii=False, indent=2, default=str))
165
+
166
+ if not result.success:
167
+ sys.exit(1)
168
+
169
+
170
+ def _cmd_batch(args: argparse.Namespace) -> None:
171
+ """Handle `gmaps-scraper batch` command."""
172
+ input_path = Path(args.input)
173
+ if not input_path.exists():
174
+ print(f"Error: Input file not found: {input_path}", file=sys.stderr)
175
+ sys.exit(1)
176
+
177
+ urls = load_urls_from_file(input_path)
178
+ if not urls:
179
+ print("Error: No URLs found in input file", file=sys.stderr)
180
+ sys.exit(1)
181
+
182
+ print(f"Loaded {len(urls)} URLs from {input_path}")
183
+
184
+ config = ScrapeConfig(
185
+ concurrency=args.concurrency,
186
+ headless=not args.no_headless,
187
+ language=args.lang,
188
+ proxy=args.proxy,
189
+ delay_min=args.delay_min,
190
+ delay_max=args.delay_max,
191
+ save_interval=args.save_interval,
192
+ scrape_reviews=args.reviews,
193
+ max_reviews=args.max_reviews,
194
+ )
195
+
196
+ # Progress tracking
197
+ try:
198
+ from tqdm import tqdm
199
+
200
+ pbar = tqdm(total=len(urls), desc="Scraping", unit="place")
201
+
202
+ def on_result(result: ScrapeResult, idx: int, total: int) -> None:
203
+ pbar.update(1)
204
+ status = "✓" if result.success else "✗"
205
+ name = result.place.name if result.place else "?"
206
+ pbar.set_postfix_str(f"{status} {name}")
207
+
208
+ except ImportError:
209
+
210
+ def on_result(result: ScrapeResult, idx: int, total: int) -> None:
211
+ status = "✓" if result.success else "✗"
212
+ name = result.place.name if result.place else "?"
213
+ print(f" [{idx}/{total}] {status} {name}")
214
+
215
+ pbar = None
216
+
217
+ results = asyncio.run(
218
+ scrape_batch(
219
+ urls=urls,
220
+ config=config,
221
+ output_path=args.output,
222
+ on_result=on_result,
223
+ resume=not args.no_resume,
224
+ )
225
+ )
226
+
227
+ if pbar:
228
+ pbar.close()
229
+
230
+ # Summary
231
+ success_count = sum(1 for r in results if r.success)
232
+ fail_count = len(results) - success_count
233
+ print(f"\nDone! {success_count} succeeded, {fail_count} failed")
234
+ print(f"Results saved to: {args.output}")
235
+
236
+
237
+ if __name__ == "__main__":
238
+ main()
@@ -0,0 +1,82 @@
1
+ """Pydantic data models for Google Maps scraper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Optional
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class PlaceDetails(BaseModel):
12
+ """Structured details about a Google Maps place."""
13
+
14
+ name: Optional[str] = None
15
+ place_id: Optional[str] = None
16
+ address: Optional[str] = None
17
+ rating: Optional[float] = None
18
+ review_count: Optional[int] = None
19
+ phone: Optional[str] = None
20
+ website: Optional[str] = None
21
+ category: Optional[str] = None
22
+ price_level: Optional[str] = None
23
+ hours: Optional[list[str]] = None
24
+ latitude: Optional[float] = None
25
+ longitude: Optional[float] = None
26
+ plus_code: Optional[str] = None
27
+ url: Optional[str] = None
28
+ google_maps_url: Optional[str] = None
29
+ image_url: Optional[str] = None
30
+ description: Optional[str] = None
31
+ photos_count: Optional[int] = None
32
+
33
+ # Additional details
34
+ permanently_closed: bool = False
35
+ temporarily_closed: bool = False
36
+
37
+
38
+ class Review(BaseModel):
39
+ """A single Google Maps review."""
40
+
41
+ author: Optional[str] = None
42
+ rating: Optional[int] = None
43
+ text: Optional[str] = None
44
+ time: Optional[str] = None
45
+ language: Optional[str] = None
46
+ likes: Optional[int] = None
47
+
48
+
49
+ class ScrapeResult(BaseModel):
50
+ """Result of scraping a single Google Maps URL."""
51
+
52
+ input_url: str
53
+ place: Optional[PlaceDetails] = None
54
+ reviews: list[Review] = Field(default_factory=list)
55
+ success: bool = True
56
+ error: Optional[str] = None
57
+ scraped_at: datetime = Field(default_factory=datetime.now)
58
+
59
+
60
+ class ScrapeConfig(BaseModel):
61
+ """Configuration for the scraper."""
62
+
63
+ concurrency: int = Field(default=5, ge=1, le=50)
64
+ timeout: int = Field(default=30000, description="Page load timeout in ms")
65
+ headless: bool = True
66
+ language: Optional[str] = None
67
+ max_retries: int = 3
68
+ delay_min: float = 1.0
69
+ delay_max: float = 3.0
70
+ save_interval: int = Field(
71
+ default=50,
72
+ description="Save results every N items for crash recovery",
73
+ )
74
+ proxy: Optional[str] = None
75
+ scrape_reviews: bool = Field(
76
+ default=False,
77
+ description="Whether to also scrape individual reviews",
78
+ )
79
+ max_reviews: int = Field(
80
+ default=20,
81
+ description="Max reviews to scrape per place (if scrape_reviews=True)",
82
+ )