geosint 1.0.0__tar.gz

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.
Files changed (48) hide show
  1. geosint-1.0.0/LICENSE +21 -0
  2. geosint-1.0.0/PKG-INFO +436 -0
  3. geosint-1.0.0/README.md +401 -0
  4. geosint-1.0.0/geosint/__init__.py +8 -0
  5. geosint-1.0.0/geosint/__main__.py +6 -0
  6. geosint-1.0.0/geosint/analysis.py +314 -0
  7. geosint-1.0.0/geosint/api_client.py +202 -0
  8. geosint-1.0.0/geosint/batch.py +69 -0
  9. geosint-1.0.0/geosint/bulk_extract.py +131 -0
  10. geosint-1.0.0/geosint/cache.py +92 -0
  11. geosint-1.0.0/geosint/cli.py +1115 -0
  12. geosint-1.0.0/geosint/config.py +235 -0
  13. geosint-1.0.0/geosint/db.py +970 -0
  14. geosint-1.0.0/geosint/dedupe.py +110 -0
  15. geosint-1.0.0/geosint/display.py +347 -0
  16. geosint-1.0.0/geosint/entities.py +303 -0
  17. geosint-1.0.0/geosint/exceptions.py +17 -0
  18. geosint-1.0.0/geosint/exporters.py +127 -0
  19. geosint-1.0.0/geosint/geo.py +147 -0
  20. geosint-1.0.0/geosint/osm_client.py +491 -0
  21. geosint-1.0.0/geosint/useragent.py +37 -0
  22. geosint-1.0.0/geosint/website_health.py +170 -0
  23. geosint-1.0.0/geosint/wizard.py +211 -0
  24. geosint-1.0.0/geosint.egg-info/PKG-INFO +436 -0
  25. geosint-1.0.0/geosint.egg-info/SOURCES.txt +46 -0
  26. geosint-1.0.0/geosint.egg-info/dependency_links.txt +1 -0
  27. geosint-1.0.0/geosint.egg-info/entry_points.txt +2 -0
  28. geosint-1.0.0/geosint.egg-info/requires.txt +14 -0
  29. geosint-1.0.0/geosint.egg-info/top_level.txt +1 -0
  30. geosint-1.0.0/pyproject.toml +48 -0
  31. geosint-1.0.0/setup.cfg +4 -0
  32. geosint-1.0.0/tests/test_analysis.py +376 -0
  33. geosint-1.0.0/tests/test_api_client.py +118 -0
  34. geosint-1.0.0/tests/test_batch.py +77 -0
  35. geosint-1.0.0/tests/test_bulk_extract.py +71 -0
  36. geosint-1.0.0/tests/test_cache.py +81 -0
  37. geosint-1.0.0/tests/test_cli.py +345 -0
  38. geosint-1.0.0/tests/test_colocation_grid.py +213 -0
  39. geosint-1.0.0/tests/test_db.py +369 -0
  40. geosint-1.0.0/tests/test_dedupe.py +179 -0
  41. geosint-1.0.0/tests/test_display.py +213 -0
  42. geosint-1.0.0/tests/test_entities.py +185 -0
  43. geosint-1.0.0/tests/test_exporters.py +119 -0
  44. geosint-1.0.0/tests/test_geo.py +137 -0
  45. geosint-1.0.0/tests/test_osm_client.py +138 -0
  46. geosint-1.0.0/tests/test_osm_client_hardening.py +275 -0
  47. geosint-1.0.0/tests/test_regressions.py +1010 -0
  48. geosint-1.0.0/tests/test_website_health.py +200 -0
