geosint 1.0.0__py3-none-any.whl
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.
- geosint/__init__.py +8 -0
- geosint/__main__.py +6 -0
- geosint/analysis.py +314 -0
- geosint/api_client.py +202 -0
- geosint/batch.py +69 -0
- geosint/bulk_extract.py +131 -0
- geosint/cache.py +92 -0
- geosint/cli.py +1115 -0
- geosint/config.py +235 -0
- geosint/db.py +970 -0
- geosint/dedupe.py +110 -0
- geosint/display.py +347 -0
- geosint/entities.py +303 -0
- geosint/exceptions.py +17 -0
- geosint/exporters.py +127 -0
- geosint/geo.py +147 -0
- geosint/osm_client.py +491 -0
- geosint/useragent.py +37 -0
- geosint/website_health.py +170 -0
- geosint/wizard.py +211 -0
- geosint-1.0.0.dist-info/METADATA +436 -0
- geosint-1.0.0.dist-info/RECORD +26 -0
- geosint-1.0.0.dist-info/WHEEL +5 -0
- geosint-1.0.0.dist-info/entry_points.txt +2 -0
- geosint-1.0.0.dist-info/licenses/LICENSE +21 -0
- geosint-1.0.0.dist-info/top_level.txt +1 -0
geosint/__init__.py
ADDED
geosint/__main__.py
ADDED
geosint/analysis.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Correlation across collected entities.
|
|
2
|
+
|
|
3
|
+
Everything else in this tool describes entities one at a time. This module
|
|
4
|
+
looks for the relationships BETWEEN them, which is where most of the
|
|
5
|
+
investigative value actually sits.
|
|
6
|
+
|
|
7
|
+
The core question: which entities that present as unrelated are in fact
|
|
8
|
+
connected? Four kinds of link are cheap to compute from data already
|
|
9
|
+
collected, and each has a plain-language meaning:
|
|
10
|
+
|
|
11
|
+
shared phone - two "different" organisations answering one number.
|
|
12
|
+
Common for genuine reasons (a shared reception, a
|
|
13
|
+
franchise call centre, a serviced office) and for
|
|
14
|
+
interesting ones (one operator behind several fronts).
|
|
15
|
+
shared domain - two entities publishing the same website host. Same
|
|
16
|
+
reading: often a chain, sometimes not.
|
|
17
|
+
operator/brand - an explicit self-declared affiliation in the map data.
|
|
18
|
+
The strongest link available because the source states it.
|
|
19
|
+
co-location - entities at, or within metres of, the same coordinates.
|
|
20
|
+
|
|
21
|
+
A link is a LEAD FOR AN ANALYST, never a conclusion. Every function here
|
|
22
|
+
returns the evidence and leaves the judgement to a person - the labels say
|
|
23
|
+
"shared", not "same owner", because the data supports the first and not
|
|
24
|
+
the second.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import math
|
|
30
|
+
import sqlite3
|
|
31
|
+
from collections import defaultdict
|
|
32
|
+
from contextlib import closing
|
|
33
|
+
from typing import Dict, List, Optional
|
|
34
|
+
|
|
35
|
+
from .geo import (
|
|
36
|
+
KM_PER_DEGREE_LAT,
|
|
37
|
+
KM_PER_DEGREE_LON_EQUATOR,
|
|
38
|
+
haversine_km,
|
|
39
|
+
parse_coord,
|
|
40
|
+
valid_point,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Short codes and emergency numbers legitimately serve many unrelated
|
|
44
|
+
# organisations, so a cluster built on one is noise. Matched EXACTLY, not
|
|
45
|
+
# as substrings: "999" appears inside a huge share of ordinary numbers
|
|
46
|
+
# (+1 555 019 9999 contains it), and substring matching silently dropped
|
|
47
|
+
# them from every correlation.
|
|
48
|
+
EMERGENCY_NUMBERS = frozenset({
|
|
49
|
+
"111", "112", "911", "999", "000", "119", "110", "115", "118", "122",
|
|
50
|
+
})
|
|
51
|
+
# Anything this short is a service or short code rather than a line
|
|
52
|
+
# belonging to one organisation.
|
|
53
|
+
MIN_REAL_PHONE_DIGITS = 6
|
|
54
|
+
|
|
55
|
+
# Above this many members, a "shared" value is almost certainly a shared
|
|
56
|
+
# service (a booking platform, a council switchboard) rather than a link
|
|
57
|
+
# between the entities themselves.
|
|
58
|
+
DEFAULT_MAX_CLUSTER = 25
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_generic_number(phone_norm: Optional[str]) -> bool:
|
|
62
|
+
"""True for short codes and emergency numbers, which link nothing."""
|
|
63
|
+
digits = (phone_norm or "").lstrip("+")
|
|
64
|
+
if not digits:
|
|
65
|
+
return False
|
|
66
|
+
return digits in EMERGENCY_NUMBERS or len(digits) < MIN_REAL_PHONE_DIGITS
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _key(row: dict) -> tuple:
|
|
70
|
+
"""An entity's identity: its source id within its case."""
|
|
71
|
+
return (row["entity_id"], row.get("case_name") or "")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _fetch(conn: sqlite3.Connection, case_name: Optional[str]) -> List[dict]:
|
|
75
|
+
sql = "SELECT * FROM entities"
|
|
76
|
+
params: list = []
|
|
77
|
+
if case_name:
|
|
78
|
+
sql += ' WHERE case_name = ?'
|
|
79
|
+
params.append(case_name)
|
|
80
|
+
with closing(conn.cursor()) as cur:
|
|
81
|
+
cur.execute(sql, params)
|
|
82
|
+
return [dict(r) for r in cur.fetchall()]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _cluster(
|
|
86
|
+
rows: List[dict], key_field: str, max_cluster: int
|
|
87
|
+
) -> List[dict]:
|
|
88
|
+
buckets: Dict[str, List[dict]] = defaultdict(list)
|
|
89
|
+
for row in rows:
|
|
90
|
+
key = (row.get(key_field) or "").strip()
|
|
91
|
+
if not key:
|
|
92
|
+
continue
|
|
93
|
+
buckets[key].append(row)
|
|
94
|
+
|
|
95
|
+
out = []
|
|
96
|
+
for key, members in buckets.items():
|
|
97
|
+
if len(members) < 2 or len(members) > max_cluster:
|
|
98
|
+
continue
|
|
99
|
+
out.append({
|
|
100
|
+
"key": key,
|
|
101
|
+
"count": len(members),
|
|
102
|
+
"members": sorted(members, key=lambda m: (m.get("name") or "")),
|
|
103
|
+
"names": sorted({m.get("name") or "(unnamed)" for m in members}),
|
|
104
|
+
})
|
|
105
|
+
return sorted(out, key=lambda c: -c["count"])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def shared_phones(
|
|
109
|
+
conn: sqlite3.Connection,
|
|
110
|
+
case_name: Optional[str] = None,
|
|
111
|
+
max_cluster: int = DEFAULT_MAX_CLUSTER,
|
|
112
|
+
) -> List[dict]:
|
|
113
|
+
rows = [r for r in _fetch(conn, case_name) if not is_generic_number(r.get("phone_norm"))]
|
|
114
|
+
clusters = _cluster(rows, "phone_norm", max_cluster)
|
|
115
|
+
# A single organisation mapped twice is a dedupe miss, not a link.
|
|
116
|
+
return [c for c in clusters if len(set(c["names"])) > 1]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def shared_domains(
|
|
120
|
+
conn: sqlite3.Connection,
|
|
121
|
+
case_name: Optional[str] = None,
|
|
122
|
+
max_cluster: int = DEFAULT_MAX_CLUSTER,
|
|
123
|
+
) -> List[dict]:
|
|
124
|
+
clusters = _cluster(_fetch(conn, case_name), "domain", max_cluster)
|
|
125
|
+
return [c for c in clusters if len(set(c["names"])) > 1]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def affiliations(
|
|
129
|
+
conn: sqlite3.Connection, case_name: Optional[str] = None
|
|
130
|
+
) -> List[dict]:
|
|
131
|
+
"""Operator/brand groupings - affiliation the source states outright.
|
|
132
|
+
|
|
133
|
+
Not capped like the coincidental links: a chain with 400 branches
|
|
134
|
+
genuinely is one affiliation, and saying so is the finding. Still
|
|
135
|
+
requires two distinct names, for the same reason shared_phones does -
|
|
136
|
+
one organisation mapped twice is a dedupe miss, not a link.
|
|
137
|
+
"""
|
|
138
|
+
rows = _fetch(conn, case_name)
|
|
139
|
+
out = []
|
|
140
|
+
for field in ("operator", "brand"):
|
|
141
|
+
for cluster in _cluster(rows, field, max_cluster=10_000):
|
|
142
|
+
if len(set(cluster["names"])) < 2:
|
|
143
|
+
continue
|
|
144
|
+
cluster["link"] = field
|
|
145
|
+
out.append(cluster)
|
|
146
|
+
return sorted(out, key=lambda c: -c["count"])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def co_located(
|
|
150
|
+
conn: sqlite3.Connection,
|
|
151
|
+
case_name: Optional[str] = None,
|
|
152
|
+
radius_m: float = 25.0,
|
|
153
|
+
max_cluster: int = DEFAULT_MAX_CLUSTER,
|
|
154
|
+
) -> List[dict]:
|
|
155
|
+
"""Entities within `radius_m` of each other.
|
|
156
|
+
|
|
157
|
+
Proximity is transitive for clustering purposes: if A is near B and B
|
|
158
|
+
is near C, all three belong to one cluster even when A and C are
|
|
159
|
+
further apart than the threshold. That makes this a connected-
|
|
160
|
+
components problem, so it uses union-find.
|
|
161
|
+
|
|
162
|
+
An earlier version tracked group membership in a dict and handled only
|
|
163
|
+
three of the four cases - both endpoints unassigned, and either one
|
|
164
|
+
unassigned - silently doing nothing when a linking pair joined two
|
|
165
|
+
groups that already existed. A continuous line of entities was then
|
|
166
|
+
reported as two or three separate clusters depending on the order
|
|
167
|
+
SQLite happened to return the rows in, which is the worst property a
|
|
168
|
+
correlation feature can have: the same data gives a different answer.
|
|
169
|
+
|
|
170
|
+
Cost: O(n) grid bucketing plus true distance only for candidate pairs
|
|
171
|
+
in adjacent cells. Fine for a case-sized set, and deliberately not
|
|
172
|
+
meant for a country-scale extract - filter by case for those.
|
|
173
|
+
"""
|
|
174
|
+
rows = [
|
|
175
|
+
r for r in _fetch(conn, case_name)
|
|
176
|
+
if valid_point(parse_coord(r.get("lat")), parse_coord(r.get("lon")))
|
|
177
|
+
]
|
|
178
|
+
if radius_m <= 0 or len(rows) < 2:
|
|
179
|
+
return []
|
|
180
|
+
radius_km = radius_m / 1000.0
|
|
181
|
+
|
|
182
|
+
# The grid only prunes candidate pairs, so it is correct exactly when a
|
|
183
|
+
# point within `radius_km` is guaranteed to land in the same cell or an
|
|
184
|
+
# adjacent one. That means the cell must be at least the radius wide in
|
|
185
|
+
# BOTH axes, in degrees:
|
|
186
|
+
#
|
|
187
|
+
# * latitude degrees are a constant ~110.574 km, so that side is easy.
|
|
188
|
+
# * longitude degrees shrink towards the poles, so a cell sized for
|
|
189
|
+
# the equator is far too narrow at high latitude. A fixed 0.01
|
|
190
|
+
# degrees - the previous value - is ~1.1 km at the equator but only
|
|
191
|
+
# ~2 m at 89.9 degrees, where a 25 m threshold would then need to
|
|
192
|
+
# reach thirteen cells and silently did not.
|
|
193
|
+
#
|
|
194
|
+
# Sizing the longitude cell for the highest latitude actually present
|
|
195
|
+
# keeps one uniform grid. Near the poles that makes cells very wide,
|
|
196
|
+
# which degrades to latitude-band bucketing - slower, still correct.
|
|
197
|
+
lats = [parse_coord(r["lat"]) for r in rows]
|
|
198
|
+
worst_lat = min(max(abs(v) for v in lats), 89.9)
|
|
199
|
+
lat_cell = max(radius_km / KM_PER_DEGREE_LAT, 1e-9)
|
|
200
|
+
lon_cell = min(
|
|
201
|
+
max(radius_km / (KM_PER_DEGREE_LON_EQUATOR *
|
|
202
|
+
math.cos(math.radians(worst_lat))), 1e-9),
|
|
203
|
+
360.0,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
grid: Dict[tuple, List[dict]] = defaultdict(list)
|
|
207
|
+
for r in rows:
|
|
208
|
+
lat, lon = parse_coord(r["lat"]), parse_coord(r["lon"])
|
|
209
|
+
# floor(), not int(): int() truncates towards zero, which makes the
|
|
210
|
+
# cells straddling the equator and the prime meridian twice as wide
|
|
211
|
+
# as every other cell.
|
|
212
|
+
grid[(math.floor(lat / lat_cell), math.floor(lon / lon_cell))].append(r)
|
|
213
|
+
|
|
214
|
+
# Keyed by (entity_id, case_name): with no case filter, the same place
|
|
215
|
+
# held by two cases is two rows, and must stay two nodes here.
|
|
216
|
+
parent: Dict[tuple, tuple] = {_key(r): _key(r) for r in rows}
|
|
217
|
+
|
|
218
|
+
def find(x: tuple) -> tuple:
|
|
219
|
+
while parent[x] != x:
|
|
220
|
+
parent[x] = parent[parent[x]] # path halving
|
|
221
|
+
x = parent[x]
|
|
222
|
+
return x
|
|
223
|
+
|
|
224
|
+
def union(x: tuple, y: tuple) -> None:
|
|
225
|
+
rx, ry = find(x), find(y)
|
|
226
|
+
if rx != ry:
|
|
227
|
+
parent[ry] = rx
|
|
228
|
+
|
|
229
|
+
# list() because grid is a defaultdict: .get() below must not be
|
|
230
|
+
# changed to grid[...], which would insert empty cells mid-iteration.
|
|
231
|
+
for (gy, gx), bucket in list(grid.items()):
|
|
232
|
+
neighbourhood: List[dict] = []
|
|
233
|
+
for dy in (-1, 0, 1):
|
|
234
|
+
for dx in (-1, 0, 1):
|
|
235
|
+
neighbourhood.extend(grid.get((gy + dy, gx + dx), []))
|
|
236
|
+
for a in bucket:
|
|
237
|
+
for b in neighbourhood:
|
|
238
|
+
if _key(a) >= _key(b):
|
|
239
|
+
continue # each unordered pair is considered once
|
|
240
|
+
if find(_key(a)) == find(_key(b)):
|
|
241
|
+
continue # already known to be connected
|
|
242
|
+
d = haversine_km(
|
|
243
|
+
parse_coord(a["lat"]), parse_coord(a["lon"]),
|
|
244
|
+
parse_coord(b["lat"]), parse_coord(b["lon"]),
|
|
245
|
+
)
|
|
246
|
+
if d <= radius_km:
|
|
247
|
+
union(_key(a), _key(b))
|
|
248
|
+
|
|
249
|
+
by_root: Dict[tuple, List[dict]] = defaultdict(list)
|
|
250
|
+
for r in rows:
|
|
251
|
+
by_root[find(_key(r))].append(r)
|
|
252
|
+
|
|
253
|
+
out = []
|
|
254
|
+
for members in by_root.values():
|
|
255
|
+
if len(members) < 2:
|
|
256
|
+
continue
|
|
257
|
+
names = sorted({m.get("name") or "(unnamed)" for m in members})
|
|
258
|
+
if len(names) < 2 or len(members) > max_cluster:
|
|
259
|
+
continue
|
|
260
|
+
out.append({
|
|
261
|
+
"key": f"{len(members)} entities within {radius_m:.0f}m",
|
|
262
|
+
"count": len(members),
|
|
263
|
+
"members": sorted(members, key=lambda m: (m.get("name") or "")),
|
|
264
|
+
"names": names,
|
|
265
|
+
})
|
|
266
|
+
# Sort is stable and fully specified so the same data always prints in
|
|
267
|
+
# the same order regardless of how SQLite returned the rows.
|
|
268
|
+
return sorted(out, key=lambda c: (-c["count"], c["names"][0]))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def link_summary(
|
|
272
|
+
conn: sqlite3.Connection, case_name: Optional[str] = None
|
|
273
|
+
) -> Dict[str, List[dict]]:
|
|
274
|
+
return {
|
|
275
|
+
"shared_phone": shared_phones(conn, case_name),
|
|
276
|
+
"shared_domain": shared_domains(conn, case_name),
|
|
277
|
+
"affiliation": affiliations(conn, case_name),
|
|
278
|
+
"co_located": co_located(conn, case_name),
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def apply_link_indicators(conn: sqlite3.Connection, case_name: Optional[str] = None) -> int:
|
|
283
|
+
"""Write shared_phone / shared_domain back onto the entities they
|
|
284
|
+
apply to, so the links are queryable with the same filters as every
|
|
285
|
+
other indicator instead of living only in a report.
|
|
286
|
+
|
|
287
|
+
Recomputed from scratch for the case each time: an entity whose
|
|
288
|
+
number or domain is no longer shared loses the tag. Only adding meant
|
|
289
|
+
a link that had stopped holding was reported forever."""
|
|
290
|
+
from .entities import IND_SHARED_DOMAIN, IND_SHARED_PHONE
|
|
291
|
+
|
|
292
|
+
link_slugs = {IND_SHARED_PHONE, IND_SHARED_DOMAIN}
|
|
293
|
+
tagged: Dict[tuple, set] = defaultdict(set)
|
|
294
|
+
for cluster in shared_phones(conn, case_name):
|
|
295
|
+
for m in cluster["members"]:
|
|
296
|
+
tagged[_key(m)].add(IND_SHARED_PHONE)
|
|
297
|
+
for cluster in shared_domains(conn, case_name):
|
|
298
|
+
for m in cluster["members"]:
|
|
299
|
+
tagged[_key(m)].add(IND_SHARED_DOMAIN)
|
|
300
|
+
|
|
301
|
+
updated = 0
|
|
302
|
+
with closing(conn.cursor()) as cur:
|
|
303
|
+
for row in _fetch(conn, case_name):
|
|
304
|
+
current = {s for s in (row.get("indicators") or "").split(",") if s}
|
|
305
|
+
merged = (current - link_slugs) | tagged.get(_key(row), set())
|
|
306
|
+
if merged != current:
|
|
307
|
+
cur.execute(
|
|
308
|
+
"UPDATE entities SET indicators = ? "
|
|
309
|
+
"WHERE entity_id = ? AND case_name = ?",
|
|
310
|
+
(",".join(sorted(merged)), *_key(row)),
|
|
311
|
+
)
|
|
312
|
+
updated += 1
|
|
313
|
+
conn.commit()
|
|
314
|
+
return updated
|
geosint/api_client.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Retrying wrapper around Google's Places API (New).
|
|
2
|
+
|
|
3
|
+
Deliberately targets the CURRENT API - the older "Places API (Legacy)"
|
|
4
|
+
endpoints (maps.googleapis.com/maps/api/place/...) can no longer be
|
|
5
|
+
enabled on new Google Cloud projects at all (Google froze Legacy to new
|
|
6
|
+
projects in March 2025). A script built against Legacy fails outright
|
|
7
|
+
with REQUEST_DENIED for anyone setting this up fresh in 2026.
|
|
8
|
+
|
|
9
|
+
Docs: https://developers.google.com/maps/documentation/places/web-service/text-search
|
|
10
|
+
|
|
11
|
+
Cost note: Text Search (New) bills at Pro-tier pricing by default (5,000
|
|
12
|
+
free calls/month). Adding rating/review fields to the field mask bumps
|
|
13
|
+
the ENTIRE call to Enterprise-tier pricing (only 1,000 free calls/month,
|
|
14
|
+
~$35-40/1,000 after that) - Google bills every field in a response at the
|
|
15
|
+
highest tier any single requested field belongs to. This client leaves
|
|
16
|
+
rating/review fields OUT of the default field mask for that reason; pass
|
|
17
|
+
include_ratings=True if you specifically want them and accept the cost.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import time
|
|
24
|
+
from typing import Optional, Tuple
|
|
25
|
+
|
|
26
|
+
import requests
|
|
27
|
+
|
|
28
|
+
from .exceptions import PlacesAPIError
|
|
29
|
+
from .geo import bbox_around
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
SEARCH_TEXT_URL = "https://places.googleapis.com/v1/places:searchText"
|
|
34
|
+
|
|
35
|
+
BASE_FIELDS = (
|
|
36
|
+
"places.id,places.displayName,places.formattedAddress,"
|
|
37
|
+
"places.nationalPhoneNumber,places.websiteUri,places.businessStatus,"
|
|
38
|
+
"places.location,places.types,nextPageToken"
|
|
39
|
+
)
|
|
40
|
+
RATING_FIELDS = "places.rating,places.userRatingCount"
|
|
41
|
+
|
|
42
|
+
RETRYABLE_HTTP_STATUSES = {429, 500, 502, 503, 504}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PlacesClient:
|
|
46
|
+
"""Text Search (New) already returns phone/website/type data in one
|
|
47
|
+
call - no separate per-place Details request needed, unlike the old
|
|
48
|
+
Legacy flow (search, then N detail calls). That means the billable
|
|
49
|
+
unit here is roughly one call per ~20 results, not one per business."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
api_key: str,
|
|
54
|
+
max_retries: int = 4,
|
|
55
|
+
timeout: float = 15.0,
|
|
56
|
+
include_ratings: bool = False,
|
|
57
|
+
session: Optional[requests.Session] = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self.api_key = api_key
|
|
60
|
+
self.max_retries = max_retries
|
|
61
|
+
self.timeout = timeout
|
|
62
|
+
self.include_ratings = include_ratings
|
|
63
|
+
self.session = session or requests.Session()
|
|
64
|
+
if include_ratings:
|
|
65
|
+
logger.warning(
|
|
66
|
+
"include_ratings=True: this call now bills at Enterprise-tier "
|
|
67
|
+
"pricing (only 1,000 free calls/month) instead of Pro-tier "
|
|
68
|
+
"(5,000 free calls/month)."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def _field_mask(self) -> str:
|
|
73
|
+
return BASE_FIELDS + ("," + RATING_FIELDS if self.include_ratings else "")
|
|
74
|
+
|
|
75
|
+
def _post(self, body: dict) -> dict:
|
|
76
|
+
headers = {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
"X-Goog-Api-Key": self.api_key,
|
|
79
|
+
"X-Goog-FieldMask": self._field_mask,
|
|
80
|
+
}
|
|
81
|
+
last_error: Optional[Exception] = None
|
|
82
|
+
|
|
83
|
+
for attempt in range(1, self.max_retries + 1):
|
|
84
|
+
try:
|
|
85
|
+
resp = self.session.post(
|
|
86
|
+
SEARCH_TEXT_URL, json=body, headers=headers, timeout=self.timeout
|
|
87
|
+
)
|
|
88
|
+
except requests.RequestException as exc:
|
|
89
|
+
last_error = exc
|
|
90
|
+
wait = min(2**attempt, 30)
|
|
91
|
+
logger.warning(
|
|
92
|
+
"Request failed (attempt %d/%d): %s - retrying in %ds",
|
|
93
|
+
attempt, self.max_retries, exc, wait,
|
|
94
|
+
)
|
|
95
|
+
time.sleep(wait)
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
if resp.status_code == 200:
|
|
99
|
+
return resp.json()
|
|
100
|
+
|
|
101
|
+
if resp.status_code in RETRYABLE_HTTP_STATUSES and attempt < self.max_retries:
|
|
102
|
+
wait = min(2**attempt, 30)
|
|
103
|
+
logger.warning(
|
|
104
|
+
"HTTP %d from Places API - retrying in %ds (attempt %d/%d)",
|
|
105
|
+
resp.status_code, wait, attempt, self.max_retries,
|
|
106
|
+
)
|
|
107
|
+
time.sleep(wait)
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
raise PlacesAPIError(
|
|
111
|
+
f"Places API error: HTTP {resp.status_code} - {resp.text[:300]}"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
raise PlacesAPIError(
|
|
115
|
+
f"Request failed after {self.max_retries} attempts: {last_error}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def text_search(
|
|
119
|
+
self,
|
|
120
|
+
query: str,
|
|
121
|
+
max_results: int,
|
|
122
|
+
center: Optional[Tuple[float, float]] = None,
|
|
123
|
+
radius_km: Optional[float] = None,
|
|
124
|
+
) -> list:
|
|
125
|
+
"""Paginate Text Search (New) up to max_results.
|
|
126
|
+
|
|
127
|
+
`center`/`radius_km` become a locationRestriction rectangle
|
|
128
|
+
enclosing the circle, which is Places' own way of bounding a text
|
|
129
|
+
query; the results still need narrowing to the circle. Before this, the CLI
|
|
130
|
+
accepted --near/--radius for --provider google, validated the
|
|
131
|
+
coordinates, and then dropped them: the request went out as
|
|
132
|
+
"pharmacy in " with no location at all, returned results from
|
|
133
|
+
anywhere on Earth, and stored them with a full provenance block
|
|
134
|
+
that said nothing about the constraint having been discarded.
|
|
135
|
+
"""
|
|
136
|
+
results: list = []
|
|
137
|
+
base: dict = {"textQuery": query}
|
|
138
|
+
if center is not None and radius_km:
|
|
139
|
+
# Text Search (New) accepts only a rectangular viewport as a
|
|
140
|
+
# locationRestriction - a circle is valid for locationBias
|
|
141
|
+
# alone, and sending one got the request rejected. So this
|
|
142
|
+
# sends the box enclosing the circle; the caller narrows the
|
|
143
|
+
# results to the true radius, as the OSM path does.
|
|
144
|
+
box = bbox_around(center[0], center[1], float(radius_km))
|
|
145
|
+
base["locationRestriction"] = {
|
|
146
|
+
"rectangle": {
|
|
147
|
+
"low": {"latitude": float(box["south"]),
|
|
148
|
+
"longitude": float(box["west"])},
|
|
149
|
+
"high": {"latitude": float(box["north"]),
|
|
150
|
+
"longitude": float(box["east"])},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
body = dict(base)
|
|
154
|
+
page = 1
|
|
155
|
+
|
|
156
|
+
while True:
|
|
157
|
+
data = self._post(body)
|
|
158
|
+
batch = data.get("places", [])
|
|
159
|
+
results.extend(batch)
|
|
160
|
+
logger.info(
|
|
161
|
+
"Page %d: +%d results (total %d)", page, len(batch), len(results)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
token = data.get("nextPageToken")
|
|
165
|
+
if not token or len(results) >= max_results:
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
time.sleep(2) # short delay before a new pageToken becomes valid
|
|
169
|
+
body = dict(base)
|
|
170
|
+
body["pageToken"] = token
|
|
171
|
+
page += 1
|
|
172
|
+
|
|
173
|
+
return [_normalize(p) for p in results[:max_results]]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _normalize(place: dict) -> dict:
|
|
177
|
+
"""Map Places API (New) field names onto the source-neutral shape
|
|
178
|
+
entities.py expects (the same shape the OSM collector produces)."""
|
|
179
|
+
location = place.get("location") or {}
|
|
180
|
+
return {
|
|
181
|
+
"source": "google",
|
|
182
|
+
"source_ref": place.get("id", ""),
|
|
183
|
+
"name": (place.get("displayName") or {}).get("text", ""),
|
|
184
|
+
"phone": place.get("nationalPhoneNumber", ""),
|
|
185
|
+
"website": place.get("websiteUri"),
|
|
186
|
+
"email": "",
|
|
187
|
+
"lat": location.get("latitude"),
|
|
188
|
+
"lon": location.get("longitude"),
|
|
189
|
+
"address": place.get("formattedAddress", ""),
|
|
190
|
+
"postcode": "",
|
|
191
|
+
"country": "",
|
|
192
|
+
"operator": "",
|
|
193
|
+
"brand": "",
|
|
194
|
+
"ref": "",
|
|
195
|
+
"wikidata": "",
|
|
196
|
+
"opening_hours": "",
|
|
197
|
+
"survey_date": "",
|
|
198
|
+
"operational_status": place.get("businessStatus", "OPERATIONAL"),
|
|
199
|
+
"rating": place.get("rating"),
|
|
200
|
+
"reviews": place.get("userRatingCount"),
|
|
201
|
+
"types": place.get("types", []),
|
|
202
|
+
}
|
geosint/batch.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Batch mode: run many collections from one CSV in a single command.
|
|
2
|
+
|
|
3
|
+
The columns are `what,area[,osm_tag]`. `niche,city` - the vocabulary this
|
|
4
|
+
tool used before the OSINT pivot - is still accepted as an alias so CSVs
|
|
5
|
+
written against the old names keep working, but the field names on
|
|
6
|
+
BatchQuery are the current ones: keeping `niche`/`city` in the code meant
|
|
7
|
+
the pivot was cosmetic everywhere the reader could not see.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
WHAT_COLUMNS = ("what", "niche")
|
|
17
|
+
AREA_COLUMNS = ("area", "city")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class BatchQuery:
|
|
22
|
+
what: str
|
|
23
|
+
area: str
|
|
24
|
+
osm_tag: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _pick_column(fieldnames: List[str], candidates: tuple) -> Optional[str]:
|
|
28
|
+
for candidate in candidates:
|
|
29
|
+
if candidate in fieldnames:
|
|
30
|
+
return candidate
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_batch_queries(path: str) -> List[BatchQuery]:
|
|
35
|
+
"""Read a CSV with a header row containing at least 'what' and 'area'
|
|
36
|
+
(or the older 'niche' and 'city'); 'osm_tag' is optional per row."""
|
|
37
|
+
with open(path, newline="", encoding="utf-8") as f:
|
|
38
|
+
reader = csv.DictReader(f)
|
|
39
|
+
fieldnames = [name.strip().lower() for name in (reader.fieldnames or [])]
|
|
40
|
+
what_col = _pick_column(fieldnames, WHAT_COLUMNS)
|
|
41
|
+
area_col = _pick_column(fieldnames, AREA_COLUMNS)
|
|
42
|
+
if not what_col or not area_col:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
"Batch CSV needs a header row with 'what' and 'area' columns "
|
|
45
|
+
"(or the older 'niche' and 'city'), optionally 'osm_tag'."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# DictReader keys come from the raw header, which may differ from
|
|
49
|
+
# the lowercased name matched above.
|
|
50
|
+
raw_by_lower = {n.strip().lower(): n for n in (reader.fieldnames or [])}
|
|
51
|
+
what_key = raw_by_lower[what_col]
|
|
52
|
+
area_key = raw_by_lower[area_col]
|
|
53
|
+
tag_key = raw_by_lower.get("osm_tag")
|
|
54
|
+
|
|
55
|
+
queries: List[BatchQuery] = []
|
|
56
|
+
for row in reader:
|
|
57
|
+
what = (row.get(what_key) or "").strip()
|
|
58
|
+
area = (row.get(area_key) or "").strip()
|
|
59
|
+
osm_tag = (row.get(tag_key) or "").strip() if tag_key else ""
|
|
60
|
+
if not what and not osm_tag:
|
|
61
|
+
continue
|
|
62
|
+
if not area:
|
|
63
|
+
continue
|
|
64
|
+
queries.append(BatchQuery(what=what, area=area, osm_tag=osm_tag or None))
|
|
65
|
+
|
|
66
|
+
if not queries:
|
|
67
|
+
raise ValueError(f"No valid (what, area) rows found in {path}.")
|
|
68
|
+
|
|
69
|
+
return queries
|