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.
Files changed (27) hide show
  1. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/PKG-INFO +9 -2
  2. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/README.md +8 -1
  3. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_reviews.py +31 -9
  4. gmbscraper-0.2.2/src/gmbscraper/_search_reviews.py +188 -0
  5. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_version.py +1 -1
  6. gmbscraper-0.2.2/tests/test_search_reviews.py +21 -0
  7. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/.gitignore +0 -0
  8. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/LICENSE +0 -0
  9. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/pyproject.toml +0 -0
  10. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/skills/SKILL.md +0 -0
  11. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/skills/references/development.md +0 -0
  12. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/skills/references/matching.md +0 -0
  13. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/skills/references/parallel-fanout.md +0 -0
  14. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/__init__.py +0 -0
  15. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_adaptive_search.py +0 -0
  16. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_matching.py +0 -0
  17. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_page.py +0 -0
  18. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_parallel.py +0 -0
  19. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_places.py +0 -0
  20. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/_scraper.py +0 -0
  21. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/models.py +0 -0
  22. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/parsing.py +0 -0
  23. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/src/gmbscraper/py.typed +0 -0
  24. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/tests/test_matching.py +0 -0
  25. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/tests/test_parallel.py +0 -0
  26. {gmbscraper-0.2.0 → gmbscraper-0.2.2}/tests/test_parsing.py +0 -0
  27. {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.0
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, which signals throttling rather than a problem with one place.
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, which signals throttling rather than a problem with one place.
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
- raise GoogleLimitedViewError("Google Maps is showing a limited view — reviews unavailable")
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
- before = len(seen)
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]
@@ -4,4 +4,4 @@ Read by ``gmbscraper/__init__.py`` (public ``__version__``) and by the
4
4
  build backend (``pyproject.toml`` -> ``[tool.hatch.version]``).
5
5
  """
6
6
 
7
- __version__ = "0.2.0"
7
+ __version__ = "0.2.2"
@@ -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