From aba2b9fbe098887e9b9c17329184269cfee8ee75 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 31 Jul 2026 10:10:38 +0200 Subject: [PATCH 1/2] fix: handle missing metadata.container key across all Person parser fields Google's People API now sometimes omits metadata.container on email, name, profileInfo, sourceIds, coverPhoto, and inAppReachability entries, which crashed ghunt/parsers/people.py with KeyError: 'container' (e.g. `ghunt email
`). Also guards the cover_photo imageUrl lookup against a missing field. Master already patched the coverPhoto case alone (11b845b); this extends the same defensive handling to the other fields, matching the approach proposed in unmerged upstream PR mxrch/GHunt#593. --- README.md | 9 +++++++++ ghunt/parsers/people.py | 25 ++++++++++++++++--------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9999ce9d..0f9e74d9 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,15 @@
+> **This is `ghunt-fixed`, a fork of [mxrch/GHunt](https://github.com/mxrch/GHunt), modified on 2026-07-31.** +> The upstream PyPI release (2.3.4) crashes with `KeyError: 'container'` on some accounts because +> Google's People API responses now sometimes omit `metadata.container`. This fork patches +> `ghunt/parsers/people.py` to handle that missing key gracefully instead of crashing +> (see [upstream PR #593](https://github.com/mxrch/GHunt/pull/593), not yet merged/released as of this fork). +> Still licensed under AGPLv3, same as upstream — see [LICENSE.md](LICENSE.md). + +
+ #### 🌐 GHunt Online version : https://osint.industries #### 🐍 Now Python 3.13 compatible ! diff --git a/ghunt/parsers/people.py b/ghunt/parsers/people.py index 43554121..f63c0d23 100644 --- a/ghunt/parsers/people.py +++ b/ghunt/parsers/people.py @@ -60,7 +60,9 @@ async def _scrape(self, as_client: httpx.AsyncClient, photo_data: Dict[str, any] self.isDefault, self.flathash = await is_default_profile_pic(as_client, self.url) elif photo_type == "cover_photo": - self.url = '='.join(photo_data.get("imageUrl").split("=")[:-1]) + image_url = photo_data.get("imageUrl", "") + if image_url: + self.url = '='.join(image_url.split("=")[:-1]) if (isDefault := photo_data.get("isDefault")): self.isDefault = isDefault else: @@ -107,8 +109,8 @@ def __init__(self): def _scrape(self, apps_data, container_name: str): for app in apps_data: - if app["metadata"]["container"] == container_name: - self.apps.append(app["appType"].title()) + if app.get("metadata", {}).get("container") == container_name: + self.apps.append(app.get("appType", "").title()) class PersonContainers(dict): pass @@ -129,33 +131,37 @@ async def _scrape(self, as_client: httpx.AsyncClient, person_data: Dict[str, any self.personId = person_data.get("personId") if person_data.get("email"): for email_data in person_data["email"]: + container = email_data.get("metadata", {}).get("container", "unknown") person_email = PersonEmail() person_email._scrape(email_data) - self.emails[email_data["metadata"]["container"]] = person_email + self.emails[container] = person_email if person_data.get("name"): for name_data in person_data["name"]: + container = name_data.get("metadata", {}).get("container", "unknown") person_name = PersonName() person_name._scrape(name_data) - self.names[name_data["metadata"]["container"]] = person_name + self.names[container] = person_name if person_data.get("readOnlyProfileInfo"): for profile_data in person_data["readOnlyProfileInfo"]: + container = profile_data.get("metadata", {}).get("container", "unknown") person_profile = PersonProfileInfo() person_profile._scrape(profile_data) - self.profileInfos[profile_data["metadata"]["container"]] = person_profile + self.profileInfos[container] = person_profile if person_data.get("photo"): for photo_data in person_data["photo"]: person_photo = PersonPhoto() await person_photo._scrape(as_client, photo_data, "profile_photo") - self.profilePhotos[profile_data["metadata"]["container"]] = person_photo + self.profilePhotos[container] = person_photo if (source_ids := person_data.get("metadata", {}).get("identityInfo", {}).get("sourceIds")): for source_ids_data in source_ids: + container = source_ids_data.get("container", "unknown") person_source_ids = PersonSourceIds() person_source_ids._scrape(source_ids_data) - self.sourceIds[source_ids_data["container"]] = person_source_ids + self.sourceIds[container] = person_source_ids if person_data.get("coverPhoto"): for cover_photo_data in person_data["coverPhoto"]: @@ -167,7 +173,8 @@ async def _scrape(self, as_client: httpx.AsyncClient, person_data: Dict[str, any if (apps_data := person_data.get("inAppReachability")): containers_names = set() for app_data in person_data["inAppReachability"]: - containers_names.add(app_data["metadata"]["container"]) + if (container := app_data.get("metadata", {}).get("container")): + containers_names.add(container) for container_name in containers_names: person_app_reachability = PersonInAppReachability() From 714d5bd9f9f3b5d0c1b764dc5e273689addfb8d7 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 31 Jul 2026 10:15:56 +0200 Subject: [PATCH 2/2] fix: switch to skip-on-missing-container, fix profile photo mis-attribution - Use "skip this entry" instead of a synthetic "unknown" fallback key, per feedback on upstream mxrch/GHunt#593: falling back to "unknown" risks silently colliding/overwriting two different container-less entries under the same key. - Fold in the fix from upstream mxrch/GHunt#602: profile photos were looped inside readOnlyProfileInfo's loop and keyed by that loop's container instead of the photo's own metadata.container, so every photo got mis-attributed to whichever profile container the outer loop was on. Moved the photo loop out and keyed independently. --- ghunt/parsers/people.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/ghunt/parsers/people.py b/ghunt/parsers/people.py index f63c0d23..99143bb0 100644 --- a/ghunt/parsers/people.py +++ b/ghunt/parsers/people.py @@ -131,43 +131,54 @@ async def _scrape(self, as_client: httpx.AsyncClient, person_data: Dict[str, any self.personId = person_data.get("personId") if person_data.get("email"): for email_data in person_data["email"]: - container = email_data.get("metadata", {}).get("container", "unknown") + if not (container := email_data.get("metadata", {}).get("container")): + continue person_email = PersonEmail() person_email._scrape(email_data) self.emails[container] = person_email if person_data.get("name"): for name_data in person_data["name"]: - container = name_data.get("metadata", {}).get("container", "unknown") + if not (container := name_data.get("metadata", {}).get("container")): + continue person_name = PersonName() person_name._scrape(name_data) self.names[container] = person_name if person_data.get("readOnlyProfileInfo"): for profile_data in person_data["readOnlyProfileInfo"]: - container = profile_data.get("metadata", {}).get("container", "unknown") + if not (container := profile_data.get("metadata", {}).get("container")): + continue person_profile = PersonProfileInfo() person_profile._scrape(profile_data) self.profileInfos[container] = person_profile - if person_data.get("photo"): - for photo_data in person_data["photo"]: - person_photo = PersonPhoto() - await person_photo._scrape(as_client, photo_data, "profile_photo") - self.profilePhotos[container] = person_photo + # Each photo carries its own container in its metadata, independent of + # readOnlyProfileInfo's containers, so it's keyed separately rather than + # nested in the loop above (that previously mis-attributed every photo + # to whichever profile container happened to be current in the outer loop). + if person_data.get("photo"): + for photo_data in person_data["photo"]: + if not (container := photo_data.get("metadata", {}).get("container")): + continue + person_photo = PersonPhoto() + await person_photo._scrape(as_client, photo_data, "profile_photo") + self.profilePhotos[container] = person_photo if (source_ids := person_data.get("metadata", {}).get("identityInfo", {}).get("sourceIds")): for source_ids_data in source_ids: - container = source_ids_data.get("container", "unknown") + if not (container := source_ids_data.get("container")): + continue person_source_ids = PersonSourceIds() person_source_ids._scrape(source_ids_data) self.sourceIds[container] = person_source_ids if person_data.get("coverPhoto"): for cover_photo_data in person_data["coverPhoto"]: + if not (container := cover_photo_data.get("metadata", {}).get("container")): + continue person_cover_photo = PersonPhoto() await person_cover_photo._scrape(as_client, cover_photo_data, "cover_photo") - container = cover_photo_data.get("metadata", {}).get("container", "unknown") self.coverPhotos[container] = person_cover_photo if (apps_data := person_data.get("inAppReachability")):