gmbscraper 0.2.0__tar.gz → 0.2.2__tar.gz
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.
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/PKG-INFO +9 -2
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/README.md +8 -1
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_reviews.py +31 -9
- gmbscraper-0.2.2/src/gmbscraper/_search_reviews.py +188 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_version.py +1 -1
- gmbscraper-0.2.2/tests/test_search_reviews.py +21 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/.gitignore +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/LICENSE +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/pyproject.toml +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/skills/SKILL.md +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/skills/references/development.md +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/skills/references/matching.md +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/skills/references/parallel-fanout.md +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/__init__.py +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_adaptive_search.py +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_matching.py +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_page.py +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_parallel.py +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_places.py +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_scraper.py +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/models.py +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/parsing.py +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/py.typed +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/tests/test_matching.py +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/tests/test_parallel.py +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/tests/test_parsing.py +0 -0
- {gmbscraper-0.2.0 → gmbscraper-0.2.2}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gmbscraper
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Playwright-based scraper for Google Maps / Google Business Profile listings and reviews
|
|
5
5
|
Project-URL: Homepage, https://github.com/exprtec/google-maps-scrapper
|
|
6
6
|
Project-URL: Repository, https://github.com/exprtec/google-maps-scrapper
|
|
@@ -91,7 +91,14 @@ with google_maps_browser() as maps:
|
|
|
91
91
|
reviews UI at all (throttled/limited view, or the reviews tab never opens),
|
|
92
92
|
since a caller needs to distinguish "zero reviews" from "couldn't scrape."
|
|
93
93
|
Its `GoogleLimitedViewError` subclass marks Google's signed-out limited
|
|
94
|
-
view
|
|
94
|
+
view when no reviews could be read at all.
|
|
95
|
+
- Signed-out Google Maps now usually serves a "limited view" whose reviews tab
|
|
96
|
+
stops after a few reviews behind a sign-in prompt. When `scrape_reviews` sees
|
|
97
|
+
it, it reads the place's name and feature id from Maps and opens the
|
|
98
|
+
"N Google reviews" panel from a Google Search for that name instead, which
|
|
99
|
+
lists every review. Only the panel whose link carries the same feature id is
|
|
100
|
+
used, so a similarly named business is never scraped by mistake. If Search
|
|
101
|
+
can't show the panel, it falls back to whatever the Maps tab shows.
|
|
95
102
|
- Each place opens in a single page load, and each browser launch gets a
|
|
96
103
|
random desktop Chrome user agent unless you pass `user_agent=`.
|
|
97
104
|
- There's no retry/backoff or proxy rotation built in. This is a DOM scraper,
|
|
@@ -67,7 +67,14 @@ with google_maps_browser() as maps:
|
|
|
67
67
|
reviews UI at all (throttled/limited view, or the reviews tab never opens),
|
|
68
68
|
since a caller needs to distinguish "zero reviews" from "couldn't scrape."
|
|
69
69
|
Its `GoogleLimitedViewError` subclass marks Google's signed-out limited
|
|
70
|
-
view
|
|
70
|
+
view when no reviews could be read at all.
|
|
71
|
+
- Signed-out Google Maps now usually serves a "limited view" whose reviews tab
|
|
72
|
+
stops after a few reviews behind a sign-in prompt. When `scrape_reviews` sees
|
|
73
|
+
it, it reads the place's name and feature id from Maps and opens the
|
|
74
|
+
"N Google reviews" panel from a Google Search for that name instead, which
|
|
75
|
+
lists every review. Only the panel whose link carries the same feature id is
|
|
76
|
+
used, so a similarly named business is never scraped by mistake. If Search
|
|
77
|
+
can't show the panel, it falls back to whatever the Maps tab shows.
|
|
71
78
|
- Each place opens in a single page load, and each browser launch gets a
|
|
72
79
|
random desktop Chrome user agent unless you pass `user_agent=`.
|
|
73
80
|
- There's no retry/backoff or proxy rotation built in. This is a DOM scraper,
|
|
@@ -8,14 +8,16 @@ from typing import Any
|
|
|
8
8
|
from gmbscraper._page import (
|
|
9
9
|
click_reviews_tab,
|
|
10
10
|
evaluate_json,
|
|
11
|
+
first_text,
|
|
11
12
|
is_limited_maps_view,
|
|
12
13
|
navigate_to_place,
|
|
13
14
|
open_page,
|
|
14
15
|
scroll_reviews_pane,
|
|
15
16
|
set_reviews_sort,
|
|
16
17
|
)
|
|
18
|
+
from gmbscraper._search_reviews import place_feature_id, scrape_search_reviews_on_page
|
|
17
19
|
from gmbscraper.models import MapsReview
|
|
18
|
-
from gmbscraper.parsing import normalize_text, parse_relative_date
|
|
20
|
+
from gmbscraper.parsing import extract_place_name_from_url, normalize_text, parse_relative_date
|
|
19
21
|
|
|
20
22
|
EXTRACT_REVIEWS_JS = """() => {
|
|
21
23
|
function extractUsername(card) {
|
|
@@ -152,6 +154,10 @@ EXTRACT_REVIEWS_JS = """() => {
|
|
|
152
154
|
}"""
|
|
153
155
|
|
|
154
156
|
|
|
157
|
+
# Scrolls that load no new review before the pane counts as fully read.
|
|
158
|
+
IDLE_SCROLL_ROUNDS = 6
|
|
159
|
+
|
|
160
|
+
|
|
155
161
|
class GoogleReviewsUnavailableError(RuntimeError):
|
|
156
162
|
pass
|
|
157
163
|
|
|
@@ -201,8 +207,24 @@ def scrape_reviews_on_page(
|
|
|
201
207
|
|
|
202
208
|
navigate_to_place(page, maps_url)
|
|
203
209
|
if is_limited_maps_view(page):
|
|
204
|
-
|
|
210
|
+
# Signed-out Maps now caps the reviews tab at a few reviews behind a
|
|
211
|
+
# sign-in prompt; Search's review panel for the same place lists them all.
|
|
212
|
+
name = first_text(page, ("h1",)) or extract_place_name_from_url(page.url)
|
|
213
|
+
found = scrape_search_reviews_on_page(
|
|
214
|
+
page,
|
|
215
|
+
name=name,
|
|
216
|
+
feature_id=place_feature_id(page.url),
|
|
217
|
+
max_reviews=max_reviews,
|
|
218
|
+
known_ids=known_ids,
|
|
219
|
+
)
|
|
220
|
+
if found:
|
|
221
|
+
return found
|
|
222
|
+
navigate_to_place(page, maps_url)
|
|
205
223
|
if not click_reviews_tab(page):
|
|
224
|
+
if is_limited_maps_view(page):
|
|
225
|
+
raise GoogleLimitedViewError(
|
|
226
|
+
"Google Maps is showing a limited view — reviews unavailable"
|
|
227
|
+
)
|
|
206
228
|
raise GoogleReviewsUnavailableError("Could not open the Google Maps reviews tab")
|
|
207
229
|
|
|
208
230
|
set_reviews_sort(page, label="Newest")
|
|
@@ -213,7 +235,10 @@ def scrape_reviews_on_page(
|
|
|
213
235
|
|
|
214
236
|
seen: dict[str, MapsReview] = {}
|
|
215
237
|
reached_known = False
|
|
238
|
+
# Rounds in a row that found no new review; growth is measured across a
|
|
239
|
+
# scroll, from one read of the pane to the next.
|
|
216
240
|
idle_rounds = 0
|
|
241
|
+
read_count = 0
|
|
217
242
|
max_scrolls = 80 if max_reviews is None else max(10, min(80, max_reviews // 2 + 8))
|
|
218
243
|
for _ in range(1, max_scrolls + 1):
|
|
219
244
|
for review in read_visible_reviews(page, limit=max_reviews):
|
|
@@ -229,15 +254,12 @@ def scrape_reviews_on_page(
|
|
|
229
254
|
reached_known = reached_known or external_id in known_ids
|
|
230
255
|
if reached_known or (max_reviews is not None and len(seen) >= max_reviews):
|
|
231
256
|
break
|
|
232
|
-
|
|
257
|
+
idle_rounds = idle_rounds + 1 if len(seen) == read_count else 0
|
|
258
|
+
read_count = len(seen)
|
|
259
|
+
if idle_rounds >= IDLE_SCROLL_ROUNDS:
|
|
260
|
+
break
|
|
233
261
|
if not scroll_reviews_pane(page):
|
|
234
262
|
idle_rounds += 1
|
|
235
|
-
else:
|
|
236
|
-
idle_rounds = 0
|
|
237
|
-
if len(seen) == before:
|
|
238
|
-
idle_rounds += 1
|
|
239
|
-
if idle_rounds >= 6:
|
|
240
|
-
break
|
|
241
263
|
time.sleep(0.5)
|
|
242
264
|
|
|
243
265
|
if not seen:
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Read a place's reviews from Google Search's review panel.
|
|
2
|
+
|
|
3
|
+
Signed-out Google Maps now serves a "limited view" that shows only a handful of
|
|
4
|
+
reviews and asks the visitor to sign in for the rest. The review panel behind a
|
|
5
|
+
Search knowledge card (the "N Google reviews" link) still lists every review,
|
|
6
|
+
so the Maps scraper falls back to it for limited-view pages.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Collection
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import quote_plus
|
|
16
|
+
|
|
17
|
+
from gmbscraper._page import accept_consent, evaluate_json
|
|
18
|
+
from gmbscraper.models import MapsReview
|
|
19
|
+
from gmbscraper.parsing import PLACE_ID_PATTERN, normalize_text, parse_relative_date
|
|
20
|
+
|
|
21
|
+
SEARCH_URL = "https://www.google.com/search?q={query}&hl={language}"
|
|
22
|
+
REVIEW_CARD = "div.bwb7ce"
|
|
23
|
+
REVIEW_PANE = "div.RVCQse"
|
|
24
|
+
IDLE_SCROLL_ROUNDS = 4
|
|
25
|
+
MAX_SCROLLS = 80
|
|
26
|
+
|
|
27
|
+
EXTRACT_SEARCH_REVIEWS_JS = """() => {
|
|
28
|
+
// Filled stars are drawn in Google's gold; empty ones in a light grey.
|
|
29
|
+
function countStars(card) {
|
|
30
|
+
let filled = 0;
|
|
31
|
+
for (const path of card.querySelectorAll('.h3PQJ svg path')) {
|
|
32
|
+
const match = getComputedStyle(path).fill.match(/\\d+/g);
|
|
33
|
+
if (match && Number(match[0]) > 200 && Number(match[2]) < 120) filled += 1;
|
|
34
|
+
}
|
|
35
|
+
return filled;
|
|
36
|
+
}
|
|
37
|
+
function reviewText(card) {
|
|
38
|
+
// The full text sits in a hidden expanded copy; the visible one is truncated with "More".
|
|
39
|
+
const node = card.querySelector('.gbdaZe .d83Iyc') || card.querySelector('.d83Iyc');
|
|
40
|
+
if (!node) return '';
|
|
41
|
+
const copy = node.cloneNode(true);
|
|
42
|
+
copy.querySelectorAll('a[role="button"]').forEach((el) => el.remove());
|
|
43
|
+
return (copy.textContent || '').trim().replace(/^["\\u201c]|["\\u201d]$/g, '').trim();
|
|
44
|
+
}
|
|
45
|
+
return [...document.querySelectorAll('div.bwb7ce')].map((card) => ({
|
|
46
|
+
external_id: card.getAttribute('data-id')
|
|
47
|
+
|| card.querySelector('[data-review-id]')?.getAttribute('data-review-id') || '',
|
|
48
|
+
username: (card.querySelector('.rhtdWc')?.textContent || '').trim(),
|
|
49
|
+
stars: countStars(card),
|
|
50
|
+
date_text: (card.querySelector('.y3Ibjb')?.textContent || '').trim(),
|
|
51
|
+
text: reviewText(card),
|
|
52
|
+
}));
|
|
53
|
+
}"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def place_feature_id(url: str) -> str:
|
|
57
|
+
"""The `0x…:0x…` feature id Google uses for a place in Maps and Search URLs."""
|
|
58
|
+
match = PLACE_ID_PATTERN.search(url or "")
|
|
59
|
+
return match.group(1).lower() if match else ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Clicks are dispatched in the page: Search's layout often lays an overlay over
|
|
63
|
+
# these controls, which makes a pointer click time out.
|
|
64
|
+
CLICK_REVIEWS_LINK_JS = """(featureId) => {
|
|
65
|
+
const pattern = /^\\s*[\\d,.]+\\s+Google reviews?\\s*$/i;
|
|
66
|
+
const links = [...document.querySelectorAll('a[data-fid]')].filter((a) =>
|
|
67
|
+
a.getAttribute('data-fid').toLowerCase() === featureId && pattern.test(a.textContent || ''));
|
|
68
|
+
if (!links.length) return false;
|
|
69
|
+
links[0].scrollIntoView({block: 'center'});
|
|
70
|
+
links[0].click();
|
|
71
|
+
return true;
|
|
72
|
+
}"""
|
|
73
|
+
|
|
74
|
+
CLICK_NEWEST_JS = """() => {
|
|
75
|
+
const label = [...document.querySelectorAll('span, div, button')]
|
|
76
|
+
.find((el) => el.children.length === 0 && (el.textContent || '').trim() === 'Newest');
|
|
77
|
+
if (!label) return false;
|
|
78
|
+
(label.closest('[jsaction], [role="button"], [role="radio"], [role="tab"]') || label).click();
|
|
79
|
+
return true;
|
|
80
|
+
}"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def open_search_review_panel(
|
|
84
|
+
page: Any, *, name: str, feature_id: str, language: str = "en"
|
|
85
|
+
) -> bool:
|
|
86
|
+
"""Open the review panel for `feature_id` from a Search for `name`. False if
|
|
87
|
+
Search doesn't show that place's review link (a name search can surface a
|
|
88
|
+
different business, so only a link carrying the same feature id counts)."""
|
|
89
|
+
if not feature_id:
|
|
90
|
+
return False
|
|
91
|
+
page.goto(
|
|
92
|
+
SEARCH_URL.format(query=quote_plus(name), language=language),
|
|
93
|
+
wait_until="domcontentloaded",
|
|
94
|
+
timeout=45000,
|
|
95
|
+
)
|
|
96
|
+
accept_consent(page)
|
|
97
|
+
with contextlib.suppress(Exception):
|
|
98
|
+
page.wait_for_selector(f'a[data-fid="{feature_id}"]', timeout=10000)
|
|
99
|
+
for _ in range(2):
|
|
100
|
+
try:
|
|
101
|
+
if not page.evaluate(CLICK_REVIEWS_LINK_JS, feature_id):
|
|
102
|
+
return False
|
|
103
|
+
page.wait_for_selector(REVIEW_CARD, timeout=15000)
|
|
104
|
+
return True
|
|
105
|
+
except Exception:
|
|
106
|
+
continue
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def sort_search_reviews_newest(page: Any) -> bool:
|
|
111
|
+
try:
|
|
112
|
+
first_id = page.locator(REVIEW_CARD).first.get_attribute("data-id")
|
|
113
|
+
if not page.evaluate(CLICK_NEWEST_JS):
|
|
114
|
+
return False
|
|
115
|
+
# The list re-renders in place; wait for a different first card.
|
|
116
|
+
page.wait_for_function(
|
|
117
|
+
"([selector, id]) => { const c = document.querySelector(selector);"
|
|
118
|
+
" return c && c.getAttribute('data-id') !== id; }",
|
|
119
|
+
arg=[REVIEW_CARD, first_id],
|
|
120
|
+
timeout=8000,
|
|
121
|
+
)
|
|
122
|
+
page.wait_for_timeout(500)
|
|
123
|
+
return True
|
|
124
|
+
except Exception:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def read_search_reviews(page: Any) -> list[MapsReview]:
|
|
129
|
+
reviews: list[MapsReview] = []
|
|
130
|
+
for item in evaluate_json(page, EXTRACT_SEARCH_REVIEWS_JS):
|
|
131
|
+
stars = int(item.get("stars") or 0)
|
|
132
|
+
if not 1 <= stars <= 5:
|
|
133
|
+
continue
|
|
134
|
+
date_text = normalize_text(item.get("date_text", ""))
|
|
135
|
+
reviews.append(
|
|
136
|
+
MapsReview(
|
|
137
|
+
external_id=normalize_text(item.get("external_id", "")),
|
|
138
|
+
stars=stars,
|
|
139
|
+
username=normalize_text(item.get("username", "")),
|
|
140
|
+
text=normalize_text(item.get("text", "")),
|
|
141
|
+
date_text=date_text,
|
|
142
|
+
date=parse_relative_date(date_text),
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
return reviews
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _scroll_pane(page: Any) -> None:
|
|
149
|
+
with contextlib.suppress(Exception):
|
|
150
|
+
page.evaluate(
|
|
151
|
+
"(selector) => { const el = document.querySelector(selector); if (el) el.scrollTop = el.scrollHeight; }",
|
|
152
|
+
REVIEW_PANE,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def scrape_search_reviews_on_page(
|
|
157
|
+
page: Any,
|
|
158
|
+
*,
|
|
159
|
+
name: str,
|
|
160
|
+
feature_id: str,
|
|
161
|
+
max_reviews: int | None = None,
|
|
162
|
+
known_ids: Collection[str] = (),
|
|
163
|
+
) -> list[MapsReview] | None:
|
|
164
|
+
"""Reviews newest first, or None when Search can't show this place's review panel."""
|
|
165
|
+
if not name or not open_search_review_panel(page, name=name, feature_id=feature_id):
|
|
166
|
+
return None
|
|
167
|
+
sort_search_reviews_newest(page)
|
|
168
|
+
|
|
169
|
+
seen: dict[str, MapsReview] = {}
|
|
170
|
+
idle_rounds = 0
|
|
171
|
+
for _ in range(MAX_SCROLLS):
|
|
172
|
+
before = len(seen)
|
|
173
|
+
reached_known = False
|
|
174
|
+
for review in read_search_reviews(page):
|
|
175
|
+
external_id = review.resolved_external_id()
|
|
176
|
+
seen.setdefault(external_id, review)
|
|
177
|
+
reached_known = reached_known or external_id in known_ids
|
|
178
|
+
if reached_known or (max_reviews is not None and len(seen) >= max_reviews):
|
|
179
|
+
break
|
|
180
|
+
idle_rounds = idle_rounds + 1 if len(seen) == before else 0
|
|
181
|
+
if idle_rounds >= IDLE_SCROLL_ROUNDS:
|
|
182
|
+
break
|
|
183
|
+
_scroll_pane(page)
|
|
184
|
+
page.wait_for_timeout(900)
|
|
185
|
+
time.sleep(0.2)
|
|
186
|
+
|
|
187
|
+
reviews = list(seen.values())
|
|
188
|
+
return reviews if max_reviews is None else reviews[:max_reviews]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from gmbscraper._search_reviews import place_feature_id
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_place_feature_id_reads_maps_place_urls() -> None:
|
|
7
|
+
url = (
|
|
8
|
+
"https://www.google.com/maps/place/Best+Builders/@31.44,73.17,17z/data=!3m1!4b1!4m6!3m5"
|
|
9
|
+
"!1s0x3922691029581c13:0x1B3FF9D4247B4509!8m2"
|
|
10
|
+
)
|
|
11
|
+
assert place_feature_id(url) == "0x3922691029581c13:0x1b3ff9d4247b4509"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_place_feature_id_reads_search_review_fragments() -> None:
|
|
15
|
+
url = "https://www.google.com/search?q=x#lrd=0x3922691029581c13:0x1b3ff9d4247b4509,1,,,,"
|
|
16
|
+
assert place_feature_id(url) == "0x3922691029581c13:0x1b3ff9d4247b4509"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_place_feature_id_is_empty_without_one() -> None:
|
|
20
|
+
assert place_feature_id("https://maps.google.com/?cid=1963562652097266953") == ""
|
|
21
|
+
assert place_feature_id("") == ""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|