geosint-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jenil Mistry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
geosint-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,436 @@
1
+ Metadata-Version: 2.4
2
+ Name: geosint
3
+ Version: 1.0.0
4
+ Summary: Geospatial OSINT collection and monitoring for points of interest and organisations - OpenStreetMap by default, optionally Google Places.
5
+ Author: Jenil Mistry
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jenilmistryhq/geosint
8
+ Project-URL: Repository, https://github.com/jenilmistryhq/geosint
9
+ Project-URL: Issues, https://github.com/jenilmistryhq/geosint/issues
10
+ Keywords: osint,geospatial,openstreetmap,overpass,geojson,reconnaissance,monitoring,investigation
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Information Technology
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Scientific/Engineering :: GIS
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: Internet :: WWW/HTTP
20
+ Classifier: Environment :: Console
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: requests>=2.31
25
+ Provides-Extra: xlsx
26
+ Requires-Dist: openpyxl>=3.1; extra == "xlsx"
27
+ Provides-Extra: dotenv
28
+ Requires-Dist: python-dotenv>=1.0; extra == "dotenv"
29
+ Provides-Extra: bulk
30
+ Requires-Dist: osmium>=3.7; extra == "bulk"
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=7.4; extra == "dev"
33
+ Requires-Dist: osmium>=3.7; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # geosint
37
+
38
+ Geospatial OSINT collection and monitoring for points of interest and
39
+ organisations. Collects from public map data, records **where every fact came
40
+ from**, reports **what changed between collections**, and correlates entities
41
+ by shared infrastructure.
42
+
43
+ [![Python](https://img.shields.io/pypi/pyversions/geosint.svg)](https://pypi.org/project/geosint/)
44
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
45
+
46
+ ```
47
+ $ geosint collect --what "bureau de change" --area "Austin, TX" --case meridian
48
+
49
+ 3 entities collected (0 new to this case)
50
+
51
+ ENTITY CATEGORY ADDRESS PHONE DOMAIN CONF TRIAGE
52
+ ----------------- ---------------- --------- --------------- ----------------- ---- ----------
53
+ Alpha Exchange bureau_de_change 1 Main St +1 555 077 2200 newholdco.example 83 Unreviewed
54
+ Beta Money Ser... bureau_de_change 1 Main St +1 555 010 0100 holdco.example 83 Unreviewed
55
+ Cedar Currency bureau_de_change Cedar Ave +1 555 044 7788 67 Unreviewed
56
+
57
+ 3/3 have usable coordinates.
58
+
59
+ 3 field change(s) since the last collection:
60
+
61
+ WHEN ENTITY FIELD WAS NOW SOURCE
62
+ ---------------- -------------- ------- ---------------------- --------------------- ------
63
+ 2026-09-09 19:31 Alpha Exchange phone +1 555 010 0100 +1 555 077 2200 osm
64
+ 2026-09-09 19:31 Alpha Exchange website https://holdco.example https://newholdco.e... osm
65
+ 2026-09-09 19:31 Alpha Exchange domain holdco.example newholdco.example osm
66
+
67
+ 1 previously-seen entity did not reappear:
68
+ - Northside Exchange
69
+ ```
70
+
71
+ ## Scope
72
+
73
+ This collects **organisations, businesses, and infrastructure** from public map
74
+ data — entities that publish an address and a phone number precisely so they
75
+ can be found. It queries each source's own official public API, at documented
76
+ rate limits, identifying itself with a contact address.
77
+
78
+ **It is not a people-search tool.** There is no person lookup, no
79
+ cross-referencing of individuals, and none should be added. Everything it
80
+ returns describes a *place* or an *organisation*.
81
+
82
+ ## Start here
83
+
84
+ ```bash
85
+ pip install geosint
86
+ export GEOSINT_CONTACT="you@example.com" # required, see below
87
+ geosint start
88
+ ```
89
+
90
+ `start` asks five plain questions, remembers the answers, and runs your first
91
+ collection. Everything below is the same thing with flags.
92
+
93
+ ---
94
+
95
+ ## Why this rather than a scraper
96
+
97
+ Three things, and they are the whole design.
98
+
99
+ **1. Provenance on every field.** Each record carries its source, source
100
+ reference, collection time, case, and the query that found it. `geosint show`
101
+ prints all of it. An answer you cannot attribute is not intelligence, it is
102
+ trivia.
103
+
104
+ **2. Change is the product.** Re-run a collection and the tool diffs it against
105
+ what is stored, writing an append-only observation for every field that moved.
106
+ A phone number changing, a website going dark, an entity vanishing from the
107
+ map — these are findings, and a tool that just overwrites its database destroys
108
+ them.
109
+
110
+ ```bash
111
+ geosint collect --what pharmacy --area "Austin, TX" --case alpha # week 1
112
+ geosint collect --what pharmacy --area "Austin, TX" --case alpha # week 2
113
+ geosint changes --since 7d --case alpha
114
+ ```
115
+
116
+ **3. Correlation between entities.** Everything else describes entities one at
117
+ a time. `geosint links` looks for what connects them:
118
+
119
+ | Link | Meaning |
120
+ |---|---|
121
+ | shared phone | Two "different" organisations answering one number |
122
+ | shared domain | Two entities publishing the same web host |
123
+ | operator / brand | Affiliation the source states outright — the strongest signal available |
124
+ | co-location | Entities within metres of each other |
125
+
126
+ ```
127
+ $ geosint links --case meridian
128
+
129
+ Stated operator / brand affiliations
130
+
131
+ Affiliation: Meridian Holdings (2 entities)
132
+ - Alpha Exchange
133
+ - Beta Money Services
134
+
135
+ Co-located entities (within 25m)
136
+
137
+ Cluster: 2 entities within 25m (2 entities)
138
+ - Alpha Exchange
139
+ - Beta Money Services
140
+ ```
141
+
142
+ A shared value is **a lead, not a conclusion**. Shared numbers and domains have
143
+ mundane explanations — a shared reception, a franchise call centre, a serviced
144
+ office — at least as often as interesting ones. The tool reports the evidence
145
+ and leaves the judgement to you.
146
+
147
+ ---
148
+
149
+ ## Setup
150
+
151
+ ```bash
152
+ pip install geosint # core
153
+ pip install "geosint[xlsx,dotenv]" # + Excel export, .env support
154
+ pip install "geosint[bulk]" # + offline .osm.pbf extraction
155
+ ```
156
+
157
+ ### The contact address is required
158
+
159
+ OpenStreetMap's Nominatim and Overpass are run by volunteers, and their usage
160
+ policy requires requests to carry a real contact so an operator can email you
161
+ rather than block you.
162
+
163
+ ```bash
164
+ export GEOSINT_CONTACT="you@example.com" # macOS / Linux
165
+ $env:GEOSINT_CONTACT="you@example.com" # Windows PowerShell
166
+ ```
167
+
168
+ Or pass `--contact` per run. It is sent to OSM and to any site you explicitly
169
+ choose to check with `--check-websites`, and nowhere else.
170
+
171
+ ### Optional: the Google collector
172
+
173
+ `--provider google` has better contact coverage but no `operator`, `brand`,
174
+ `ref`, or `wikidata` fields — the metadata that makes OSM more useful for
175
+ investigation. It also costs money past a free monthly allowance, and requires
176
+ billing enabled with a card on file even to stay inside it. Google prices a
177
+ whole response at the tier of its most expensive requested field, so
178
+ `--include-ratings` drops the free allowance from 5,000 to 1,000 calls/month.
179
+
180
+ 1. https://console.cloud.google.com → new project
181
+ 2. Enable **Places API (New)**, enable billing
182
+ 3. Create an API key, restrict it to "Places API (New)"
183
+ 4. `export GOOGLE_PLACES_API_KEY="your-key"`
184
+
185
+ `--near` / `--radius` become a Places `locationRestriction` rectangle around the
186
+ circle (the only shape Text Search accepts there), and results are then narrowed
187
+ to the true radius. The radius is capped at 50 km, since Text Search returns at
188
+ most 60 results - a wider radius is refused rather than quietly clamped. Use
189
+ `--provider osm` for a bigger sweep. `--tile` is osm-only.
190
+
191
+ ---
192
+
193
+ ## Commands
194
+
195
+ ### Collect
196
+
197
+ ```bash
198
+ # A named area (geocoded to its bounding box)
199
+ geosint collect --what pharmacy --area "Austin, TX" --case alpha
200
+
201
+ # A point and radius - more precise, and usually what you want.
202
+ # A box around a city centre is mostly not the city centre.
203
+ geosint collect --what pharmacy --near 30.2672,-97.7431 --radius 2
204
+
205
+ # Any OSM tag, for anything not in the built-in category list
206
+ geosint collect --osm-tag "office=diplomatic" --area "Vienna, Austria"
207
+ geosint collect --osm-tag "man_made=mast" --near 51.5,-0.12 --radius 10
208
+
209
+ # Dense area under-returning? Split it into a 3x3 grid (9 queries, deduped)
210
+ geosint collect --what pharmacy --area "Chicago, IL" --tile 3
211
+
212
+ # Many collections from a CSV (header: what,area[,osm_tag]).
213
+ # Each row is its own tracked collection, so re-running the batch diffs
214
+ # every row and reports what stopped appearing, per row.
215
+ geosint batch --input targets.csv --case alpha
216
+
217
+ # A whole country or region, offline, no rate limit
218
+ geosint pbf --pbf texas-latest.osm.pbf --what pharmacy --case alpha
219
+ ```
220
+
221
+ For `pbf`, download the smallest extract covering your region from
222
+ <https://download.geofabrik.de/>.
223
+
224
+ ### Analyse
225
+
226
+ ```bash
227
+ geosint entities --case alpha # browse
228
+ geosint entities --indicator shared_phone # filter by indicator
229
+ geosint entities --missing # what stopped appearing
230
+ geosint show "Alpha Exchange" # one entity + full provenance
231
+ geosint links --case alpha --tag # correlate, and tag the results
232
+ geosint near 30.2672,-97.7431 2 # what's within 2km
233
+ ```
234
+
235
+ ### Record judgement
236
+
237
+ ```bash
238
+ geosint triage "Alpha Exchange" Flagged --note "same address as Beta"
239
+ geosint note "Alpha Exchange" "registration MSB-11021 predates the website"
240
+ geosint recheck "Alpha Exchange" +7d # also +2w, +3m, +1y, 2026-10-01
241
+ geosint recheck # what's due
242
+ ```
243
+
244
+ Triage states: `Unreviewed`, `Reviewing`, `Corroborated`, `Dismissed`,
245
+ `Flagged`. Anything you write — triage, notes, recheck dates, analyst — is
246
+ **never overwritten by a re-collection**.
247
+
248
+ ### Monitor and report
249
+
250
+ ```bash
251
+ geosint changes --since 7d --case alpha # what moved
252
+ geosint runs # collection history
253
+ geosint report --case alpha # indicator + link summary
254
+ geosint cases # all cases
255
+ geosint export --case alpha # GeoJSON by default
256
+ ```
257
+
258
+ `export` writes **GeoJSON** by default because the data is inherently
259
+ geospatial — drop the file into [geojson.io](https://geojson.io), QGIS,
260
+ kepler.gl, or Google Earth with no conversion. `--format csv|xlsx|json` also
261
+ available.
262
+
263
+ ---
264
+
265
+ ## Indicators
266
+
267
+ Findings are named indicators, not a single score. A score collapses independent
268
+ facts into one number and hides which fact drove it.
269
+
270
+ | Indicator | Meaning |
271
+ |---|---|
272
+ | `no_web_presence` | No website published |
273
+ | `website_dead` | Website published but unreachable (needs `--check-websites`) |
274
+ | `no_contact_details` | No phone, website, or email at all |
275
+ | `no_address` / `no_coordinates` | Incomplete location data |
276
+ | `operator_named` | The source names who runs it |
277
+ | `chain_affiliated` | Carries a brand tag |
278
+ | `registration_ref` | Carries a licence/registration reference |
279
+ | `wikidata_linked` | Hard link to a structured record |
280
+ | `stale_survey` | Map data not verified recently |
281
+ | `not_operational` | Source reports it closed |
282
+ | `shared_phone` / `shared_domain` | Written by `geosint links --tag` |
283
+
284
+ `confidence` is a separate number: the percentage of core fields actually
285
+ populated. It measures **record completeness**, not how interesting the entity
286
+ is — those are different questions and should not share a scale.
287
+
288
+ ---
289
+
290
+ ## What the sources can and cannot tell you
291
+
292
+ - **OSM has no rating, review, or operational-status data.** Those come back as
293
+ unknown, and every check skips rather than inventing a value. A blank never
294
+ reads as a zero.
295
+ - **Ways carry tags but not coordinates** in offline `pbf` mode (full geometry
296
+ assembly needs a location index and far more memory). They are flagged
297
+ `no_coordinates` so the gap is visible in the data rather than silently absent.
298
+ Nodes — the common case for POIs — carry their coordinates.
299
+ - **`--check-websites` is not an audit.** One GET to each entity's own published
300
+ homepage, once, recording only whether it answered. Nothing is read out of the
301
+ page, and it is never a crawl. Sites whose host resolves into private or
302
+ loopback address space are declined rather than fetched — the URL comes from a
303
+ world-writable OSM tag.
304
+ - **Coverage varies enormously by region.** OSM is crowdsourced; absence of an
305
+ entity is weak evidence of absence in reality.
306
+ - **This tool does not scrape.** `pbf` reaches country scale through OSM's own
307
+ bulk distribution channel — a bigger free, ToS-clean source, not evasion of
308
+ anyone's rate limits. It does not harvest contact details off web pages
309
+ either: `email` comes only from the source's own `email`/`contact:email` tag,
310
+ published as a contact detail by a mapper.
311
+ - **Spreadsheet exports are formula-escaped.** An OSM `name` tag is
312
+ world-writable, and Excel evaluates a cell beginning `=`, `+`, `-` or `@`.
313
+ Values that would execute are prefixed so they stay text. GeoJSON and JSON are
314
+ untouched — nothing that reads them executes them.
315
+
316
+ ---
317
+
318
+ ## Options (`collect` / `batch` / `pbf`)
319
+
320
+ | Flag | Default | What it does |
321
+ |---|---|---|
322
+ | `--what` | — | Category, e.g. `"pharmacy"` |
323
+ | `--osm-tag` | — | Any OSM tag, e.g. `"office=ngo"` (overrides `--what`) |
324
+ | `--area` | — | Place name, geocoded to a bounding box |
325
+ | `--near` / `--radius` | — | Centre point (`lat,lon`) and radius in km. google caps the radius at 50km |
326
+ | `--case` | `default` | Scopes entities, changes, and correlation |
327
+ | `--contact` | `$GEOSINT_CONTACT` | Your email/URL, sent as User-Agent |
328
+ | `--provider` | `osm` | `osm` (free, richer metadata) or `google` |
329
+ | `--tile` | 1 | osm only — N x N grid split for fuller coverage. Raise `--max-results` with it |
330
+ | `--check-websites` | off | Record whether each published site answers. 8 at a time |
331
+ | `--no-store` | off | One-off collection; disables change detection |
332
+ | `--db-path` | `.geosint.db` | Database location |
333
+ | `--max-results` | 60 | osm ceiling 3000; google hard cap 60 |
334
+ | `--min-confidence` | 0 | Drop records less than N% complete |
335
+ | `--format` | geojson | `geojson`, `csv`, `xlsx`, `json` |
336
+ | `--output` | — | Write results to this file |
337
+ | `--cache-ttl` | 24 | Hours before a cached query refetches (0 = never reuse) |
338
+ | `--no-cache` | off | Skip the cache entirely |
339
+ | `--dry-run` | off | Collect and analyse, write nothing |
340
+ | `--verbose` / `--quiet` | normal | Debug logging / warnings only |
341
+
342
+ A stored collection always bypasses the cache on read: it exists to answer
343
+ "what changed?", and only the network can answer that. The cache key covers
344
+ every flag that changes the result set, `--max-results` included, so raising a
345
+ cap refetches rather than replaying the smaller answer.
346
+
347
+ `--max-results` is applied **after** tiling merges and dedupes, so `--tile 3`
348
+ with the default 60 still keeps 60 — of the whole area, ranked by record
349
+ completeness, not the first tiles queried. When results are dropped to fit the
350
+ cap the run says so and names the flag to raise. A radius collection keeps the
351
+ nearest N instead, because there distance is a meaningful order.
352
+
353
+ ---
354
+
355
+ ## Development
356
+
357
+ ```bash
358
+ git clone https://github.com/jenilmistryhq/geosint.git
359
+ cd geosint
360
+ pip install -e ".[dev,xlsx,dotenv]"
361
+ pytest -q
362
+ ```
363
+
364
+ 388 tests, all against mocked HTTP and temporary SQLite, or a real synthetic
365
+ `.osm.pbf` fixture — no API key, no network, no cost. (One skips unless the
366
+ `bulk` extra is installed.) Each test runs in its own working directory with a
367
+ neutralised `$HOME`, so nothing leaks between tests or into the repo.
368
+
369
+ Two rules this suite learned the hard way:
370
+
371
+ - **Build fixtures from a real normalizer, not by hand.** `dedupe` once keyed on
372
+ field names no normalizer produced. Every test passed; every branch of every
373
+ chain silently merged into one record. `tests/test_dedupe.py` now builds its
374
+ input by calling the collectors' own `_normalize`.
375
+ - **Mock the transport, assert on the contract.** Tests that mock a client
376
+ wholesale prove only that the mock works — `--provider google` had no
377
+ end-to-end test and silently discarded `--near` for as long as the flag
378
+ existed.
379
+
380
+ `tests/test_regressions.py` holds one named test per bug that produced
381
+ confident, wrong output.
382
+
383
+ ### Layout
384
+
385
+ ```
386
+ geosint/
387
+ ├── cli.py argument parsing + command orchestration
388
+ ├── wizard.py `start` - guided setup
389
+ ├── display.py tables, bar charts, entity cards
390
+ ├── config.py env/.env/CLI resolution + validation (one path, all commands)
391
+ ├── useragent.py required contact -> User-Agent
392
+ ├── geo.py coordinates, radius maths, GeoJSON
393
+ ├── entities.py Entity record, indicators, confidence
394
+ ├── analysis.py correlation: shared phone/domain/operator/location
395
+ ├── db.py SQLite: entities, observations, collections, aliases, actions
396
+ ├── osm_client.py Overpass + Nominatim collector
397
+ ├── api_client.py Google Places API (New) collector
398
+ ├── bulk_extract.py offline .osm.pbf extraction
399
+ ├── dedupe.py identity keys, in-memory and at the DB boundary
400
+ ├── website_health.py does the published site resolve
401
+ ├── batch.py CSV multi-collection runner
402
+ ├── cache.py per-query cache with a TTL
403
+ └── exporters.py GeoJSON / CSV / XLSX / JSON
404
+ ```
405
+
406
+ ### Data model
407
+
408
+ ```
409
+ collections one row per run: query, source, area, counts, timestamps
410
+ |
411
+ entities current best-known state, one row per entity
412
+ |
413
+ observations APPEND-ONLY. Every field change, with source and timestamp.
414
+ aliases maps a second source's id onto the canonical entity
415
+ actions analyst triage changes and notes
416
+ ```
417
+
418
+ Adding a category? Add it to `CATEGORY_TAG_MAP` in `osm_client.py` plus a line
419
+ in `test_osm_client.py`.
420
+
421
+ ## Legal and ethical note
422
+
423
+ This queries each data source's own official public API (OpenStreetMap's
424
+ Nominatim and Overpass, Google's Places API) and, with `--check-websites`, makes
425
+ a plain GET to an entity's own published homepage. It does not scrape search
426
+ results, bypass authentication or CAPTCHAs, evade rate limits, or attempt to
427
+ disguise itself — the contact address is mandatory precisely so it does not.
428
+
429
+ You are responsible for the lawfulness and proportionality of your collection.
430
+ Public availability is not the same as a right to compile, and the answer
431
+ differs by jurisdiction and by purpose. Where collected records touch personal
432
+ data, data-protection law generally applies to you as the one compiling them.
433
+
434
+ ## License
435
+
436
+ [MIT](LICENSE)