Skip to content

Commit 3d04c84

Browse files
authored
Emit CID aliases from parser
Squash merge PR #50 after review feedback fixes and passing checks.
1 parent dfa440f commit 3d04c84

3 files changed

Lines changed: 183 additions & 7 deletions

File tree

src/gmaps_scraper/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ class Place:
8282
maps_url: str
8383
cid: str | None = None
8484
google_id: str | None = None
85+
cid_aliases: list[str] = field(default_factory=list)
8586
is_favorite: bool = False
8687
added_by: ListOwner | None = None
8788

@@ -100,6 +101,8 @@ def to_dict(self) -> dict[str, object]:
100101
del result["note"]
101102
if self.cid is not None:
102103
result["cid"] = self.cid
104+
if self.cid_aliases:
105+
result["cid_aliases"] = self.cid_aliases
103106
if self.google_id is not None:
104107
result["google_id"] = self.google_id
105108
if self.added_by is not None:

src/gmaps_scraper/parser.py

Lines changed: 121 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ class _Candidate:
7171
signal_score: int
7272

7373

74+
@dataclass(frozen=True, slots=True)
75+
class _PlaceDedupeKeys:
76+
primary: set[str]
77+
aliases: set[str]
78+
79+
7480
class ParseError(RuntimeError):
7581
"""Raised when a saved list cannot be parsed from the supplied artifacts."""
7682

@@ -388,7 +394,8 @@ def _parse_list_owner(node: JSONValue | None) -> ListOwner | None:
388394

389395
def _extract_places(node: JSONValue) -> list[Place]:
390396
places: list[Place] = []
391-
seen: set[str] = set()
397+
primary_key_indexes: dict[str, int] = {}
398+
alias_key_indexes: dict[str, int] = {}
392399

