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.
- gmaps_scraper/__init__.py +33 -0
- gmaps_scraper/batch.py +263 -0
- gmaps_scraper/cli.py +238 -0
- gmaps_scraper/models.py +82 -0
- gmaps_scraper/parser.py +546 -0
- gmaps_scraper/scraper.py +494 -0
- gmaps_scraper/utils.py +205 -0
- google_maps_scraper-0.1.0.dist-info/METADATA +284 -0
- google_maps_scraper-0.1.0.dist-info/RECORD +12 -0
- google_maps_scraper-0.1.0.dist-info/WHEEL +4 -0
- google_maps_scraper-0.1.0.dist-info/entry_points.txt +2 -0
- google_maps_scraper-0.1.0.dist-info/licenses/LICENSE +21 -0
gmaps_scraper/parser.py
ADDED
|
@@ -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
|