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." ,
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" ,
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) {
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
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",
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 {
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
36413733def _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+
36773779def _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
42474356def _looks_like_ui_action_label (value : str ) -> bool :
0 commit comments