393400
for current, ancestors in _walk_json(node):
394401
if not _is_coordinate_tuple(current):
@@ -405,6 +412,7 @@ def _extract_places(node: JSONValue) -> list[Place]:
405412
metadata_node = _find_place_metadata(ancestors)
406413
address = _extract_address(metadata_node)
407414
cid = _find_cid(metadata_node)
415+
cid_aliases = _find_cid_aliases(metadata_node)
408416
google_id = _find_google_id(metadata_node)
409417
name = _find_place_name(ancestors, address=address, place_record=place_record)
410418
note = _find_place_note(place_record, name=name, address=address)
@@ -422,23 +430,95 @@ def _extract_places(node: JSONValue) -> list[Place]:
422430
lng=lng,
423431
),
424432
cid=cid,
433+
cid_aliases=cid_aliases,
425434
google_id=google_id,
426435
is_favorite=is_favorite,
427436
added_by=_find_place_added_by(place_record),
428437
)
429-
dedupe_key = (
430-
google_id
431-
or (f"{cid}:{lat:.6f}:{lng:.6f}" if cid is not None else None)
432-
or f"{place.name}:{lat:.6f}:{lng:.6f}"
438+
dedupe_keys = _place_dedupe_keys(place)
439+
duplicate_index = _duplicate_place_index(
440+
dedupe_keys,
441+
primary_key_indexes=primary_key_indexes,
442+
alias_key_indexes=alias_key_indexes,
433443
)
434-
if dedupe_key in seen:
444+
if duplicate_index is not None:
445+
if _place_dedupe_quality(place) > _place_dedupe_quality(places[duplicate_index]):
446+
places[duplicate_index] = place
447+
_record_place_dedupe_keys(
448+
duplicate_index,
449+
dedupe_keys,
450+
primary_key_indexes=primary_key_indexes,
451+
alias_key_indexes=alias_key_indexes,
452+
)
435453
continue
436-
seen.add(dedupe_key)
437454
places.append(place)
455+
_record_place_dedupe_keys(
456+
len(places) - 1,
457+
dedupe_keys,
458+
primary_key_indexes=primary_key_indexes,
459+
alias_key_indexes=alias_key_indexes,
460+
)
438461

439462
return places
440463

441464

465+
def _place_dedupe_keys(place: Place) -> _PlaceDedupeKeys:
466+
primary: set[str] = set()
467+
aliases: set[str] = set()
468+
if place.google_id:
469+
primary.add(f"gid:{place.google_id}")
470+
if place.cid is not None:
471+
cid_suffix = f"{place.lat:.6f}:{place.lng:.6f}"
472+
primary.add(f"cid:{place.cid}:{cid_suffix}")
473+
aliases.update(
474+
f"cid:{cid_alias}:{cid_suffix}"
475+
for cid_alias in place.cid_aliases
476+
)
477+
if not primary:
478+
primary.add(f"name:{place.name}:{place.lat:.6f}:{place.lng:.6f}")
479+
return _PlaceDedupeKeys(primary=primary, aliases=aliases)
480+
481+
482+
def _duplicate_place_index(
483+
keys: _PlaceDedupeKeys,
484+
*,
485+
primary_key_indexes: dict[str, int],
486+
alias_key_indexes: dict[str, int],
487+
) -> int | None:
488+
for key in keys.primary:
489+
if key in primary_key_indexes:
490+
return primary_key_indexes[key]
491+
for key in keys.aliases:
492+
if key in primary_key_indexes:
493+
return primary_key_indexes[key]
494+
for key in keys.primary:
495+
if key in alias_key_indexes:
496+
return alias_key_indexes[key]
497+
return None
498+
499+
500+
def _record_place_dedupe_keys(
501+
index: int,
502+
keys: _PlaceDedupeKeys,
503+
*,
504+
primary_key_indexes: dict[str, int],
505+
alias_key_indexes: dict[str, int],
506+
) -> None:
507+
for key in keys.primary:
508+
primary_key_indexes[key] = index
509+
for key in keys.aliases:
510+
alias_key_indexes[key] = index
511+
512+
513+
def _place_dedupe_quality(place: Place) -> tuple[bool, bool, bool, bool]:
514+
return (
515+
place.google_id is not None,
516+
bool(place.cid_aliases),
517+
place.cid is not None,
518+
place.address is not None,
519+
)
520+
521+
442522
def _find_place_metadata(ancestors: Sequence[JSONValue]) -> list[JSONValue] | None:
443523
place_record = _find_place_record(ancestors)
444524
metadata_node = _place_metadata_from_record(place_record)
@@ -597,6 +677,19 @@ def _find_cid(node: list[JSONValue] | None) -> str | None:
597677
return None
598678

599679

680+
def _find_cid_aliases(node: list[JSONValue] | None) -> list[str]:
681+
if node is None:
682+
return []
683+
structured_value = _safe_index(node, 6)
684+
structured_cid = _find_cid_in_structured_slot(structured_value)
685+
if structured_cid is None:
686+
return []
687+
return _find_cid_aliases_in_structured_slot(
688+
structured_value,
689+
selected_cid=structured_cid,
690+
)
691+
692+
600693
def _find_google_id(node: list[JSONValue] | None) -> str | None:
601694
if node is None:
602695
return None
@@ -660,6 +753,27 @@ def _find_cid_in_structured_slot(value: JSONValue | None) -> str | None:
660753
return _normalize_cid_token(numeric_texts[0])
661754

662755

756+
def _find_cid_aliases_in_structured_slot(
757+
value: JSONValue | None,
758+
*,
759+
selected_cid: str,
760+
) -> list[str]:
761+
if not isinstance(value, list):
762+
return []
763+
owner = _parse_list_owner(value)
764+
if owner is not None and (owner.photo_url is not None or owner.profile_id is not None):
765+
return []
766+
aliases: list[str] = []
767+
for item in value:
768+
text = _clean_text(item)
769+
if text is None or _LONG_INTEGER_PATTERN.fullmatch(text) is None:
770+
continue
771+
alias = _normalize_cid_token(text)
772+
if alias is not None and alias != selected_cid and alias not in aliases:
773+
aliases.append(alias)
774+
return aliases
775+
776+
663777
def _find_cid_in_fallback_value(value: JSONValue | None) -> str | None:
664778
if isinstance(value, int | str):
665779
return _normalize_fallback_cid_token(value)

tests/test_parser.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ def test_uses_fingerprint_not_positive_s2_cell_from_slot_6(self) -> None:
255255
parsed = parse_saved_list_artifacts(_LIST_URL, runtime_state=runtime_state)
256256

257257
self.assertEqual(parsed.places[0].cid, _NORTHWIND_CID)
258+
self.assertEqual(parsed.places[0].cid_aliases, [_NORTHWIND_CELL_ID])
258259
self.assertNotEqual(parsed.places[0].cid, _NORTHWIND_CELL_ID)
259260

260261
def test_normalizes_negative_slot_6_fingerprint(self) -> None:
@@ -332,6 +333,64 @@ def test_dedupes_places_with_same_cid(self) -> None:
332333
self.assertEqual(parsed.places[0].cid, _NORTHWIND_CID)
333334
self.assertEqual(parsed.places[1].cid, _HARBOR_CID)
334335

336+
def test_dedupes_places_with_cid_alias(self) -> None:
337+
runtime_state = copy.deepcopy(["noise", _LIST_NODE])
338+
original_metadata = runtime_state[1][8][0][1]
339+
assert isinstance(original_metadata, list)
340+
original_metadata[7] = None
341+
duplicate_place = copy.deepcopy(runtime_state[1][8][0])
342+
duplicate_metadata = duplicate_place[1]
343+
assert isinstance(duplicate_metadata, list)
344+
duplicate_metadata[6] = [_NORTHWIND_CELL_ID]
345+
duplicate_metadata[7] = None
346+
runtime_state[1][8].append(duplicate_place)
347+
348+
parsed = parse_saved_list_artifacts(_LIST_URL, runtime_state=runtime_state)
349+
350+
self.assertEqual(len(parsed.places), 2)
351+
self.assertEqual(parsed.places[0].cid, _NORTHWIND_CID)
352+
self.assertEqual(parsed.places[1].cid, _HARBOR_CID)
353+
354+
def test_prefers_canonical_place_when_cid_alias_duplicate_appears_first(self) -> None:
355+
runtime_state = copy.deepcopy(["noise", _LIST_NODE])
356+
duplicate_place = copy.deepcopy(runtime_state[1][8][0])
357+
duplicate_metadata = duplicate_place[1]
358+
assert isinstance(duplicate_metadata, list)
359+
duplicate_metadata[6] = [_NORTHWIND_CELL_ID]
360+
duplicate_metadata[7] = None
361+
runtime_state[1][8].insert(0, duplicate_place)
362+
363+
parsed = parse_saved_list_artifacts(_LIST_URL, runtime_state=runtime_state)
364+
365+
self.assertEqual(len(parsed.places), 2)
366+
self.assertEqual(parsed.places[0].cid, _NORTHWIND_CID)
367+
self.assertEqual(parsed.places[0].cid_aliases, [_NORTHWIND_CELL_ID])
368+
self.assertEqual(parsed.places[0].google_id, "/g/11northwind")
369+
self.assertEqual(parsed.places[1].cid, _HARBOR_CID)
370+
371+
def test_keeps_distinct_places_with_same_cid_alias_and_coordinates(self) -> None:
372+
runtime_state = copy.deepcopy(["noise", _LIST_NODE])
373+
first_place = runtime_state[1][8][0]
374+
second_place = runtime_state[1][8][1]
375+
assert isinstance(first_place, list)
376+
assert isinstance(second_place, list)
377+
first_metadata = first_place[1]
378+
second_metadata = second_place[1]
379+
assert isinstance(first_metadata, list)
380+
assert isinstance(second_metadata, list)
381+
first_metadata[7] = None
382+
second_metadata[5] = [None, None, 35.6501307, 139.6868459]
383+
second_metadata[6] = [_NORTHWIND_CELL_ID, _HARBOR_CID]
384+
second_metadata[7] = None
385+
386+
parsed = parse_saved_list_artifacts(_LIST_URL, runtime_state=runtime_state)
387+
388+
self.assertEqual(len(parsed.places), 2)
389+
self.assertEqual(parsed.places[0].cid, _NORTHWIND_CID)
390+
self.assertEqual(parsed.places[1].cid, _HARBOR_CID)
391+
self.assertEqual(parsed.places[0].cid_aliases, [_NORTHWIND_CELL_ID])
392+
self.assertEqual(parsed.places[1].cid_aliases, [_NORTHWIND_CELL_ID])
393+
335394
def test_keeps_distinct_places_that_share_a_cid(self) -> None:
336395
runtime_state = copy.deepcopy(["noise", _LIST_NODE])
337396
second_place = runtime_state[1][8][1]

0 commit comments

Comments
 (0)