Skip to content

Commit 8a94e9c

Browse files
authored
Harden Google Maps place extraction (#37)
* Reject first-person review descriptions * Reject Google Maps UI action descriptions * Ignore owner payloads when extracting place CIDs * Allow numeric CID arrays while skipping owner payloads * Preserve search result panel text * Fix lint in panel text test * Ignore tab strips in description fallback
1 parent e2395e6 commit 8a94e9c

4 files changed

Lines changed: 351 additions & 59 deletions

File tree

src/gmaps_scraper/parser.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,9 @@ def _find_cid_in_value(value: JSONValue | None) -> str | None:
644644
return _normalize_cid_token(value)
645645
if not isinstance(value, list):
646646
return None
647+
owner = _parse_list_owner(value)
648+
if owner is not None and (owner.photo_url is not None or owner.profile_id is not None):
649+
return None
647650

648651
numeric_texts = [
649652
text

src/gmaps_scraper/place_scraper.py

Lines changed: 151 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,21 @@
9999
"share",
100100
"website",
101101
}
102+
_UI_ACTION_CLUSTER_LABEL_PATTERN = (
103+
r"(?:call|directions|save|saved|nearby|send to phone|share|website|sign in)"
104+
)
105+
_UI_ACTION_CLUSTER_RE = re.compile(
106+
rf"^{_UI_ACTION_CLUSTER_LABEL_PATTERN}(?:\s+{_UI_ACTION_CLUSTER_LABEL_PATTERN}){{2,}}$",
107+
re.IGNORECASE,
108+
)
102109
_DESCRIPTION_STOP_MARKERS = {
103110
"photos",
104111
"about this data",
105112
"sponsored",
106113
"write a review",
107114
"claim this business",
108115
"suggest an edit",
116+
"overview reviews about",
109117
"limited view of google maps",
110118
"get the most out of google maps",
111119
"our policies do not permit contributions to this type of place.",
@@ -136,8 +144,33 @@
136144
"i'm sorry to inform",
137145
)
138146
_DESCRIPTION_REVIEW_PROSE_MARKERS = (
147+
"best place to stay",
148+
"boy was it worth",
149+
"definitely recommend this place",
150+
"great experience overall",
151+
"had a great time",
152+
"hidden gem-literally",
139153
"highly recommended",
154+
"i forgot his name",
155+
"i'd just finished",
156+
"i've tasted",
157+
"i’d just finished",
158+
"i’ve tasted",
159+
"it was my first attempt",
160+
"my stay in",
161+
"once step in",
162+
"offered great recommendation",
163+
"omfg",
140164
"overrated",
165+
"so yummy",
166+
"the katsu burger",
167+
"the rooms were huge",
168+
"we have ever had",
169+
"we've ever had",
170+
"what a great hotel",
171+
"would recommend to everyone",
172+
"about this data",
173+
"get the most out of google maps",
141174
"your children",
142175
"your kids",
143176
"you should",
@@ -573,6 +606,68 @@
573606
}
574607
return null;
575608
};
609+
const elementTop = (element) => {
610+
const rect = element?.getBoundingClientRect?.();
611+
return rect && rect.height > 0 ? rect.top : null;
612+
};
613+
const elementBottom = (element) => {
614+
const rect = element?.getBoundingClientRect?.();
615+
return rect && rect.height > 0 ? rect.bottom : null;
616+
};
617+
const descriptionBoundaryTop = () => {
618+
const rows = Array.from(panel.querySelectorAll("[data-item-id]"))
619+
.map(elementTop)
620+
.filter((value) => value !== null);
621+
if (rows.length > 0) {
622+
return Math.min(...rows);
623+
}
624+
const addressRow = addressRowElement();
625+
const addressTop = elementTop(addressRow);
626+
return addressTop === null ? Infinity : addressTop;
627+
};
628+
const descriptionValue = () => {
629+
const direct = firstText([".WeS02d", ".PYvSYb"]);
630+
if (direct) {
631+
return direct;
632+
}
633+
const titleBottom = Math.max(
634+
...[
635+
elementBottom(titleElement),
636+
...Array.from(panel.querySelectorAll("div.F7nice")).map(elementBottom),
637+
].filter((value) => value !== null),
638+
0,
639+
);
640+
const boundaryTop = descriptionBoundaryTop();
641+
const candidates = [];
642+
for (const element of panel.querySelectorAll("div, span")) {
643+
const text = cleanLine(element.innerText || element.textContent || "");
644+
if (!text || text.includes("·")) {
645+
continue;
646+
}
647+
if (
648+
element.closest(
649+
"button, a, [role='button'], [role='tab'], [role='tablist'], "
650+
+ "[data-item-id], [data-review-id], div.F7nice",
651+
)
652+
) {
653+
continue;
654+
}
655+
if (
656+
Array.from(element.children).some(
657+
(child) => cleanLine(child.innerText || child.textContent || "") === text,
658+
)
659+
) {
660+
continue;
661+
}
662+
const top = elementTop(element);
663+
if (top === null || top <= titleBottom || top >= boundaryTop) {
664+
continue;
665+
}
666+
candidates.push({top, text});
667+
}
668+
candidates.sort((left, right) => left.top - right.top);
669+
return candidates[0]?.text || null;
670+
};
576671
577672
const normalizeCount = (value) => {
578673
if (!value) {
@@ -683,22 +778,19 @@
683778
}
684779
685780
const mainPhotoUrl = firstImageUrl([
781+
"div.RZ66Rb button[jsaction*='heroHeaderImage'] img",
686782
"button[jsaction*='heroHeaderImage'] img",
687-
"button[aria-label^='Photo of'] img",
688-
"button[aria-label^='写真'] img",
689-
"button[jsaction*='image'] img",
690-
"button[jsaction*='photo'] img",
783+
"div.ZKCDEc [data-photo-index='0'] img",
784+
"[data-photo-index='0'] img",
691785
"[data-photo-index] img",
692-
], document)
786+
])
693787
|| firstBackgroundImageUrl([
694-
"button[jsaction*='image']",
695-
"button[jsaction*='photo']",
788+
"div.RZ66Rb button[jsaction*='heroHeaderImage']",
789+
"button[jsaction*='heroHeaderImage']",
790+
"div.ZKCDEc [data-photo-index='0']",
791+
"[data-photo-index='0']",
696792
"[data-photo-index]",
697-
"[aria-label*='Photo']",
698-
"[aria-label*='photo']",
699-
"[aria-label*='写真']",
700-
"[aria-label*='画像']",
701-
], document);
793+
]);
702794
const photoUrl = mainPhotoUrl
703795
|| firstAttr(["meta[property='og:image']", "meta[itemprop='image']"], "content", document);
704796
@@ -1033,7 +1125,7 @@
10331125
"button[data-item-id^='phone:']",
10341126
]),
10351127
plus_code: itemValue("oloc"),
1036-
description: firstText([".WeS02d", ".PYvSYb"]),
1128+
description: descriptionValue(),
10371129
review_topics: collectReviewTopics(),
10381130
admission_prices: collectLeafPrices(sectionRootByHeading([
10391131
"Admission",
@@ -1324,24 +1416,33 @@
13241416
}
13251417
return {category: null, address: null};
13261418
};
1327-
const findDescriptionLine = (lines, excludedValues) => {
1419+
const cardDescription = (article, excludedValues) => {
13281420
const excluded = new Set(excludedValues.map(cleanLine).filter(Boolean));
1329-
return lines.find((line) => {
1330-
const text = cleanLine(line);
1421+
const rows = Array.from(article.querySelectorAll("div.W4Efsd"));
1422+
for (const row of rows) {
1423+
const text = cleanLine(row.innerText || row.textContent || "");
13311424
if (!text || excluded.has(text)) {
1332-
return false;
1425+
continue;
13331426
}
13341427
if (text.includes("·") || parseCardRating(text) || parseCardReviewCount(text)) {
1335-
return false;
1428+
continue;
1429+
}
1430+
if (
1431+
row.querySelector(
1432+
".AJB7ye, .UsdlK, [role='img'][aria-label*='star' i], a[href^='tel:']",
1433+
)
1434+
) {
1435+
continue;
13361436
}
13371437
if (/^(open|closed|temporarily closed|website|directions|saved in)\b/i.test(text)) {
1338-
return false;
1438+
continue;
13391439
}
13401440
if (/^[+()\d\s.-]{7,}$/.test(text)) {
1341-
return false;
1441+
continue;
13421442
}
1343-
return text.length >= 12;
1344-
}) || null;
1443+
return text;
1444+
}
1445+
return null;
13451446
};
13461447
const safeDecodeURIComponent = (value) => {
13471448
try {
@@ -1373,10 +1474,11 @@
13731474
review_count: reviewCount,
13741475
category: categoryAddress.category,
13751476
address: categoryAddress.address,
1376-
search_result_description: findDescriptionLine(
1377-
lines,
1477+
search_result_description: cardDescription(
1478+
article,
13781479
[name, categoryAddress.category, categoryAddress.address],
13791480
),
1481+
panel_text: lines.join("\n"),
13801482
body_text: lines.join("\n"),
13811483
};
13821484
};
@@ -2321,6 +2423,7 @@ def _search_result_snapshot(candidate: str | Mapping[str, object]) -> dict[str,
23212423
"category",
23222424
"address",
23232425
"search_result_description",
2426+
"panel_text",
23242427
"body_text",
23252428
):
23262429
value = candidate.get(key)
@@ -2390,9 +2493,8 @@ def _build_place_details(
23902493
snapshot: Mapping[str, object],
23912494
) -> PlaceDetails:
23922495
panel_lines = _body_lines(snapshot.get("panel_text"))
2393-
body_lines = _body_lines(snapshot.get("body_text"))
2394-
search_lines = panel_lines or body_lines
2395-
combined_lines = _dedupe_lines([*panel_lines, *body_lines])
2496+
search_lines = panel_lines
2497+
combined_lines = _dedupe_lines(panel_lines)
23962498
category = _clean_category_text(snapshot.get("category")) or _extract_category_from_lines(
23972499
search_lines
23982500
)
@@ -2484,7 +2586,7 @@ def _build_place_details(
24842586
plus_code=_clean_plus_code_text(snapshot.get("plus_code"))
24852587
or _extract_plus_code_from_lines(combined_lines),
24862588
address_parts=_extract_address_parts(snapshot.get("address_parts")),
2487-
description=_extract_description(snapshot, combined_lines),
2589+
description=_extract_description(snapshot),
24882590
search_result_description=_clean_description_text(
24892591
snapshot.get("search_result_description")
24902592
),
@@ -3624,18 +3726,8 @@ def _extract_plus_code_from_lines(lines: list[str]) -> str | None:
36243726
return None
36253727

36263728

3627-
def _extract_description(snapshot: Mapping[str, object], lines: list[str]) -> str | None:
3628-
direct = _clean_description_text(snapshot.get("description"))
3629-
if direct is not None:
3630-
return direct
3631-
for index, line in enumerate(lines):
3632-
if line.startswith("Seasonal ") or line.startswith("Modern setting "):
3633-
return line
3634-
if line == "Share" and index + 1 < len(lines):
3635-
candidate = _clean_description_text(lines[index + 1])
3636-
if candidate is not None and candidate.lower() not in _DESCRIPTION_STOP_MARKERS:
3637-
return candidate
3638-
return None
3729+
def _extract_description(snapshot: Mapping[str, object]) -> str | None:
3730+
return _clean_description_text(snapshot.get("description"))
36393731

36403732

36413733
def _clean_description_text(value: object) -> str | None:
@@ -3652,7 +3744,11 @@ def _clean_description_text(value: object) -> str | None:
36523744
return None
36533745
if _looks_like_status_text(normalized):
36543746
return None
3655-
if _looks_like_search_results_label(normalized) or _looks_like_ui_action_label(normalized):
3747+
if (
3748+
_looks_like_search_results_label(normalized)
3749+
or _looks_like_ui_action_label(normalized)
3750+
or _looks_like_ui_action_cluster(normalized)
3751+
):
36563752
return None
36573753
if (
36583754
_looks_like_description_review_prose(normalized)
@@ -3674,6 +3770,12 @@ def _clean_description_text(value: object) -> str | None:
36743770
return normalized
36753771

36763772

3773+
def _looks_like_ui_action_cluster(value: str) -> bool:
3774+
text = re.sub(r"[\ue000-\uf8ff]", " ", value)
3775+
text = re.sub(r"\s+", " ", text).strip(" .")
3776+
return _UI_ACTION_CLUSTER_RE.fullmatch(text) is not None
3777+
3778+
36773779
def _strip_description_service_options(value: str) -> str | None:
36783780
segments = [_clean_description_segment(part) for part in re.split(r"[·•⋅]+", value)]
36793781
cleaned_segments = [segment for segment in segments if segment]
@@ -3796,6 +3898,10 @@ def _normalize_photo_url(value: object) -> str | None:
37963898
"googleusercontent.com" in host or host.endswith("ggpht.com")
37973899
) and path.startswith(("/a-", "/a/")):
37983900
return None
3901+
if re.fullmatch(r"lh[0-9]+\.(?:googleusercontent\.com|ggpht\.com)", host) is None:
3902+
return None
3903+
if re.search(r"(?:=|-)w[0-9]+-h[0-9]+(?:-|$)", normalized) is None:
3904+
return None
37993905
return normalized
38003906

38013907

@@ -4241,7 +4347,10 @@ def _looks_like_search_results_label(value: str) -> bool:
42414347
normalized = _clean_text(value)
42424348
if normalized is None:
42434349
return False
4244-
return normalized.casefold() in _SEARCH_RESULTS_LABELS
4350+
normalized_lookup = normalized.casefold()
4351+
return normalized_lookup in _SEARCH_RESULTS_LABELS or normalized_lookup.startswith(
4352+
"sponsored "
4353+
)
42454354

42464355

42474356
def _looks_like_ui_action_label(value: str) -> bool:

tests/test_parser.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,26 @@ def test_does_not_use_owner_profile_id_as_place_cid(self) -> None:
350350
"https://www.google.com/maps/search/?api=1&query=Northwind+Cafe%2C+Example+District",
351351
)
352352

353+
def test_does_not_use_owner_payload_inside_metadata_as_place_cid(self) -> None:
354+
runtime_state = copy.deepcopy(["noise", _LIST_NODE])
355+
first_place = runtime_state[1][8][0]
356+
assert isinstance(first_place, list)
357+
first_metadata = first_place[1]
358+
assert isinstance(first_metadata, list)
359+
360+
first_metadata[6] = [
361+
"Fixture Owner",
362+
"https://lh3.googleusercontent.com/a-/fixture-owner",
363+
"104356373423434804635",
364+
]
365+
366+
parsed = parse_saved_list_artifacts(_LIST_URL, runtime_state=runtime_state)
367+
368+
self.assertEqual(len(parsed.places), 2)
369+
self.assertEqual(parsed.places[0].cid, None)
370+
self.assertEqual(parsed.places[0].google_id, "/g/11northwind")
371+
self.assertNotEqual(parsed.places[0].cid, "104356373423434804635")
372+
353373
def test_extracts_favorite_and_note_from_user_payload_shape(self) -> None:
354374
runtime_state = [
355375
"noise",

0 commit comments

Comments
 (0)