gmbscraper 0.1.0__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.
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .mypy_cache/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .ty_cache/
13
+ .python-version
14
+ .DS_Store
15
+
16
+ .coverage
17
+ coverage.xml
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Exprtec
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.5
2
+ Name: gmbscraper
3
+ Version: 0.1.0
4
+ Summary: Playwright-based scraper for Google Maps / Google Business Profile listings and reviews
5
+ Project-URL: Homepage, https://github.com/exprtec/gmbscraper-python
6
+ Project-URL: Repository, https://github.com/exprtec/gmbscraper-python
7
+ Project-URL: Issues, https://github.com/exprtec/gmbscraper-python/issues
8
+ Author-email: Abdul Hadi Bharara <hadi@exprtec.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: gmb,google-maps,lead-generation,playwright,scraper
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: playwright>=1.49.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # gmbscraper
25
+
26
+ A typed, importable Playwright scraper for Google Maps / Google Business Profile data:
27
+ place search, place details, identity-based matching, review scraping, and
28
+ parallel fan-out helpers.
29
+
30
+ - Search Google Maps and scrape place details (name, category, address, phone,
31
+ website, rating, review count) without the official (paid, rate-limited)
32
+ Places API.
33
+ - Adaptive quadrant search that recursively splits the map viewport to pull
34
+ results beyond Google's ~120-per-query cap.
35
+ - Confidence-tiered identity matching (`phone_and_domain_match` >
36
+ `phone_match` > `domain_match` > `exact_name_match`) for matching a known
37
+ business/outlet against scraped candidates.
38
+ - Review scraping with owner-response filtering, "see more" text expansion,
39
+ and multilingual review-tab detection.
40
+ - Thread-based fan-out helpers (`chunk_items`, `run_chunked`) for running
41
+ multiple browser workers over a batch of items.
42
+
43
+ > **Status:** pre-1.0 (`0.x`). Extracted from duplicated scraping code across
44
+ > several internal projects; the API may still move before `1.0.0`.
45
+
46
+ Using this from another project (agent or human)? Read [`skills/SKILL.md`](skills/SKILL.md) first — it covers the API surface, matching semantics, parallel fan-out, and how to add this as a dependency before PyPI publication.
47
+
48
+ ## Install
49
+
50
+ Not yet published to PyPI. Add it as a `uv` path dependency — see [`skills/SKILL.md`](skills/SKILL.md#installing-in-a-consuming-project) for the exact `pyproject.toml` snippet — then:
51
+
52
+ ```bash
53
+ uv run playwright install --with-deps chromium
54
+ ```
55
+
56
+ ## Quickstart
57
+
58
+ ```python
59
+ from gmbscraper import BusinessIdentity, OutletIdentity, google_maps_browser
60
+
61
+ with google_maps_browser() as maps:
62
+ # Search + adaptive pagination
63
+ links = maps.search_places_adaptive("plumbers", latitude=-33.87, longitude=151.21)
64
+
65
+ # Scrape a single place page
66
+ place = maps.scrape_place(links[0].maps_url, fallback_name=links[0].fallback_name)
67
+ print(place.name, place.rating, place.review_count)
68
+
69
+ # Find the Google Business Profile for a known business
70
+ match = maps.search_and_match(
71
+ OutletIdentity(name="Acme Plumbing", phone="0400 000 000", address="1 Main St, Sydney"),
72
+ BusinessIdentity(name="Acme Plumbing Pty Ltd", website="acmeplumbing.com.au"),
73
+ )
74
+ if match:
75
+ print(match.match_reason, match.place_id)
76
+
77
+ # Scrape reviews
78
+ reviews = maps.scrape_reviews(place.maps_url, max_reviews=50)
79
+ ```
80
+
81
+ ## Design notes
82
+
83
+ - Every network-facing function swallows and skips broken selectors rather
84
+ than raising — a `PlaceProfile` is returned with whatever fields could be
85
+ read, since Google's DOM/markup changes without notice and partial data is
86
+ usually more useful than a hard failure. `scrape_reviews` is the exception:
87
+ it raises `GoogleReviewsUnavailableError` when Google serves a page with no
88
+ reviews UI at all (throttled/limited view, or the reviews tab never opens),
89
+ since a caller needs to distinguish "zero reviews" from "couldn't scrape."
90
+ - There's no retry/backoff or proxy rotation built in. This is a DOM scraper,
91
+ not a hosted anti-block service — for scraping at a scale where blocking is
92
+ routine, a paid API (Outscraper, SerpApi, etc.) will be cheaper than
93
+ building that infrastructure yourself.
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ uv sync
99
+ uv run pytest
100
+ uv run ruff check .
101
+ uv run ruff format --check .
102
+ ```
103
+
104
+ See [`skills/references/development.md`](skills/references/development.md) for what is and isn't covered by the test suite.
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,85 @@
1
+ # gmbscraper
2
+
3
+ A typed, importable Playwright scraper for Google Maps / Google Business Profile data:
4
+ place search, place details, identity-based matching, review scraping, and
5
+ parallel fan-out helpers.
6
+
7
+ - Search Google Maps and scrape place details (name, category, address, phone,
8
+ website, rating, review count) without the official (paid, rate-limited)
9
+ Places API.
10
+ - Adaptive quadrant search that recursively splits the map viewport to pull
11
+ results beyond Google's ~120-per-query cap.
12
+ - Confidence-tiered identity matching (`phone_and_domain_match` >
13
+ `phone_match` > `domain_match` > `exact_name_match`) for matching a known
14
+ business/outlet against scraped candidates.
15
+ - Review scraping with owner-response filtering, "see more" text expansion,
16
+ and multilingual review-tab detection.
17
+ - Thread-based fan-out helpers (`chunk_items`, `run_chunked`) for running
18
+ multiple browser workers over a batch of items.
19
+
20
+ > **Status:** pre-1.0 (`0.x`). Extracted from duplicated scraping code across
21
+ > several internal projects; the API may still move before `1.0.0`.
22
+
23
+ Using this from another project (agent or human)? Read [`skills/SKILL.md`](skills/SKILL.md) first — it covers the API surface, matching semantics, parallel fan-out, and how to add this as a dependency before PyPI publication.
24
+
25
+ ## Install
26
+
27
+ Not yet published to PyPI. Add it as a `uv` path dependency — see [`skills/SKILL.md`](skills/SKILL.md#installing-in-a-consuming-project) for the exact `pyproject.toml` snippet — then:
28
+
29
+ ```bash
30
+ uv run playwright install --with-deps chromium
31
+ ```
32
+
33
+ ## Quickstart
34
+
35
+ ```python
36
+ from gmbscraper import BusinessIdentity, OutletIdentity, google_maps_browser
37
+
38
+ with google_maps_browser() as maps:
39
+ # Search + adaptive pagination
40
+ links = maps.search_places_adaptive("plumbers", latitude=-33.87, longitude=151.21)
41
+
42
+ # Scrape a single place page
43
+ place = maps.scrape_place(links[0].maps_url, fallback_name=links[0].fallback_name)
44
+ print(place.name, place.rating, place.review_count)
45
+
46
+ # Find the Google Business Profile for a known business
47
+ match = maps.search_and_match(
48
+ OutletIdentity(name="Acme Plumbing", phone="0400 000 000", address="1 Main St, Sydney"),
49
+ BusinessIdentity(name="Acme Plumbing Pty Ltd", website="acmeplumbing.com.au"),
50
+ )
51
+ if match:
52
+ print(match.match_reason, match.place_id)
53
+
54
+ # Scrape reviews
55
+ reviews = maps.scrape_reviews(place.maps_url, max_reviews=50)
56
+ ```
57
+
58
+ ## Design notes
59
+
60
+ - Every network-facing function swallows and skips broken selectors rather
61
+ than raising — a `PlaceProfile` is returned with whatever fields could be
62
+ read, since Google's DOM/markup changes without notice and partial data is
63
+ usually more useful than a hard failure. `scrape_reviews` is the exception:
64
+ it raises `GoogleReviewsUnavailableError` when Google serves a page with no
65
+ reviews UI at all (throttled/limited view, or the reviews tab never opens),
66
+ since a caller needs to distinguish "zero reviews" from "couldn't scrape."
67
+ - There's no retry/backoff or proxy rotation built in. This is a DOM scraper,
68
+ not a hosted anti-block service — for scraping at a scale where blocking is
69
+ routine, a paid API (Outscraper, SerpApi, etc.) will be cheaper than
70
+ building that infrastructure yourself.
71
+
72
+ ## Development
73
+
74
+ ```bash
75
+ uv sync
76
+ uv run pytest
77
+ uv run ruff check .
78
+ uv run ruff format --check .
79
+ ```
80
+
81
+ See [`skills/references/development.md`](skills/references/development.md) for what is and isn't covered by the test suite.
82
+
83
+ ## License
84
+
85
+ MIT
@@ -0,0 +1,59 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "gmbscraper"
7
+ dynamic = ["version"]
8
+ description = "Playwright-based scraper for Google Maps / Google Business Profile listings and reviews"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Abdul Hadi Bharara", email = "hadi@exprtec.com" },
14
+ ]
15
+ keywords = ["google-maps", "gmb", "scraper", "playwright", "lead-generation"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = [
27
+ "playwright>=1.49.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/exprtec/gmbscraper-python"
32
+ Repository = "https://github.com/exprtec/gmbscraper-python"
33
+ Issues = "https://github.com/exprtec/gmbscraper-python/issues"
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "ruff>=0.8",
38
+ "pytest>=8.0",
39
+ ]
40
+
41
+ [tool.hatch.version]
42
+ path = "src/gmbscraper/_version.py"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/gmbscraper"]
46
+
47
+ [tool.ruff]
48
+ line-length = 100
49
+ target-version = "py312"
50
+
51
+ [tool.ruff.lint]
52
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
53
+ ignore = ["E501"]
54
+
55
+ [tool.ruff.lint.isort]
56
+ known-first-party = ["gmbscraper"]
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
@@ -0,0 +1,91 @@
1
+ ---
2
+ name: gmbscraper
3
+ description: Google Maps / Google Business Profile scraping via the `gmbscraper` package. Use when writing or reviewing code that searches Google Maps for places, scrapes a place's details, matches a known business/outlet against scraped candidates, or scrapes Google Maps reviews — in this repo or any project that depends on `gmbscraper` (e.g. reviewMonster, automation) — and when modifying gmbscraper's own scraper, matching, parsing, or review internals.
4
+ ---
5
+
6
+ # gmbscraper
7
+
8
+ Playwright-based scraper for Google Maps / Google Business Profile data. No official API key, no Places API quota — it drives a real (stealth-configured) Chromium browser against maps.google.com.
9
+
10
+ ## Quick Reference
11
+
12
+ * Not on PyPI yet. Add it as a path (or git) dependency; see [Installing In A Consuming Project](#installing-in-a-consuming-project) below.
13
+ * Everything flows through one object: `with google_maps_browser() as scraper:` yields a `GoogleMapsScraper`. Open one per thread if you fan out — see [Parallel Fan-out](references/parallel-fanout.md).
14
+ * Search: `scraper.search_places_adaptive(query, latitude=.., longitude=..)` → `list[MapsSearchLink]`. Handles Google's ~120-results-per-query cap by splitting the map viewport automatically; safe to use even when you don't expect a saturated result set.
15
+ * Place detail: `scraper.scrape_place(url, fallback_name=.., rank=..)` → `PlaceProfile`. Never raises — a selector miss just leaves that field blank, so check for empty strings, not exceptions.
16
+ * Identity matching: `scraper.search_and_match(outlet, business)` → `PlaceProfile | None`, scored by phone/domain/name confidence. See [Matching](references/matching.md) before hand-rolling any similarity check.
17
+ * Reviews: `scraper.scrape_reviews(maps_url)` → `list[MapsReview]`. Unlimited by default (`max_reviews=None`) — this is the one call that raises: `GoogleReviewsUnavailableError` when there's no reviews UI to open at all (blocked/limited view, or truly zero reviews).
18
+ * Pass `locale=` to `google_maps_browser` matching the business's country (`"en-AU"`, `"en-NZ"`, ...) — it changes the address/phone formatting Google renders, which downstream parsing assumes.
19
+ * No retry/backoff/proxy rotation is built in. This is a DOM scraper, not an anti-block service — see [Limits](#limits).
20
+ * Package development (tests, lint, versioning): see [Development](references/development.md).
21
+
22
+ ## Search, Then Scrape
23
+
24
+ ```python
25
+ from gmbscraper import google_maps_browser
26
+
27
+ with google_maps_browser(locale="en-AU") as scraper:
28
+ links = scraper.search_places_adaptive("plumbers", latitude=-33.87, longitude=151.21)
29
+ for link in links:
30
+ place = scraper.scrape_place(link.maps_url, fallback_name=link.fallback_name)
31
+ print(place.name, place.rating, place.review_count)
32
+ ```
33
+
34
+ `MapsSearchLink` has `maps_url`, `fallback_name`, and `search_rank` (`None` for links found via viewport-splitting rather than the original query). Dedup on `maps_url` — adaptive search can revisit the same place across overlapping quadrants.
35
+
36
+ ## Matching A Known Business
37
+
38
+ Use this instead of writing your own name-similarity check — a plain fuzzy-name match produces false positives across chains and franchises. `search_and_match` needs at least one strong identity signal (phone, website, or an exact name) to return anything:
39
+
40
+ ```python
41
+ from gmbscraper import BusinessIdentity, OutletIdentity, google_maps_browser
42
+
43
+ with google_maps_browser() as scraper:
44
+ place = scraper.search_and_match(
45
+ OutletIdentity(name="Acme Plumbing", phone="0400 000 000", address="1 Main St, Sydney"),
46
+ BusinessIdentity(name="Acme Plumbing Pty Ltd", website="acmeplumbing.com.au"),
47
+ )
48
+ if place is not None:
49
+ print(place.match_reason) # e.g. "phone_and_domain_match"
50
+ ```
51
+
52
+ `place is None` means no candidate cleared the confidence bar — treat it as "no match," not as an error. Full tier breakdown and the standalone matching functions (for scoring your own candidate lists) are in [Matching](references/matching.md).
53
+
54
+ ## Scraping Reviews
55
+
56
+ ```python
57
+ from gmbscraper import GoogleReviewsUnavailableError, google_maps_browser
58
+
59
+ with google_maps_browser() as scraper:
60
+ try:
61
+ reviews = scraper.scrape_reviews(place.maps_url)
62
+ except GoogleReviewsUnavailableError:
63
+ reviews = []
64
+ ```
65
+
66
+ Each `MapsReview` has `stars`, `username`, `text`, `date_text` (raw), `date` (resolved ISO string, `""` if unparseable), and `external_id` — call `.resolved_external_id()` instead of reading `.external_id` directly when you need a stable dedup key, since Google omits the id on some cards and the method falls back to a content hash.
67
+
68
+ Pass `max_reviews=N` to stop early once you have enough; omitting it scrapes everything reachable (bounded by an idle-scroll cutoff, not a hard count).
69
+
70
+ ## Installing In A Consuming Project
71
+
72
+ Not published to PyPI yet, so add it as a `uv` path source rather than a version pin. From a project two directories under `Documents/Work/<project>/<subdir>` (matches both `automation/worker` and `reviewMonster/backend`):
73
+
74
+ ```toml
75
+ # pyproject.toml
76
+ [project]
77
+ dependencies = ["gmbscraper"]
78
+
79
+ [tool.uv.sources]
80
+ gmbscraper = { path = "../../../open-source/google-maps-python" }
81
+ ```
82
+
83
+ Then `uv sync`. Adjust the relative path if your project sits at a different depth. `automation/worker/pyproject.toml` has a working example to copy from.
84
+
85
+ If your project also needs Playwright's browser binary (it almost certainly does, since gmbscraper only declares the `playwright` Python package): `uv run playwright install --with-deps chromium`. Check whether the target repo's Dockerfile already runs this for its own Playwright usage before adding a second install step.
86
+
87
+ ## Limits
88
+
89
+ * No retry/backoff and no proxy rotation. Google will eventually throttle a browser that scrapes hard enough from one IP; `is_limited_maps_view`-style detection exists only inside review scraping (surfaced as `GoogleReviewsUnavailableError`), not on the search/place-detail path — a throttled search silently returns thin/empty results rather than raising. If you need resilience against sustained blocking at scale, that's a paid API's job (Outscraper, SerpApi), not this package's.
90
+ * Name/phone/domain normalization (`normalize_name`, `normalize_phone`, `normalize_domain`) is AU/NZ-tuned: it strips `pty`/`ltd`, AU/NZ multi-part TLD suffixes (`com.au`, `co.nz`, ...), and converts phone numbers to a local-format suffix assuming AU-style `+61` numbers. Matching businesses outside AU/NZ will work but with weaker name normalization.
91
+ * `match_place` mutates the `PlaceProfile` it's given (sets `.match_reason`) rather than returning a copy — don't reuse the same `PlaceProfile` instance across multiple `match_place` calls against different identities, or the reason from an earlier failed match can leak into a later check.
@@ -0,0 +1,28 @@
1
+ # Development
2
+
3
+ Working on `gmbscraper` itself (not just consuming it).
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ uv sync
9
+ uv run playwright install --with-deps chromium # only needed to exercise real scraping, not for the unit tests below
10
+ ```
11
+
12
+ ## Validation
13
+
14
+ ```bash
15
+ uv run pytest
16
+ uv run ruff check .
17
+ uv run ruff format --check .
18
+ ```
19
+
20
+ The test suite covers `parsing.py`, `_matching.py`, and `_parallel.py` only — everything that touches a real `Page`/`BrowserContext` (`_page.py`, `_places.py`, `_adaptive_search.py`, `_reviews.py`, `_scraper.py`) has no automated coverage and is verified by running it against live Google Maps. Keep new logic in the parsing/matching/parallel modules pure and unit-testable where possible; push anything that must touch a `page` object into the browser-facing modules, which stay manually verified.
21
+
22
+ ## Versioning
23
+
24
+ `src/gmbscraper/_version.py` is the single source of truth (`__version__ = "x.y.z"`), read by both the package's own `__init__.py` and `pyproject.toml`'s `[tool.hatch.version]`. Bump it there — nowhere else.
25
+
26
+ ## Not Yet Published
27
+
28
+ There's no PyPI release and no publish workflow yet. Consuming projects use a `tool.uv.sources` path dependency (see the main [SKILL.md](../SKILL.md#installing-in-a-consuming-project)). If this gets published later, mirror `instantly-python-sdk`'s pattern (`instantlyai` on PyPI, `scripts/release.sh`, version-pinned dependency in consumers) rather than inventing a new one.
@@ -0,0 +1,55 @@
1
+ # Matching
2
+
3
+ How `search_and_match` decides a scraped Google Maps place is "the same business" as one of your own records, and how to use the scoring functions directly if you're matching against a candidate list you already have (e.g. from your own `search_places_adaptive` results) instead of letting `search_and_match` search for you.
4
+
5
+ ## The Two Identity Types
6
+
7
+ * `BusinessIdentity(name, website=None)` — the business as your system knows it (a company/provider record). One `BusinessIdentity` typically covers many outlets.
8
+ * `OutletIdentity(name, phone=None, address=None)` — the specific location being matched. `name` here is usually the outlet/branch name, not the parent business name — pass the parent name too (as `BusinessIdentity.name`) since Google Business Profile listings are sometimes titled with the outlet name and sometimes the parent brand.
9
+
10
+ Both are plain dataclasses — construct them fresh per match attempt, don't share instances.
11
+
12
+ ## Confidence Tiers
13
+
14
+ `match_place(place, outlet, business)` checks, in order of strength:
15
+
16
+ 1. `phone_and_domain_match` — normalized phone AND normalized domain both match.
17
+ 2. `phone_match` — phone matches, domain doesn't (or business has no website on record).
18
+ 3. `domain_match` — domain matches, phone doesn't.
19
+ 4. `exact_name_match` — no phone/domain signal, but the normalized place name exactly equals the normalized outlet or business name.
20
+
21
+ If none of the four fire, `match_place` returns `None` — there is no "weak match" tier below `exact_name_match`. A fuzzy/partial name match is not enough on its own; that's deliberate, since a partial-name-only match rate is where false positives across chains and franchises come from.
22
+
23
+ `is_definitive_match(place)` is `True` for any of the four reasons — used internally by `search_and_match` to stop scraping further candidates as soon as it finds one, rather than checking every result on the page.
24
+
25
+ ## Gate Before You Scrape
26
+
27
+ `has_match_key(outlet, business)` tells you up front whether there's *any* chance of a match — phone, website, or a name to compare. If it's `False`, don't bother searching/scraping candidates at all; there's nothing to match against and you'll only burn browser time for a guaranteed `None`. `search_and_match` does not call this gate itself (it assumes you've already decided the record is worth attempting), so callers doing bulk enrichment should check it themselves before queuing a browser task:
28
+
29
+ ```python
30
+ from gmbscraper import BusinessIdentity, OutletIdentity, has_match_key
31
+
32
+ if not has_match_key(outlet, business):
33
+ continue # nothing to match on — skip without touching the browser
34
+ ```
35
+
36
+ ## Scoring Your Own Candidate List
37
+
38
+ If you already have a list of scraped `PlaceProfile` candidates (e.g. you called `scrape_place` yourself in a loop) and just need to pick the best one, skip `search_and_match` and use the scoring functions directly:
39
+
40
+ ```python
41
+ from gmbscraper import match_place, pick_best_match
42
+
43
+ scored = [
44
+ matched for place in candidates if (matched := match_place(place, outlet, business)) is not None
45
+ ]
46
+ best = pick_best_match(scored) # None if scored is empty
47
+ ```
48
+
49
+ `pick_best_match` sorts by confidence tier first, then by `PlaceProfile.rank` (position in your candidate list) as a tiebreaker — pass the `rank` you scraped each candidate at (`scrape_place(url, rank=i)`) so ties resolve toward earlier/more relevant search results.
50
+
51
+ ## Normalization Gotchas
52
+
53
+ * `normalize_phone` assumes AU-style numbers: it strips a `+61`/`0011 61` prefix down to a local `0`-leading number, then compares the last 9 digits. Phone matching against non-AU/NZ numbers may under-match.
54
+ * `normalize_domain` unwraps Google's `google.com/url?q=...` redirect links before comparing, and treats a fixed list of AU/NZ multi-part suffixes (`com.au`, `co.nz`, `org.au`, ...) as part of the registrable domain rather than stripping them like an ordinary two-part TLD.
55
+ * `normalize_name` strips legal suffixes (`pty`, `ltd`, `inc`, ...) and a fixed stop-word list (`the`, `group`, `services`, `co`, ...) before comparing — two differently-branded businesses that both reduce to the same stripped name (e.g. two outlets both named "The Community Group") would collide on `exact_name_match`. Combine name matching with phone/domain whenever you have them; don't rely on name alone for anything you can't afford to get wrong.
@@ -0,0 +1,46 @@
1
+ # Parallel Fan-out
2
+
3
+ `chunk_items` and `run_chunked` split a batch of work across threads, each running its own browser. Reach for these when you have more than a handful of places/leads/reviews to process in one job and want to run several browsers concurrently instead of one after another.
4
+
5
+ ## One Browser Per Thread — Not One Shared Browser
6
+
7
+ Playwright's *sync* API (what `google_maps_browser` uses) is not thread-safe. Each worker thread must open its own `google_maps_browser()` context — never pass a single `GoogleMapsScraper` into multiple threads. `run_chunked` hands each worker a *chunk of items*, not a shared scraper, specifically so each worker opens its own browser internally:
8
+
9
+ ```python
10
+ from gmbscraper import google_maps_browser, run_chunked
11
+
12
+
13
+ def worker(chunk: list[MyItem], worker_id: int) -> MyStats:
14
+ stats = MyStats()
15
+ with google_maps_browser() as scraper: # one browser per worker thread
16
+ for item in chunk:
17
+ ... # scraper.scrape_place(...) / .search_and_match(...) / .scrape_reviews(...)
18
+ return stats
19
+
20
+
21
+ final = run_chunked(items, workers=4, empty_stats=MyStats, worker=worker)
22
+ ```
23
+
24
+ If `workers >= len(items)` (or `workers=1`), `run_chunked` runs inline on the calling thread without spawning an executor at all — safe to always pass a `--workers` CLI flag through even when it's 1.
25
+
26
+ ## Additive Stats
27
+
28
+ `worker` must return an object satisfying the `AdditiveStats` protocol — just an `add(self, other) -> None` method that merges another instance's counts into itself. `run_chunked` calls `empty_stats()` once up front and `.add()` once per completed worker:
29
+
30
+ ```python
31
+ from dataclasses import dataclass
32
+
33
+
34
+ @dataclass
35
+ class MyStats:
36
+ matched: int = 0
37
+ errors: int = 0
38
+
39
+ def add(self, other: "MyStats") -> None:
40
+ self.matched += other.matched
41
+ self.errors += other.errors
42
+ ```
43
+
44
+ ## Chunking Only
45
+
46
+ If you want the even split without the threading (e.g. you're managing your own executor, or persisting through a DB session per chunk the way `automation`'s NDIS jobs do), call `chunk_items(items, workers)` directly — it returns `workers` lists (fewer if `len(items) < workers`), round-robin distributed so no chunk is more than one item larger than another.
@@ -0,0 +1,39 @@
1
+ """Playwright-based scraper for Google Maps / Google Business Profile listings and reviews."""
2
+
3
+ from gmbscraper._matching import has_match_key, is_definitive_match, match_place, pick_best_match
4
+ from gmbscraper._parallel import chunk_items, run_chunked
5
+ from gmbscraper._reviews import GoogleReviewsUnavailableError, scrape_reviews
6
+ from gmbscraper._scraper import GoogleMapsScraper, google_maps_browser
7
+ from gmbscraper._version import __version__
8
+ from gmbscraper.models import (
9
+ BusinessIdentity,
10
+ MapsReview,
11
+ MapsSearchLink,
12
+ MapsView,
13
+ OutletIdentity,
14
+ PlaceProfile,
15
+ SearchWindow,
16
+ )
17
+ from gmbscraper.parsing import normalize_text
18
+
19
+ __all__ = [
20
+ "BusinessIdentity",
21
+ "GoogleMapsScraper",
22
+ "GoogleReviewsUnavailableError",
23
+ "MapsReview",
24
+ "MapsSearchLink",
25
+ "MapsView",
26
+ "OutletIdentity",
27
+ "PlaceProfile",
28
+ "SearchWindow",
29
+ "__version__",
30
+ "chunk_items",
31
+ "google_maps_browser",
32
+ "has_match_key",
33
+ "is_definitive_match",
34
+ "match_place",
35
+ "normalize_text",
36
+ "pick_best_match",
37
+ "run_chunked",
38
+ "scrape_reviews",
39
+ ]