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 ADDED
@@ -0,0 +1,8 @@
1
+ """geosint - geospatial OSINT collection and monitoring.
2
+
3
+ Collects points of interest and organizations from public map data,
4
+ records provenance for every observation, and reports what changed
5
+ between collections.
6
+ """
7
+
8
+ __version__ = "1.0.0"
geosint/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import run
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(run())
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