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,546 @@
1
+ """DOM parsing logic for extracting place details from Google Maps pages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ import time
8
+ from typing import Optional
9
+
10
+ from playwright.async_api import Page
11
+
12
+ from gmaps_scraper.models import PlaceDetails, Review
13
+ from gmaps_scraper.utils import (
14
+ extract_coordinates_from_url,
15
+ parse_review_count,
16
+ safe_float,
17
+ safe_int,
18
+ )
19
+
20
+ logger = logging.getLogger("gmaps_scraper")
21
+
22
+
23
+ async def parse_place_details(page: Page) -> PlaceDetails:
24
+ """Extract standard place details using playwright page."""
25
+ details = PlaceDetails()
26
+ details.url = page.url
27
+
28
+ t0 = time.time()
29
+
30
+ # Try to expand hours if possible before evaluation
31
+ try:
32
+ hours_button = await page.query_selector(
33
+ 'div[data-hide-tooltip-on-mouse-move] button[data-item-id*="oh"],'
34
+ 'button[aria-label*="hour"], button[aria-label*="營業"], button[aria-label*="営業"]'
35
+ )
36
+ if hours_button:
37
+ await hours_button.click(timeout=1000)
38
+ await page.wait_for_timeout(500)
39
+ except Exception:
40
+ pass
41
+
42
+ # We use a single evaluate call to run all DOM queries entirely in the browser.
43
+ # This prevents the massive async CDP overhead from hundreds of Python queries.
44
+ # NOTE: All regex patterns here use [0-9] instead of \d, and [(] [)] instead of
45
+ # \( \) to avoid Python raw-string / JS double-escape confusion.
46
+ js_extractor = r"""() => {
47
+ const result = {};
48
+
49
+ // Find the main place panel to scope our search and avoid "Nearby Places" bleed
50
+ let panel = document.body;
51
+ let titleEl = document.querySelector('h1.DUwDvf');
52
+ if (titleEl) {
53
+ let curr = titleEl;
54
+ for(let i=0; i<8; i++) {
55
+ if(curr.parentElement && curr.parentElement.tagName !== 'BODY') {
56
+ curr = curr.parentElement;
57
+ }
58
+ }
59
+ panel = curr;
60
+ }
61
+
62
+ // Helper: first visible text from selector list (no digit requirement)
63
+ const firstText = (selectors) => {
64
+ for (let sel of selectors) {
65
+ let el = panel.querySelector(sel);
66
+ if (el && el.innerText && el.innerText.trim()) {
67
+ return el.innerText.trim();
68
+ }
69
+ }
70
+ return null;
71
+ };
72
+
73
+ // Helper: first visible text containing at least one digit
74
+ const firstTextWithNumber = (selectors) => {
75
+ for (let sel of selectors) {
76
+ let el = panel.querySelector(sel);
77
+ if (el && el.innerText && el.innerText.trim() && el.innerText.match(/[0-9]/)) {
78
+ return el.innerText.trim();
79
+ }
80
+ }
81
+ return null;
82
+ };
83
+
84
+ // Helper: data-item-id wrapper
85
+ const dataItemId = (keywords) => {
86
+ for (let kw of keywords) {
87
+ let kw_l = kw.toLowerCase().replace(/ /g, '');
88
+ let el = document.querySelector(`button[data-item-id="${kw_l}"]`);
89
+ if (el && el.innerText && el.innerText.trim()) {
90
+ let text = el.innerText.trim();
91
+ for (let k of keywords) {
92
+ if (text.startsWith(k)) return text.substring(k.length).replace(/^:/,'').trim();
93
+ }
94
+ return text;
95
+ }
96
+ }
97
+ return null;
98
+ };
99
+
100
+ // ===== Name =====
101
+ result.name = titleEl ? titleEl.innerText.trim() : "";
102
+ if (!result.name) {
103
+ result.name = firstText([
104
+ 'h1.DUwDvf',
105
+ 'h2[data-attrid="title"]',
106
+ '.x3AX1-LfntMc-header-title-title',
107
+ 'h1.lfPIob', 'h1[class*="header"] span',
108
+ 'h1.fontHeadlineLarge', 'div[role="main"] h1', 'h1'
109
+ ]);
110
+ }
111
+
112
+ // ===== Rating =====
113
+ result.rating = firstTextWithNumber([
114
+ 'div.F7nice > span > span[aria-hidden="true"]:first-child',
115
+ 'div.fontDisplayLarge',
116
+ 'span[role="img"][aria-label*="star"]',
117
+ 'span.ceNzKf[role="img"]',
118
+ ]);
119
+ if (!result.rating) {
120
+ // Fallback: check aria-label on star icons
121
+ let starEls = panel.querySelectorAll('span[role="img"]');
122
+ for (let el of starEls) {
123
+ let aria = el.getAttribute('aria-label');
124
+ if (aria && (aria.includes('star') || aria.includes('顆星') || aria.includes('つ星'))) {
125
+ let m = aria.match(/([0-9]+[.,]?[0-9]*)/);
126
+ if (m) { result.rating = m[1]; break; }
127
+ }
128
+ }
129
+ }
130
+
131
+ // ===== Review Count (PRIMARY: aria-label on review button) =====
132
+ // Strategy: use aria-label which is the most reliable source across all locales.
133
+ // Pattern: "1,296 件の Google クチコミ" or "1,296 reviews" or "1,296 篇 Google 評論"
134
+ // IMPORTANT: Collect ALL candidates and pick the LARGEST, because tag/filter
135
+ // buttons (e.g. "晴空塔 3,175則評論") can appear before the main total count.
136
+ const reviewKeywords = ['review', '評論', 'クチコミ'];
137
+ const reviewCountRegex = /([0-9][0-9,.]*[ ]*[KM万萬]?)[ ]*(?:reviews?|則[ ]*Google[ ]*評論|篇[ ]*Google[ ]*評論|則評論|篇評論|評論|件の[ ]*Google[ ]*クチコミ|件のクチコミ|クチコミ)/i;
138
+
139
+ // Helper: parse a count string like "93,962" or "3.6K" to a numeric value for comparison
140
+ const parseCount = (s) => {
141
+ s = s.trim().toUpperCase();
142
+ let mul = 1;
143
+ if (s.includes('K')) mul = 1000;
144
+ else if (s.includes('M')) mul = 1000000;
145
+ else if (s.includes('萬') || s.includes('万')) mul = 10000;
146
+ s = s.replace(/[KM萬万]/g, '');
147
+ let n = parseFloat(s.replace(/,/g, ''));
148
+ return isNaN(n) ? 0 : n * mul;
149
+ };
150
+
151
+ // Method 1: aria-label on buttons — collect ALL and pick largest
152
+ let bestCount = null;
153
+ let bestCountVal = 0;
154
+ let bestSource = null;
155
+
156
+ // Additional regex: "更多評論 (93,959)" / "More reviews (93,959)" — keyword BEFORE number
157
+ const reverseCountRegex = /(?:reviews?|評論|クチコミ)\s*[(]([0-9][0-9,.]*[ ]*[KM万萬]*)[)]/i;
158
+
159
+ let allButtons = panel.querySelectorAll('button');
160
+ for (let btn of allButtons) {
161
+ let aria = btn.getAttribute('aria-label');
162
+ if (aria && reviewKeywords.some(kw => aria.includes(kw))) {
163
+ let m = aria.match(reviewCountRegex) || aria.match(reverseCountRegex);
164
+ if (m) {
165
+ let val = parseCount(m[1]);
166
+ if (val > bestCountVal) {
167
+ bestCountVal = val;
168
+ bestCount = m[1].trim();
169
+ bestSource = 'aria-button';
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ // Method 2: aria-label on any element — also collect and compare
176
+ if (!bestCount) {
177
+ let allEls = panel.querySelectorAll('*');
178
+ for (let el of allEls) {
179
+ let aria = el.getAttribute('aria-label');
180
+ if (aria && aria.match(/[0-9]/) && reviewKeywords.some(kw => aria.includes(kw))) {
181
+ let m = aria.match(reviewCountRegex) || aria.match(reverseCountRegex);
182
+ if (m) {
183
+ let val = parseCount(m[1]);
184
+ if (val > bestCountVal) {
185
+ bestCountVal = val;
186
+ bestCount = m[1].trim();
187
+ bestSource = 'aria-any';
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ // Method 3: innerText of F7nice review span (displayed count next to rating)
195
+ // Always runs — F7nice is the most authoritative source for total review count.
196
+ // It overrides tag-button counts which are per-filter, not total.
197
+ {
198
+ let reviewSpans = panel.querySelectorAll('div.F7nice span');
199
+ for (let sp of reviewSpans) {
200
+ let text = sp.innerText ? sp.innerText.trim() : '';
201
+ // Match "(1,296)" or "1,296" — parenthesized or bare number
202
+ let m = text.match(/^[(]?([0-9][0-9,.]*[ ]*[KM万萬]*)[)]?$/i);
203
+ if (m && !text.match(/^[0-9]+[.,][0-9]+$/) /* skip rating like 4.2 */) {
204
+ let val = parseCount(m[1]);
205
+ if (val > bestCountVal) {
206
+ bestCountVal = val;
207
+ bestCount = m[1].trim();
208
+ bestSource = 'f7nice-span';
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ if (bestCount) {
215
+ result.review_count = bestCount;
216
+ result._rc_source = bestSource;
217
+ }
218
+
219
+ // Method 4: Text walker — match "N reviews" / "N 件のクチコミ" / "N 篇評論"
220
+ if (!result.review_count) {
221
+ let walk = document.createTreeWalker(panel, NodeFilter.SHOW_TEXT, null, false);
222
+ let n;
223
+ while (n = walk.nextNode()) {
224
+ let text = n.nodeValue.trim();
225
+ if (text.length > 0 && text.length < 100) {
226
+ let m = text.match(reviewCountRegex);
227
+ if (m) {
228
+ result.review_count = m[1].trim();
229
+ result._rc_source = 'text-walker';
230
+ break;
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ // Method 5: Tab buttons — "Reviews" tab with count
237
+ if (!result.review_count) {
238
+ let tabs = panel.querySelectorAll('div[role="tablist"] button');
239
+ for (let tab of tabs) {
240
+ let text = tab.innerText || "";
241
+ if (reviewKeywords.some(kw => text.toLowerCase().includes(kw.toLowerCase()))) {
242
+ let m = text.match(/([0-9][0-9,.]*[ ]*[KM万萬]*)/i);
243
+ if (m) {
244
+ result.review_count = m[1].trim();
245
+ result._rc_source = 'tab';
246
+ break;
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ // Method 6: Full body text search (for hotel/lodging pages where the scraper
253
+ // has clicked the star icon to open the Reviews summary panel)
254
+ // NOTE: Using \s* not [ ]* because innerText has \n between DOM elements
255
+ if (!result.review_count) {
256
+ let bodyText = document.body.innerText;
257
+ let m = bodyText.match(/([0-9][0-9,.]*)\s*(?:件の|件\s*の)?\s*(?:Google\s*)?(?:クチコミ|reviews?|評論)/i);
258
+ if (m) {
259
+ result.review_count = m[1].trim();
260
+ result._rc_source = 'body-text';
261
+ }
262
+ }
263
+
264
+ // ===== Category =====
265
+ result.category = firstText([
266
+ 'button[jsaction*="category"]',
267
+ 'span[jstcache] button.DkEaL',
268
+ 'div[role="main"] button.DkEaL'
269
+ ]);
270
+
271
+ // ===== Address =====
272
+ result.address = dataItemId(["地址", "Address", "住所", "所在地"]);
273
+ if (!result.address) {
274
+ let addressBtn = document.querySelector('button[data-item-id="address"]');
275
+ if (addressBtn) result.address = addressBtn.innerText.trim();
276
+ }
277
+ if (!result.address) {
278
+ // Fallback: look for aria-label containing address keywords
279
+ let btns = document.querySelectorAll('button[aria-label]');
280
+ for (let btn of btns) {
281
+ let aria = btn.getAttribute('aria-label') || '';
282
+ if (["地址", "Address", "住所"].some(kw => aria.includes(kw))) {
283
+ let text = aria;
284
+ ["地址", "Address", "住所"].forEach(kw => {
285
+ text = text.replace(new RegExp(`^${kw}:?`, 'i'), '').trim();
286
+ });
287
+ if (text) { result.address = text; break; }
288
+ }
289
+ }
290
+ }
291
+ // Clean newlines from address (multi-line DOM elements)
292
+ if (result.address) {
293
+ result.address = result.address.replace(/[\r\n]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
294
+ }
295
+
296
+ // ===== Phone =====
297
+ result.phone = dataItemId(["電話", "Phone", "電話番号"]);
298
+ if (!result.phone) {
299
+ let phoneEl = document.querySelector('button[data-item-id^="phone:"]');
300
+ if (phoneEl) result.phone = phoneEl.innerText.trim();
301
+ }
302
+
303
+ // ===== Website =====
304
+ let webEl = document.querySelector('a[data-item-id="authority"]');
305
+ if (webEl) result.website = webEl.getAttribute('href');
306
+ if (!result.website) result.website = dataItemId(["網站", "Website", "ウェブサイト"]);
307
+
308
+ // ===== Plus Code =====
309
+ result.plus_code = dataItemId(["Plus Code", "Plus code", "プラスコード"]);
310
+ if (!result.plus_code) {
311
+ let plusEl = document.querySelector('button[data-item-id="oloc"]');
312
+ if (plusEl) result.plus_code = plusEl.innerText.trim();
313
+ }
314
+
315
+ // ===== Closed Status =====
316
+ let closedEl = panel.querySelector('span[class*="permanently"], div[class*="closed"]');
317
+ if (closedEl && closedEl.innerText) {
318
+ let lower = closedEl.innerText.toLowerCase();
319
+ if (lower.includes("permanently") || lower.includes("永久")) result.permanently_closed = true;
320
+ else if (lower.includes("temporarily") || lower.includes("暫時")) result.temporarily_closed = true;
321
+ }
322
+
323
+ // ===== Description =====
324
+ result.description = firstText(['div[class*="editorial"] span', 'div[class*="description"] span']);
325
+
326
+ // ===== Hours =====
327
+ result.hours = [];
328
+ let hrsRows = document.querySelectorAll('table[class*="eK4R0e"] tr, table[class*="hours"] tr');
329
+ if (hrsRows.length > 0) {
330
+ for (let row of hrsRows) {
331
+ if (row.innerText && row.innerText.trim()) result.hours.push(row.innerText.trim());
332
+ }
333
+ }
334
+ if (result.hours.length === 0) {
335
+ let hoursBtn = document.querySelector('div[data-hide-tooltip-on-mouse-move] button[data-item-id*="oh"], button[aria-label*="hour"], button[aria-label*="營業"], button[aria-label*="営業"]');
336
+ if (hoursBtn) {
337
+ let aria = hoursBtn.getAttribute('aria-label');
338
+ if (aria) result.hours.push(aria);
339
+ }
340
+ }
341
+
342
+ // ===== Photos Count =====
343
+ let photoBtn = panel.querySelector('button[aria-label*="photo"], button[aria-label*="張相片"], button[aria-label*="写真"], button[aria-label*="枚の写真"]');
344
+ if (photoBtn) {
345
+ let aria = photoBtn.getAttribute('aria-label') || '';
346
+ let m = aria.match(/([0-9][0-9,.]*)/);
347
+ if (m) result.photos_count = m[1].replace(/,/g, '');
348
+ }
349
+
350
+ return result;
351
+ }"""
352
+
353
+ extracted = await page.evaluate(js_extractor)
354
+
355
+ # Populate python model
356
+ details.name = extracted.get("name")
357
+ if extracted.get("rating"):
358
+ details.rating = safe_float(extracted["rating"].replace(",", "."))
359
+ if extracted.get("review_count"):
360
+ c = extracted["review_count"]
361
+ m = re.search(r"([0-9,.]+[\sKM万萬]*)", c, re.IGNORECASE)
362
+ if m:
363
+ details.review_count = parse_review_count(m.group(1))
364
+ else:
365
+ details.review_count = parse_review_count(c)
366
+
367
+ details.address = extracted.get("address")
368
+ details.phone = extracted.get("phone")
369
+ details.website = extracted.get("website")
370
+ cat = extracted.get("category")
371
+ if cat:
372
+ # Strip leading/trailing separators (·, ‧, ·, •) and whitespace
373
+ details.category = re.sub(r'^[\s·‧·•·]+|[\s·‧·•·]+$', '', cat).strip()
374
+ else:
375
+ details.category = None
376
+ hours_list = extracted.get("hours", [])
377
+ if hours_list:
378
+ details.hours = hours_list
379
+ details.plus_code = extracted.get("plus_code")
380
+ details.description = extracted.get("description")
381
+
382
+ if extracted.get("photos_count"):
383
+ details.photos_count = safe_int(extracted["photos_count"])
384
+
385
+ if extracted.get("permanently_closed"):
386
+ details.permanently_closed = True
387
+ elif extracted.get("temporarily_closed"):
388
+ details.temporarily_closed = True
389
+
390
+ # Capture resolved Google Maps URL (after redirect from search URL)
391
+ current_url = page.url
392
+ if '/maps/' in current_url:
393
+ details.google_maps_url = current_url
394
+
395
+ # Extract coordinates from URL
396
+ coords = extract_coordinates_from_url(current_url)
397
+ if coords:
398
+ details.latitude, details.longitude = coords
399
+
400
+ # Try place_id from URL
401
+ place_id_match = re.search(r"place_id[=:]([A-Za-z0-9_-]+)", page.url)
402
+ if place_id_match:
403
+ details.place_id = place_id_match.group(1)
404
+
405
+ if not details.place_id:
406
+ place_id_match = re.search(r"0x[0-9a-f]+:0x[0-9a-f]+", page.url)
407
+ if place_id_match:
408
+ details.place_id = place_id_match.group(0)
409
+
410
+ # FALLBACK 1: Category from span.mgr77e (hotel/lodging category label)
411
+ if details.category is None:
412
+ try:
413
+ cat_js = r"""() => {
414
+ let el = document.querySelector('span.mgr77e');
415
+ return el ? el.innerText.trim() : null;
416
+ }"""
417
+ cat_value = await page.evaluate(cat_js)
418
+ if cat_value:
419
+ details.category = cat_value.strip('·').strip()
420
+ except Exception:
421
+ pass
422
+
423
+ # FALLBACK 3: If still no review count, try HTML source regex
424
+ if details.review_count is None:
425
+ try:
426
+ html = await page.content()
427
+ # Search for review count patterns in raw HTML/JSON data embedded by Google
428
+ for kw in [
429
+ "件の Google クチコミ", "件のクチコミ", "クチコミ",
430
+ "reviews", "review",
431
+ "則 Google 評論", "篇 Google 評論", "則評論", "篇評論", "評論",
432
+ ]:
433
+ idx = html.find(kw)
434
+ if idx != -1:
435
+ chunk = html[max(0, idx - 20):idx].strip()
436
+ num_match = re.search(r'([0-9][0-9,.]{0,10}[\sKM万萬]?)\s*$', chunk)
437
+ if num_match:
438
+ details.review_count = parse_review_count(num_match.group(1).strip())
439
+ logger.debug("Review count found via HTML fallback: %s", details.review_count)
440
+ break
441
+ except Exception as e:
442
+ logger.debug("HTML fallback failed: %s", e)
443
+
444
+ t1 = time.time()
445
+ logger.info("DOM parsing took %.2fs (review_count=%s, source=%s)",
446
+ t1 - t0, details.review_count, extracted.get("_rc_source", "fallback"))
447
+
448
+ return details
449
+
450
+
451
+ async def parse_reviews(page: Page, max_reviews: int = 20) -> list[Review]:
452
+ """Extract reviews from a Google Maps place page."""
453
+ reviews: list[Review] = []
454
+
455
+ # Try to click the reviews tab/button
456
+ try:
457
+ review_tab = await page.query_selector(
458
+ 'button[aria-label*="review"], '
459
+ 'button[aria-label*="評論"], '
460
+ 'button[aria-label*="クチコミ"], '
461
+ 'div[role="tablist"] button:nth-child(2)'
462
+ )
463
+ if review_tab:
464
+ await review_tab.click(timeout=2000)
465
+ await page.wait_for_timeout(2000)
466
+ except Exception as e:
467
+ logger.warning("Failed to click review tab: %s", e)
468
+ return reviews
469
+
470
+ # Scroll the reviews panel to load more reviews
471
+ try:
472
+ scrollable = await page.query_selector(
473
+ 'div[role="main"] div.m6QErb.DxyBCb.kA9KIf.dS8AEf'
474
+ )
475
+ if not scrollable:
476
+ scrollable = await page.query_selector('div[role="main"]')
477
+
478
+ if scrollable:
479
+ for _ in range(min(max_reviews // 5, 20)):
480
+ await scrollable.evaluate("el => el.scrollTop = el.scrollHeight")
481
+ await page.wait_for_timeout(1000)
482
+ # Check if we have enough reviews loaded
483
+ review_elements = await page.evaluate(
484
+ """() => document.querySelectorAll('div[data-review-id], div[class*="review"]').length"""
485
+ )
486
+ if review_elements >= max_reviews:
487
+ break
488
+ except Exception:
489
+ pass
490
+
491
+ # Extract all reviews via page evaluate
492
+ js_reviews = r"""(max_len) => {
493
+ let results = [];
494
+ // Click all "more" buttons first
495
+ let moreBtns = document.querySelectorAll('button[aria-label*="See more"], button[aria-label*="もっと見る"], button.w8nwRe.kyuRq');
496
+ for (let i=0; i<moreBtns.length && i<max_len; i++) {
497
+ try { moreBtns[i].click() } catch(e){}
498
+ }
499
+
500
+ let containers = document.querySelectorAll('div[data-review-id]');
501
+ if (containers.length === 0) containers = document.querySelectorAll('div[class*="jftiEf"]');
502
+
503
+ for (let i=0; i<containers.length && i<max_len; i++) {
504
+ let container = containers[i];
505
+ let rev = {};
506
+
507
+ // Name
508
+ let nameEl = container.querySelector('div.d4r55, button[class*="reviewer"] div, a[class*="name"]');
509
+ if (nameEl) rev.author = nameEl.innerText.trim();
510
+
511
+ // Text
512
+ let textEl = container.querySelector('span[class*="wiI7pd"], div.MyEned span');
513
+ if (textEl) rev.text = textEl.innerText.trim();
514
+
515
+ // Time
516
+ let timeEl = container.querySelector('span[class*="rsqaWe"], span.dehysf');
517
+ if (timeEl) rev.time = timeEl.innerText.trim();
518
+
519
+ // Stars
520
+ let starEl = container.querySelector('span[role="img"][aria-label*="star"], span[role="img"][aria-label*="顆星"], span[role="img"][aria-label*="つ星"]');
521
+ if (starEl) {
522
+ let aria = starEl.getAttribute('aria-label');
523
+ if (aria){
524
+ let m = aria.match(/([0-9])/);
525
+ if (m) rev.rating = parseInt(m[1]);
526
+ }
527
+ }
528
+ results.push(rev);
529
+ }
530
+ return results;
531
+ }"""
532
+
533
+ # Brief wait for "more" button clicks to settle
534
+ await page.wait_for_timeout(500)
535
+ extracted_reviews = await page.evaluate(js_reviews, max_reviews)
536
+
537
+ for r in extracted_reviews:
538
+ rev = Review()
539
+ rev.author = r.get("author")
540
+ rev.text = r.get("text")
541
+ rev.time = r.get("time")
542
+ rev.rating = r.get("rating")
543
+ if rev.author or rev.text:
544
+ reviews.append(rev)
545
+
546
+ return reviews