Spaces:
Runtime error
Hardening: multi-user safety, rename to places, geocoder cache, robust chatbot
Browse filesTier 1 (honesty + safety):
- Rename restaurants β places end-to-end: output files (places_full.csv,
places_map.kml, places_map.geojson, places_skipped.csv, places_override.csv),
HTTP download filenames, page title, headings, SSE messages, gitignore.
Internal LLM prompt key left as-is (benchmark oracle depends on it).
- Fix roulette cross-user data leak: /roulette no longer falls back to the
globally-newest CSV across all jobs (which leaked one user's places to
another on a shared deploy). job_id is now required; client already sends it.
Tier 2 (hosting readiness):
- Job TTL + persisted state: each job's state mirrors to <job_dir>/state.json
so progress survives a restart; job dirs older than JOB_TTL_HOURS (default 24)
are deleted on startup and on each new upload.
- Geocoder: persistent placeβcoords cache (.geocode_cache.json) so duplicates
and re-runs skip the network; NOMINATIM_URL env to point at a self-hosted
instance; HOSTED=1 refuses bulk geocoding against public OSM (usage policy).
- Robust chatbot handoff: auto-chunk large exports into per-file downloads,
accept multiple pasted replies and merge/dedup them, and parse tolerantly
(markdown fences, prose, trailing commas, truncated arrays salvaged).
TODO: added Hardening (today), Backlog (in-app map preview β revisit end of
day), and Tier 3 Growth (mobile consumption docs, offline targets, persistent
library, HTML map export, remix loop) above the Beli V2 section.
Note: pre-existing prefilter false-negative (unrelated, prefilter.py untouched).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- .dockerignore +1 -0
- .gitignore +9 -1
- README.md +21 -14
- TODO.md +30 -0
- docker-compose.yml +6 -0
- main.py +5 -5
- pipeline/chatbot.py +64 -11
- pipeline/export.py +1 -1
- pipeline/geocode.py +62 -13
- pipeline/override.py +1 -1
- pipeline/transcribe.py +4 -4
- web/app.py +92 -38
- web/templates/index.html +72 -22
|
@@ -8,4 +8,5 @@ data/
|
|
| 8 |
tests/fixtures/benchmark_results.json
|
| 9 |
*.checkpoint.json
|
| 10 |
*.batch_pending.json
|
|
|
|
| 11 |
.DS_Store
|
|
|
|
| 8 |
tests/fixtures/benchmark_results.json
|
| 9 |
*.checkpoint.json
|
| 10 |
*.batch_pending.json
|
| 11 |
+
.geocode_cache.json
|
| 12 |
.DS_Store
|
|
@@ -7,16 +7,24 @@ data/
|
|
| 7 |
# Web UI job artifacts
|
| 8 |
jobs/
|
| 9 |
|
| 10 |
-
#
|
| 11 |
saved_posts.json
|
| 12 |
saved_collections.json
|
| 13 |
saved_locations.json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
restaurants_full.csv
|
| 15 |
restaurants_skipped.csv
|
| 16 |
restaurants_override.csv
|
| 17 |
restaurants_map.kml
|
| 18 |
restaurants.csv
|
| 19 |
*.checkpoint.json
|
|
|
|
|
|
|
| 20 |
tests/benchmark_results.json
|
| 21 |
|
| 22 |
# Python
|
|
|
|
| 7 |
# Web UI job artifacts
|
| 8 |
jobs/
|
| 9 |
|
| 10 |
+
# Generated outputs (in case any exist at root)
|
| 11 |
saved_posts.json
|
| 12 |
saved_collections.json
|
| 13 |
saved_locations.json
|
| 14 |
+
places_full.csv
|
| 15 |
+
places_skipped.csv
|
| 16 |
+
places_override.csv
|
| 17 |
+
places_map.kml
|
| 18 |
+
places_map.geojson
|
| 19 |
+
# Legacy (pre-rename) output names
|
| 20 |
restaurants_full.csv
|
| 21 |
restaurants_skipped.csv
|
| 22 |
restaurants_override.csv
|
| 23 |
restaurants_map.kml
|
| 24 |
restaurants.csv
|
| 25 |
*.checkpoint.json
|
| 26 |
+
*.batch_pending.json
|
| 27 |
+
.geocode_cache.json
|
| 28 |
tests/benchmark_results.json
|
| 29 |
|
| 30 |
# Python
|
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
# Instagram Saved
|
| 2 |
|
| 3 |
-
Turn your Instagram saved posts into a searchable, filterable
|
| 4 |
|
| 5 |
Every paid alternative (Someday Map, Rezz, ReelsMap, Drawer) is a subscription app that sends your saves to their servers and charges you monthly. This project runs entirely on your machine: Ollama handles AI extraction locally, Nominatim handles geocoding for free, and the only output is files on your disk.
|
| 6 |
|
|
@@ -10,7 +10,7 @@ Every paid alternative (Someday Map, Rezz, ReelsMap, Drawer) is a subscription a
|
|
| 10 |
|
| 11 |
1. **Extracts** β reads every saved post caption using AI (Claude or a free local Ollama model), identifies posts about places worth visiting (restaurants, bars, cafes, hotels, shops, hikes, viewpoints, museums, and more), and pulls out name, city, category, cuisine, price range, highlight, and occasion
|
| 12 |
2. **Geocodes** β looks up coordinates via Nominatim (OpenStreetMap) with country + city + proximity validation to avoid false pins
|
| 13 |
-
3. **Exports** β writes `
|
| 14 |
|
| 15 |
## Why not the paid apps?
|
| 16 |
|
|
@@ -208,16 +208,16 @@ python main.py saved_posts.json --transcribe --whisper-model medium # more accu
|
|
| 208 |
|
| 209 |
| File | Description |
|
| 210 |
|---|---|
|
| 211 |
-
| `
|
| 212 |
-
| `
|
| 213 |
-
| `
|
| 214 |
-
| `
|
| 215 |
|
| 216 |
## Fixing missing map pins
|
| 217 |
|
| 218 |
-
After each run, `
|
| 219 |
|
| 220 |
-
1. Open `
|
| 221 |
2. Search for the restaurant on Google Maps, copy the URL
|
| 222 |
3. Paste it into the `maps_url` column (or enter `lat`/`lng` directly)
|
| 223 |
4. Re-run: `python main.py saved_posts.json --no-extract --no-geocode --yes`
|
|
@@ -229,7 +229,7 @@ Supported URL formats:
|
|
| 229 |
## Import into Google My Maps
|
| 230 |
|
| 231 |
1. Go to [mymaps.google.com](https://mymaps.google.com) β **Create a new map**
|
| 232 |
-
2. Click **Import** on the base layer β upload `
|
| 233 |
3. Done β pins are organised into folders by country and city
|
| 234 |
|
| 235 |
## Docker (self-hosted, batteries included)
|
|
@@ -253,6 +253,9 @@ Open [http://localhost:8000](http://localhost:8000). On first run, `llama3.2` (~
|
|
| 253 |
| `OLLAMA_URL` | `http://ollama:11434` | Ollama API endpoint (set automatically by compose) |
|
| 254 |
| `OLLAMA_ENABLED` | `true` | Set to `false` to hide the Ollama provider (for hosted deployments) |
|
| 255 |
| `DEFAULT_OLLAMA_MODEL` | `llama3.2` | Model pulled on first run and pre-selected in the UI |
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
### Tier comparison
|
| 258 |
|
|
@@ -299,10 +302,10 @@ The UI lets you:
|
|
| 299 |
| **ChatGPT / Claude.ai (free chatbot)** | $0 | Captions to chatbot | None |
|
| 300 |
|
| 301 |
**ChatGPT / Claude.ai path (no API key needed):**
|
| 302 |
-
1. Drop your export file and click **Prepare extraction file** β the app pre-filters your posts and generates a numbered JSON file
|
| 303 |
-
2. Download
|
| 304 |
-
3. Paste
|
| 305 |
-
4. The app geocodes the results and builds your map β same output as any other path
|
| 306 |
|
| 307 |
**BYOK (Bring Your Own Key):** Select the Claude provider and paste your Anthropic API key into the password field. It is used for that request only and never stored on the server.
|
| 308 |
|
|
@@ -316,6 +319,10 @@ The geocoder runs three validation checks before accepting any result:
|
|
| 316 |
|
| 317 |
If any check fails, the row is left blank rather than accepting a wrong pin.
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
## Benchmark & quality testing
|
| 320 |
|
| 321 |
The `tests/` directory contains a regression suite that measures extraction quality across all models.
|
|
|
|
| 1 |
+
# Instagram Saved Places β Map
|
| 2 |
|
| 3 |
+
Turn your Instagram saved posts into a searchable, filterable map of every place you've saved β restaurants, bars, cafes, hotels, shops, hikes, viewpoints, museums and more β **for free, forever, without giving anyone your data.**
|
| 4 |
|
| 5 |
Every paid alternative (Someday Map, Rezz, ReelsMap, Drawer) is a subscription app that sends your saves to their servers and charges you monthly. This project runs entirely on your machine: Ollama handles AI extraction locally, Nominatim handles geocoding for free, and the only output is files on your disk.
|
| 6 |
|
|
|
|
| 10 |
|
| 11 |
1. **Extracts** β reads every saved post caption using AI (Claude or a free local Ollama model), identifies posts about places worth visiting (restaurants, bars, cafes, hotels, shops, hikes, viewpoints, museums, and more), and pulls out name, city, category, cuisine, price range, highlight, and occasion
|
| 12 |
2. **Geocodes** β looks up coordinates via Nominatim (OpenStreetMap) with country + city + proximity validation to avoid false pins
|
| 13 |
+
3. **Exports** β writes `places_full.csv`, `places_map.kml` (Google My Maps), and `places_map.geojson` (Felt, Mapbox, etc.)
|
| 14 |
|
| 15 |
## Why not the paid apps?
|
| 16 |
|
|
|
|
| 208 |
|
| 209 |
| File | Description |
|
| 210 |
|---|---|
|
| 211 |
+
| `places_full.csv` | One row per place: name, city, category, cuisine, highlight, price_range, lat, lng, creator, saved_at, instagram_url |
|
| 212 |
+
| `places_map.kml` | Google My Mapsβready file, organised by country β city. Pins show cuisine, must-order dish, and link back to Instagram. |
|
| 213 |
+
| `places_skipped.csv` | Reels that couldn't be downloaded or weren't food. Review manually to fill gaps. |
|
| 214 |
+
| `places_override.csv` | Manual override template β fill in `maps_url` or `lat`/`lng` for ungeocoded places. |
|
| 215 |
|
| 216 |
## Fixing missing map pins
|
| 217 |
|
| 218 |
+
After each run, `places_override.csv` lists every restaurant with no coordinates. To add a pin manually:
|
| 219 |
|
| 220 |
+
1. Open `places_override.csv`
|
| 221 |
2. Search for the restaurant on Google Maps, copy the URL
|
| 222 |
3. Paste it into the `maps_url` column (or enter `lat`/`lng` directly)
|
| 223 |
4. Re-run: `python main.py saved_posts.json --no-extract --no-geocode --yes`
|
|
|
|
| 229 |
## Import into Google My Maps
|
| 230 |
|
| 231 |
1. Go to [mymaps.google.com](https://mymaps.google.com) β **Create a new map**
|
| 232 |
+
2. Click **Import** on the base layer β upload `places_map.kml`
|
| 233 |
3. Done β pins are organised into folders by country and city
|
| 234 |
|
| 235 |
## Docker (self-hosted, batteries included)
|
|
|
|
| 253 |
| `OLLAMA_URL` | `http://ollama:11434` | Ollama API endpoint (set automatically by compose) |
|
| 254 |
| `OLLAMA_ENABLED` | `true` | Set to `false` to hide the Ollama provider (for hosted deployments) |
|
| 255 |
| `DEFAULT_OLLAMA_MODEL` | `llama3.2` | Model pulled on first run and pre-selected in the UI |
|
| 256 |
+
| `JOB_TTL_HOURS` | `24` | Per-upload job directories are deleted after this many hours |
|
| 257 |
+
| `NOMINATIM_URL` | public OSM | Geocoder endpoint β point at a self-hosted Nominatim for hosted use |
|
| 258 |
+
| `HOSTED` | unset | Set to `1` on a multi-user deploy: refuses to bulk-geocode against public OSM |
|
| 259 |
|
| 260 |
### Tier comparison
|
| 261 |
|
|
|
|
| 302 |
| **ChatGPT / Claude.ai (free chatbot)** | $0 | Captions to chatbot | None |
|
| 303 |
|
| 304 |
**ChatGPT / Claude.ai path (no API key needed):**
|
| 305 |
+
1. Drop your export file and click **Prepare extraction file** β the app pre-filters your posts and generates a numbered JSON file (large libraries are split into several files that each fit a free chatbot's reply limit)
|
| 306 |
+
2. Download each file, copy the extraction prompt, and paste both into ChatGPT or Claude.ai (free tier)
|
| 307 |
+
3. Paste each reply back into the web UI (click **Add response** per file), then process
|
| 308 |
+
4. The app geocodes the results and builds your map β same output as any other path. Response parsing is tolerant of markdown fences, prose, trailing commas, and truncated replies.
|
| 309 |
|
| 310 |
**BYOK (Bring Your Own Key):** Select the Claude provider and paste your Anthropic API key into the password field. It is used for that request only and never stored on the server.
|
| 311 |
|
|
|
|
| 319 |
|
| 320 |
If any check fails, the row is left blank rather than accepting a wrong pin.
|
| 321 |
|
| 322 |
+
**Caching** β every lookup is cached to `.geocode_cache.json` (place β coordinates), so duplicate venues and re-runs never re-hit the network. Delete the file to force a fresh geocode.
|
| 323 |
+
|
| 324 |
+
**Endpoint & hosted use** β geocoding defaults to the public OSM Nominatim endpoint, which is fine for personal/local use. OSM's usage policy **forbids bulk automated use**, so a shared/hosted deployment must run its own geocoder: set `NOMINATIM_URL` to your instance. With `HOSTED=1`, the pipeline refuses to bulk-geocode against the public endpoint rather than risk getting the server IP banned.
|
| 325 |
+
|
| 326 |
## Benchmark & quality testing
|
| 327 |
|
| 328 |
The `tests/` directory contains a regression suite that measures extraction quality across all models.
|
|
@@ -128,6 +128,36 @@ KML requires Google My Maps. The primary output should be a self-contained file
|
|
| 128 |
|
| 129 |
---
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
## V2: Beli / Google Maps Saved integration
|
| 132 |
|
| 133 |
- [ ] **find_place_ids.py** β Google Places Text Search API, validate against expected country + city. ~$2 for 173 requests.
|
|
|
|
| 128 |
|
| 129 |
---
|
| 130 |
|
| 131 |
+
## Hardening β multi-user safety & correctness (today)
|
| 132 |
+
|
| 133 |
+
Surfaced in the architecture review. These make the product safe to host and honest about what it is.
|
| 134 |
+
|
| 135 |
+
- [ ] **Rename restaurants β places end-to-end** β output files (`places_full.csv`, `places_map.kml`, `places_map.geojson`, `places_skipped.csv`, `places_override.csv`), download filenames, README title, and user-facing UI/CLI text. The product handles hikes/museums/hotels now; users shouldn't download a file called "restaurants".
|
| 136 |
+
- [ ] **Fix roulette cross-user data leak** β `/roulette` with no `job_id` scanned `jobs/*/` and returned the globally newest CSV β on a hosted deploy that returns *another user's* saved places. Require an explicit `job_id` (the client already sends it); drop the global-newest fallback.
|
| 137 |
+
- [ ] **Job TTL + persisted state** β job state lived only in a process-memory dict (lost on restart) and `jobs/` grew forever. Persist each job's state to `state.json`, recover it in the progress endpoint, and delete job dirs older than `JOB_TTL_HOURS` (default 24) on startup and on each new upload.
|
| 138 |
+
- [ ] **Geocoder: cache + configurable endpoint + hosted guard** β persistent geocode cache (place β coords) so duplicates and re-runs don't re-hit Nominatim; `NOMINATIM_URL` env to point at a self-hosted instance; refuse bulk geocoding against the public OSM endpoint when `HOSTED=1` (OSM's usage policy forbids it β a hosted deploy would get IP-banned).
|
| 139 |
+
- [ ] **Robust chatbot handoff** β auto-chunk exports above the free-tier limit into multiple files, accept multiple pasted responses and merge them, and make response parsing tolerant of preamble text, trailing commas, and truncated arrays.
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
## Backlog β revisit at end of today's fixes
|
| 144 |
+
|
| 145 |
+
- [ ] **In-app map preview (QC, not consumption)** β a Leaflet/OSM preview in the results view whose job is to *catch geocode errors before export* (a venue pinned in the wrong country is invisible in the card grid). Consumption stays in Google My Maps / offline apps via the exported file. Lower priority than the hardening work β revisit once that lands.
|
| 146 |
+
|
| 147 |
+
---
|
| 148 |
+
|
| 149 |
+
## Tier 3 β Growth features
|
| 150 |
+
|
| 151 |
+
Build after the hardening pass. These extend reach without compromising the local-first, no-account story.
|
| 152 |
+
|
| 153 |
+
- [ ] **Close the mobile consumption loop (docs)** β the deliverable is meant to live on your phone, so document the last mile: opening a custom Google My Map in the Google Maps app and navigating to a pin. Cheap, directly serves the "usable on your phone while travelling" goal.
|
| 154 |
+
- [ ] **Offline / privacy-first map targets** β KML and GPX import cleanly into Organic Maps and OsmAnd: fully offline, no account, works abroad with no data plan. Arguably a better recommendation than Google My Maps for the privacy-first traveller, and we already produce the file β mostly a docs + small export addition.
|
| 155 |
+
- [ ] **Persistent library without a server DB** β let the user download a "library file" and re-upload it to merge, preserving visited status, edits, and "new since last run" across re-imports. Keyed on Instagram post ID. Stays true to local-first (no backend database). Supersedes the deferred re-import item above.
|
| 156 |
+
- [ ] **Self-contained interactive HTML map export** β single bundled file (Leaflet/MapLibre), pins link back to the original reel; opens on a phone browser with no import step. See the on-hold item under "Output".
|
| 157 |
+
- [ ] **"Remix this map" share loop** β publish a static map file anyone can view and one-click copy into their own instance. $0 hosting for OSS; mirrors Someday Map's virality mechanic.
|
| 158 |
+
|
| 159 |
+
---
|
| 160 |
+
|
| 161 |
## V2: Beli / Google Maps Saved integration
|
| 162 |
|
| 163 |
- [ ] **find_place_ids.py** β Google Places Text Search API, validate against expected country + city. ~$2 for 173 requests.
|
|
@@ -25,6 +25,12 @@ services:
|
|
| 25 |
environment:
|
| 26 |
OLLAMA_URL: http://ollama:11434
|
| 27 |
OLLAMA_ENABLED: "true"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
volumes:
|
| 29 |
- ./jobs:/app/jobs
|
| 30 |
- ./data:/app/data
|
|
|
|
| 25 |
environment:
|
| 26 |
OLLAMA_URL: http://ollama:11434
|
| 27 |
OLLAMA_ENABLED: "true"
|
| 28 |
+
JOB_TTL_HOURS: "24" # delete each upload's job dir after N hours
|
| 29 |
+
# Personal/local use geocodes against public OSM Nominatim (fine for one user).
|
| 30 |
+
# For a MULTI-USER hosted deploy, OSM forbids bulk use β run your own geocoder
|
| 31 |
+
# and uncomment these (plus a nominatim/photon service):
|
| 32 |
+
# HOSTED: "1"
|
| 33 |
+
# NOMINATIM_URL: http://nominatim:8080
|
| 34 |
volumes:
|
| 35 |
- ./jobs:/app/jobs
|
| 36 |
- ./data:/app/data
|
|
@@ -15,8 +15,8 @@ Usage
|
|
| 15 |
python main.py <path/to/saved_posts.json> --no-extract --transcribe # transcribe-only run
|
| 16 |
|
| 17 |
Output (written to --output-dir, default: same folder as the JSON file)
|
| 18 |
-
|
| 19 |
-
|
| 20 |
|
| 21 |
Environment
|
| 22 |
βββββββββββ
|
|
@@ -117,8 +117,8 @@ def main() -> None:
|
|
| 117 |
output_dir = Path(args.output_dir).resolve() if args.output_dir else input_path.parent
|
| 118 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 119 |
|
| 120 |
-
csv_path = output_dir / "
|
| 121 |
-
kml_path = output_dir / "
|
| 122 |
|
| 123 |
# Load .env starting from the input file's directory
|
| 124 |
_load_env(input_path.parent)
|
|
@@ -252,7 +252,7 @@ def main() -> None:
|
|
| 252 |
# ββ Step 2b: Manual overrides βββββββββββββββββββββββββββββββββββββββββββββ
|
| 253 |
import csv as _csv
|
| 254 |
from pipeline import override as override_mod
|
| 255 |
-
override_path = output_dir / "
|
| 256 |
with open(csv_path, encoding="utf-8") as f:
|
| 257 |
csv_rows = list(_csv.DictReader(f))
|
| 258 |
csv_rows, applied = override_mod.apply(csv_rows, override_path)
|
|
|
|
| 15 |
python main.py <path/to/saved_posts.json> --no-extract --transcribe # transcribe-only run
|
| 16 |
|
| 17 |
Output (written to --output-dir, default: same folder as the JSON file)
|
| 18 |
+
places_full.csv β one row per restaurant with coordinates + reel URL
|
| 19 |
+
places_map.kml β Google My Mapsβready KML organised by country βΊ city
|
| 20 |
|
| 21 |
Environment
|
| 22 |
βββββββββββ
|
|
|
|
| 117 |
output_dir = Path(args.output_dir).resolve() if args.output_dir else input_path.parent
|
| 118 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 119 |
|
| 120 |
+
csv_path = output_dir / "places_full.csv"
|
| 121 |
+
kml_path = output_dir / "places_map.kml"
|
| 122 |
|
| 123 |
# Load .env starting from the input file's directory
|
| 124 |
_load_env(input_path.parent)
|
|
|
|
| 252 |
# ββ Step 2b: Manual overrides βββββββββββββββββββββββββββββββββββββββββββββ
|
| 253 |
import csv as _csv
|
| 254 |
from pipeline import override as override_mod
|
| 255 |
+
override_path = output_dir / "places_override.csv"
|
| 256 |
with open(csv_path, encoding="utf-8") as f:
|
| 257 |
csv_rows = list(_csv.DictReader(f))
|
| 258 |
csv_rows, applied = override_mod.apply(csv_rows, override_path)
|
|
@@ -8,9 +8,18 @@ Flow:
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import json
|
|
|
|
| 11 |
from . import prefilter as prefilter_mod
|
| 12 |
|
| 13 |
CHUNK_WARN_THRESHOLD = 400 # show a warning above this many posts (fits in most free-tier windows)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
_PROMPT = """\
|
| 16 |
You are a place extraction assistant. The attached JSON file contains Instagram post captions and hashtags.
|
|
@@ -119,24 +128,68 @@ def _clean(val) -> str:
|
|
| 119 |
return "UNKNOWN" if not s or s.lower() in ("null", "unknown", "n/a", "") else s
|
| 120 |
|
| 121 |
|
| 122 |
-
def
|
| 123 |
-
"""
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
"""
|
| 128 |
text = json_text.strip()
|
| 129 |
-
# Strip markdown fences some chatbots add despite instructions
|
| 130 |
if text.startswith("```"):
|
| 131 |
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
| 132 |
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
rows: list[dict] = []
|
| 142 |
for item in data:
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import json
|
| 11 |
+
import re
|
| 12 |
from . import prefilter as prefilter_mod
|
| 13 |
|
| 14 |
CHUNK_WARN_THRESHOLD = 400 # show a warning above this many posts (fits in most free-tier windows)
|
| 15 |
+
CHUNK_SIZE = 350 # posts per downloadable file; comfortably under free-tier output limits
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def chunk_export(export_posts: list[dict], size: int = CHUNK_SIZE) -> list[list[dict]]:
|
| 19 |
+
"""Split the export into chunks small enough for a free chatbot to answer in one reply."""
|
| 20 |
+
if size <= 0 or not export_posts:
|
| 21 |
+
return [export_posts]
|
| 22 |
+
return [export_posts[i:i + size] for i in range(0, len(export_posts), size)]
|
| 23 |
|
| 24 |
_PROMPT = """\
|
| 25 |
You are a place extraction assistant. The attached JSON file contains Instagram post captions and hashtags.
|
|
|
|
| 128 |
return "UNKNOWN" if not s or s.lower() in ("null", "unknown", "n/a", "") else s
|
| 129 |
|
| 130 |
|
| 131 |
+
def _extract_json_array(json_text: str) -> list:
|
| 132 |
+
"""Best-effort extraction of a JSON array from a chatbot reply.
|
| 133 |
|
| 134 |
+
Tolerates: markdown fences, prose before/after the array, trailing commas,
|
| 135 |
+
and a response truncated mid-array (salvages the complete objects).
|
| 136 |
"""
|
| 137 |
text = json_text.strip()
|
|
|
|
| 138 |
if text.startswith("```"):
|
| 139 |
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
| 140 |
|
| 141 |
+
start = text.find("[")
|
| 142 |
+
end = text.rfind("]")
|
| 143 |
+
candidate = text[start:end + 1] if (start != -1 and end > start) else text
|
| 144 |
+
|
| 145 |
+
attempts = [
|
| 146 |
+
candidate,
|
| 147 |
+
re.sub(r",\s*([\]}])", r"\1", candidate), # drop trailing commas
|
| 148 |
+
]
|
| 149 |
+
# Salvage a truncated array: keep up to the last complete object, then close it
|
| 150 |
+
if start != -1:
|
| 151 |
+
last_obj = text.rfind("}")
|
| 152 |
+
if last_obj > start:
|
| 153 |
+
salvage = re.sub(r",\s*([\]}])", r"\1", text[start:last_obj + 1] + "]")
|
| 154 |
+
attempts.append(salvage)
|
| 155 |
+
|
| 156 |
+
for attempt in attempts:
|
| 157 |
+
try:
|
| 158 |
+
data = json.loads(attempt)
|
| 159 |
+
except json.JSONDecodeError:
|
| 160 |
+
continue
|
| 161 |
+
if isinstance(data, list):
|
| 162 |
+
return data
|
| 163 |
+
|
| 164 |
+
raise ValueError(
|
| 165 |
+
"Could not read a JSON array from the response. Paste the chatbot's full "
|
| 166 |
+
"reply β it should start with [ and end with ]."
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
|
| 170 |
+
def parse_responses(texts: list[str], post_mapping: dict) -> list[dict]:
|
| 171 |
+
"""Parse and merge one or more chatbot replies (one per export chunk)."""
|
| 172 |
+
seen: set = set()
|
| 173 |
+
merged: list[dict] = []
|
| 174 |
+
for t in texts:
|
| 175 |
+
if not t or not t.strip():
|
| 176 |
+
continue
|
| 177 |
+
for row in parse_response(t, post_mapping):
|
| 178 |
+
dedup = row.get("instagram_url") or id(row)
|
| 179 |
+
if dedup in seen:
|
| 180 |
+
continue
|
| 181 |
+
seen.add(dedup)
|
| 182 |
+
merged.append(row)
|
| 183 |
+
return merged
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def parse_response(json_text: str, post_mapping: dict) -> list[dict]:
|
| 187 |
+
"""Parse one chatbot JSON reply into standard row dicts.
|
| 188 |
+
|
| 189 |
+
Raises ValueError if no JSON array can be recovered.
|
| 190 |
+
Non-place entries and entries with no useful data are silently dropped.
|
| 191 |
+
"""
|
| 192 |
+
data = _extract_json_array(json_text)
|
| 193 |
|
| 194 |
rows: list[dict] = []
|
| 195 |
for item in data:
|
|
@@ -228,5 +228,5 @@ def run(input_csv: str, output_kml: str) -> None:
|
|
| 228 |
print(f"Wrote {output_kml} ({pinned}/{len(rows)} rows have coordinates)")
|
| 229 |
print("\nTo import into Google My Maps:")
|
| 230 |
print(" 1. mymaps.google.com β Create a new map")
|
| 231 |
-
print(" 2. Click 'Import' on the base layer β upload
|
| 232 |
print(" 3. Rename the map 'Instagram Saved Places'")
|
|
|
|
| 228 |
print(f"Wrote {output_kml} ({pinned}/{len(rows)} rows have coordinates)")
|
| 229 |
print("\nTo import into Google My Maps:")
|
| 230 |
print(" 1. mymaps.google.com β Create a new map")
|
| 231 |
+
print(" 2. Click 'Import' on the base layer β upload places_map.kml")
|
| 232 |
print(" 3. Rename the map 'Instagram Saved Places'")
|
|
@@ -18,16 +18,47 @@ Blank is always better than a wrong pin.
|
|
| 18 |
import csv
|
| 19 |
import json
|
| 20 |
import math
|
|
|
|
| 21 |
import ssl
|
| 22 |
import time
|
| 23 |
import urllib.request
|
| 24 |
import urllib.parse
|
|
|
|
| 25 |
|
| 26 |
import certifi
|
| 27 |
|
| 28 |
SSL_CTX = ssl.create_default_context(cafile=certifi.where())
|
| 29 |
MAX_ADDRESS_KM = 50 # max distance from the expected city centre for address hits
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
# ββ Country normalisation βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
|
|
@@ -131,8 +162,8 @@ def _city_centre(city: str, country_code: str | None) -> tuple[float, float] | N
|
|
| 131 |
query = city if not country_code else f"{city}, {country_code}"
|
| 132 |
params = urllib.parse.urlencode({"q": query, "format": "json", "limit": 1, "addressdetails": 0})
|
| 133 |
req = urllib.request.Request(
|
| 134 |
-
f"
|
| 135 |
-
headers={"User-Agent":
|
| 136 |
)
|
| 137 |
try:
|
| 138 |
with urllib.request.urlopen(req, timeout=10, context=SSL_CTX) as resp:
|
|
@@ -164,8 +195,8 @@ def _nominatim(query: str, expected_country: str, expected_city: str) -> tuple[f
|
|
| 164 |
"addressdetails": 1,
|
| 165 |
})
|
| 166 |
req = urllib.request.Request(
|
| 167 |
-
f"
|
| 168 |
-
headers={"User-Agent":
|
| 169 |
)
|
| 170 |
try:
|
| 171 |
with urllib.request.urlopen(req, timeout=10, context=SSL_CTX) as resp:
|
|
@@ -272,6 +303,13 @@ def geocode_one(row: dict) -> tuple[str, str]:
|
|
| 272 |
|
| 273 |
def run(input_csv: str, output_csv: str | None = None) -> list[dict]:
|
| 274 |
"""Geocode every row in input_csv and write results to output_csv (or in-place)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
target = output_csv or input_csv
|
| 276 |
|
| 277 |
with open(input_csv, encoding="utf-8") as f:
|
|
@@ -279,6 +317,8 @@ def run(input_csv: str, output_csv: str | None = None) -> list[dict]:
|
|
| 279 |
|
| 280 |
print(f"Loaded {len(rows)} rows. Geocoding with country + city + proximity validation...\n")
|
| 281 |
|
|
|
|
|
|
|
| 282 |
for row in rows:
|
| 283 |
row["lat"] = ""
|
| 284 |
row["lng"] = ""
|
|
@@ -287,21 +327,30 @@ def run(input_csv: str, output_csv: str | None = None) -> list[dict]:
|
|
| 287 |
name = row.get("name", "")
|
| 288 |
city = row.get("city", "")
|
| 289 |
country = row.get("country", "")
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
city,
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
row["lat"] = f"{lat:.7f}" if lat is not None else ""
|
| 300 |
row["lng"] = f"{lng:.7f}" if lng is not None else ""
|
| 301 |
|
| 302 |
status = f"β {lat:.4f}, {lng:.4f}" if lat is not None else "β not found (left blank)"
|
| 303 |
print(f" {status}")
|
| 304 |
|
|
|
|
|
|
|
| 305 |
with open(target, "w", newline="", encoding="utf-8") as f:
|
| 306 |
writer = csv.DictWriter(f, fieldnames=rows[0].keys())
|
| 307 |
writer.writeheader()
|
|
|
|
| 18 |
import csv
|
| 19 |
import json
|
| 20 |
import math
|
| 21 |
+
import os
|
| 22 |
import ssl
|
| 23 |
import time
|
| 24 |
import urllib.request
|
| 25 |
import urllib.parse
|
| 26 |
+
from pathlib import Path
|
| 27 |
|
| 28 |
import certifi
|
| 29 |
|
| 30 |
SSL_CTX = ssl.create_default_context(cafile=certifi.where())
|
| 31 |
MAX_ADDRESS_KM = 50 # max distance from the expected city centre for address hits
|
| 32 |
|
| 33 |
+
# Geocoding endpoint. Defaults to public OSM Nominatim for local use; point at a
|
| 34 |
+
# self-hosted instance for any shared/hosted deployment (OSM forbids bulk use).
|
| 35 |
+
NOMINATIM_URL = os.environ.get("NOMINATIM_URL", "https://nominatim.openstreetmap.org").rstrip("/")
|
| 36 |
+
_PUBLIC_NOMINATIM = "nominatim.openstreetmap.org"
|
| 37 |
+
USER_AGENT = "InstagramPlacesMapper/1.0 (+https://github.com/)"
|
| 38 |
+
|
| 39 |
+
# Persistent placeβcoords cache so duplicate venues and re-runs skip the network.
|
| 40 |
+
GEOCODE_CACHE_PATH = Path(
|
| 41 |
+
os.environ.get("GEOCODE_CACHE", str(Path(__file__).parent.parent / ".geocode_cache.json"))
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _cache_key(name: str, city: str, state: str, country: str, address: str) -> str:
|
| 46 |
+
return "|".join(s.strip().lower() for s in (name, city, state, country, address))
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _load_cache() -> dict:
|
| 50 |
+
try:
|
| 51 |
+
return json.loads(GEOCODE_CACHE_PATH.read_text(encoding="utf-8"))
|
| 52 |
+
except (OSError, json.JSONDecodeError):
|
| 53 |
+
return {}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _save_cache(cache: dict) -> None:
|
| 57 |
+
try:
|
| 58 |
+
GEOCODE_CACHE_PATH.write_text(json.dumps(cache), encoding="utf-8")
|
| 59 |
+
except OSError:
|
| 60 |
+
pass
|
| 61 |
+
|
| 62 |
|
| 63 |
# ββ Country normalisation βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
|
|
|
|
| 162 |
query = city if not country_code else f"{city}, {country_code}"
|
| 163 |
params = urllib.parse.urlencode({"q": query, "format": "json", "limit": 1, "addressdetails": 0})
|
| 164 |
req = urllib.request.Request(
|
| 165 |
+
f"{NOMINATIM_URL}/search?{params}",
|
| 166 |
+
headers={"User-Agent": USER_AGENT},
|
| 167 |
)
|
| 168 |
try:
|
| 169 |
with urllib.request.urlopen(req, timeout=10, context=SSL_CTX) as resp:
|
|
|
|
| 195 |
"addressdetails": 1,
|
| 196 |
})
|
| 197 |
req = urllib.request.Request(
|
| 198 |
+
f"{NOMINATIM_URL}/search?{params}",
|
| 199 |
+
headers={"User-Agent": USER_AGENT},
|
| 200 |
)
|
| 201 |
try:
|
| 202 |
with urllib.request.urlopen(req, timeout=10, context=SSL_CTX) as resp:
|
|
|
|
| 303 |
|
| 304 |
def run(input_csv: str, output_csv: str | None = None) -> list[dict]:
|
| 305 |
"""Geocode every row in input_csv and write results to output_csv (or in-place)."""
|
| 306 |
+
if os.environ.get("HOSTED", "").lower() in ("1", "true", "yes") and _PUBLIC_NOMINATIM in NOMINATIM_URL:
|
| 307 |
+
raise RuntimeError(
|
| 308 |
+
"Refusing to bulk-geocode against public Nominatim in hosted mode. "
|
| 309 |
+
"Set NOMINATIM_URL to a self-hosted instance β OSM's usage policy "
|
| 310 |
+
"forbids bulk automated use of the public endpoint."
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
target = output_csv or input_csv
|
| 314 |
|
| 315 |
with open(input_csv, encoding="utf-8") as f:
|
|
|
|
| 317 |
|
| 318 |
print(f"Loaded {len(rows)} rows. Geocoding with country + city + proximity validation...\n")
|
| 319 |
|
| 320 |
+
cache = _load_cache()
|
| 321 |
+
|
| 322 |
for row in rows:
|
| 323 |
row["lat"] = ""
|
| 324 |
row["lng"] = ""
|
|
|
|
| 327 |
name = row.get("name", "")
|
| 328 |
city = row.get("city", "")
|
| 329 |
country = row.get("country", "")
|
| 330 |
+
|
| 331 |
+
key = _cache_key(name, city, row.get("state", ""), country, row.get("address", ""))
|
| 332 |
+
if key in cache:
|
| 333 |
+
lat, lng = cache[key]
|
| 334 |
+
print(f" [{i}/{len(rows)}] {name} β {city}, {country} (cached)")
|
| 335 |
+
else:
|
| 336 |
+
print(f" [{i}/{len(rows)}] {name} β {city}, {country}")
|
| 337 |
+
lat, lng = geocode_row(
|
| 338 |
+
name,
|
| 339 |
+
city,
|
| 340 |
+
row.get("state", ""),
|
| 341 |
+
country,
|
| 342 |
+
row.get("address", ""),
|
| 343 |
+
)
|
| 344 |
+
cache[key] = [lat, lng]
|
| 345 |
+
|
| 346 |
row["lat"] = f"{lat:.7f}" if lat is not None else ""
|
| 347 |
row["lng"] = f"{lng:.7f}" if lng is not None else ""
|
| 348 |
|
| 349 |
status = f"β {lat:.4f}, {lng:.4f}" if lat is not None else "β not found (left blank)"
|
| 350 |
print(f" {status}")
|
| 351 |
|
| 352 |
+
_save_cache(cache)
|
| 353 |
+
|
| 354 |
with open(target, "w", newline="", encoding="utf-8") as f:
|
| 355 |
writer = csv.DictWriter(f, fieldnames=rows[0].keys())
|
| 356 |
writer.writeheader()
|
|
@@ -2,7 +2,7 @@
|
|
| 2 |
Manual override for restaurants that geocoding couldn't place.
|
| 3 |
|
| 4 |
Workflow:
|
| 5 |
-
1. After geocoding,
|
| 6 |
with all rows that still have no lat/lng.
|
| 7 |
2. User opens it, finds the restaurant on Google Maps, and pastes the URL
|
| 8 |
into the maps_url column (or enters lat/lng directly).
|
|
|
|
| 2 |
Manual override for restaurants that geocoding couldn't place.
|
| 3 |
|
| 4 |
Workflow:
|
| 5 |
+
1. After geocoding, places_override.csv is generated (or updated)
|
| 6 |
with all rows that still have no lat/lng.
|
| 7 |
2. User opens it, finds the restaurant on Google Maps, and pastes the URL
|
| 8 |
into the maps_url column (or enters lat/lng directly).
|
|
@@ -6,7 +6,7 @@ For each post URL not already in the extracted CSV, this module:
|
|
| 6 |
2. Transcribes it with Whisper (local, free β requires openai-whisper + torch)
|
| 7 |
3. Re-runs Claude extraction on the transcript
|
| 8 |
4. Appends new finds to the extracted CSV
|
| 9 |
-
5. Writes
|
| 10 |
so the user can manually review and add them
|
| 11 |
|
| 12 |
Install extra deps first:
|
|
@@ -144,7 +144,7 @@ def run(
|
|
| 144 |
|
| 145 |
New rows are appended to extracted_csv with lat/lng blank.
|
| 146 |
All posts that couldn't be downloaded or weren't food are written to
|
| 147 |
-
|
| 148 |
|
| 149 |
Download failures are never checkpointed β they always retry so that
|
| 150 |
adding browser auth takes effect without clearing the checkpoint.
|
|
@@ -157,7 +157,7 @@ def run(
|
|
| 157 |
return
|
| 158 |
|
| 159 |
checkpoint_path = Path(str(Path(extracted_csv).with_suffix("")) + _CHECKPOINT_SUFFIX)
|
| 160 |
-
skipped_csv_path = Path(extracted_csv).parent / "
|
| 161 |
|
| 162 |
checkpoint = _load_checkpoint(checkpoint_path) if resume else {}
|
| 163 |
already_done = sum(1 for u in skipped if u["url"] in checkpoint)
|
|
@@ -172,7 +172,7 @@ def run(
|
|
| 172 |
client = anthropic.Anthropic()
|
| 173 |
|
| 174 |
new_rows: list[dict] = []
|
| 175 |
-
skipped_records: list[dict] = [] # for
|
| 176 |
|
| 177 |
print()
|
| 178 |
bar = tqdm(skipped, desc="Transcribing reels", unit="reel", dynamic_ncols=True)
|
|
|
|
| 6 |
2. Transcribes it with Whisper (local, free β requires openai-whisper + torch)
|
| 7 |
3. Re-runs Claude extraction on the transcript
|
| 8 |
4. Appends new finds to the extracted CSV
|
| 9 |
+
5. Writes places_skipped.csv with URLs that failed or were not food,
|
| 10 |
so the user can manually review and add them
|
| 11 |
|
| 12 |
Install extra deps first:
|
|
|
|
| 144 |
|
| 145 |
New rows are appended to extracted_csv with lat/lng blank.
|
| 146 |
All posts that couldn't be downloaded or weren't food are written to
|
| 147 |
+
places_skipped.csv for manual review.
|
| 148 |
|
| 149 |
Download failures are never checkpointed β they always retry so that
|
| 150 |
adding browser auth takes effect without clearing the checkpoint.
|
|
|
|
| 157 |
return
|
| 158 |
|
| 159 |
checkpoint_path = Path(str(Path(extracted_csv).with_suffix("")) + _CHECKPOINT_SUFFIX)
|
| 160 |
+
skipped_csv_path = Path(extracted_csv).parent / "places_skipped.csv"
|
| 161 |
|
| 162 |
checkpoint = _load_checkpoint(checkpoint_path) if resume else {}
|
| 163 |
already_done = sum(1 for u in skipped if u["url"] in checkpoint)
|
|
|
|
| 172 |
client = anthropic.Anthropic()
|
| 173 |
|
| 174 |
new_rows: list[dict] = []
|
| 175 |
+
skipped_records: list[dict] = [] # for places_skipped.csv
|
| 176 |
|
| 177 |
print()
|
| 178 |
bar = tqdm(skipped, desc="Transcribing reels", unit="reel", dynamic_ncols=True)
|
|
@@ -37,6 +37,7 @@ JOBS_DIR.mkdir(exist_ok=True)
|
|
| 37 |
|
| 38 |
OLLAMA_ENABLED = os.environ.get("OLLAMA_ENABLED", "true").lower() not in ("0", "false", "no")
|
| 39 |
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
|
|
|
| 40 |
|
| 41 |
_BENCHMARK_RESULTS_PATH = Path(__file__).parent.parent / "tests" / "benchmark_results.json"
|
| 42 |
|
|
@@ -48,17 +49,66 @@ def _load_benchmark_scores() -> dict:
|
|
| 48 |
except (FileNotFoundError, json.JSONDecodeError):
|
| 49 |
return {}
|
| 50 |
|
| 51 |
-
app = FastAPI(title="Instagram
|
| 52 |
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
| 53 |
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
_jobs: dict[str, dict] = {}
|
| 56 |
_lock = threading.Lock()
|
| 57 |
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
def _update(job_id: str, **kwargs) -> None:
|
| 60 |
with _lock:
|
| 61 |
_jobs[job_id].update(kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
|
|
@@ -75,9 +125,9 @@ def _run_pipeline(job_id: str, json_bytes: bytes, model: str,
|
|
| 75 |
job_dir = JOBS_DIR / job_id
|
| 76 |
job_dir.mkdir(exist_ok=True)
|
| 77 |
json_path = job_dir / "saved_posts.json"
|
| 78 |
-
csv_path = job_dir / "
|
| 79 |
-
kml_path = job_dir / "
|
| 80 |
-
override_path = job_dir / "
|
| 81 |
|
| 82 |
try:
|
| 83 |
json_path.write_bytes(json_bytes)
|
|
@@ -107,7 +157,7 @@ def _run_pipeline(job_id: str, json_bytes: bytes, model: str,
|
|
| 107 |
extracted = list(csv.DictReader(f))
|
| 108 |
|
| 109 |
_update(job_id, step="geocode", progress=55,
|
| 110 |
-
message=f"Found {len(extracted)}
|
| 111 |
extracted=len(extracted))
|
| 112 |
|
| 113 |
# ββ Geocode ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -143,9 +193,9 @@ def _run_pipeline(job_id: str, json_bytes: bytes, model: str,
|
|
| 143 |
def _run_geocode_export(job_id: str) -> None:
|
| 144 |
"""Geocode + export an already-written CSV (no extraction step)."""
|
| 145 |
job_dir = JOBS_DIR / job_id
|
| 146 |
-
csv_path = job_dir / "
|
| 147 |
-
kml_path = job_dir / "
|
| 148 |
-
override_path = job_dir / "
|
| 149 |
try:
|
| 150 |
_update(job_id, step="geocode", progress=30,
|
| 151 |
message="Geocoding placesβ¦")
|
|
@@ -192,7 +242,7 @@ class RegeocodeRequest(BaseModel):
|
|
| 192 |
|
| 193 |
|
| 194 |
def _csv_path(job_id: str) -> Path:
|
| 195 |
-
return JOBS_DIR / job_id / "
|
| 196 |
|
| 197 |
|
| 198 |
def _read_csv(job_id: str) -> list[dict] | None:
|
|
@@ -265,10 +315,13 @@ async def upload(
|
|
| 265 |
active_model = model if model in MODELS else DEFAULT_MODEL
|
| 266 |
|
| 267 |
contents = await file.read()
|
|
|
|
| 268 |
job_id = uuid.uuid4().hex
|
| 269 |
|
|
|
|
| 270 |
with _lock:
|
| 271 |
-
_jobs[job_id] =
|
|
|
|
| 272 |
|
| 273 |
thread = threading.Thread(
|
| 274 |
target=_run_pipeline,
|
|
@@ -287,8 +340,7 @@ async def progress(job_id: str):
|
|
| 287 |
async def _generate():
|
| 288 |
import asyncio
|
| 289 |
while True:
|
| 290 |
-
|
| 291 |
-
state = dict(_jobs.get(job_id, {"step": "unknown", "message": "Job not found"}))
|
| 292 |
yield f"data: {json.dumps(state)}\n\n"
|
| 293 |
if state.get("step") in ("done", "error"):
|
| 294 |
break
|
|
@@ -300,11 +352,11 @@ async def progress(job_id: str):
|
|
| 300 |
|
| 301 |
@app.get("/download/{job_id}")
|
| 302 |
async def download(job_id: str):
|
| 303 |
-
kml = JOBS_DIR / job_id / "
|
| 304 |
if not kml.exists():
|
| 305 |
return HTMLResponse("Not found", status_code=404)
|
| 306 |
return FileResponse(str(kml), media_type="application/vnd.google-earth.kml+xml",
|
| 307 |
-
filename="
|
| 308 |
|
| 309 |
|
| 310 |
@app.get("/download/{job_id}/geojson")
|
|
@@ -316,7 +368,7 @@ async def download_geojson(job_id: str):
|
|
| 316 |
return Response(
|
| 317 |
content=geojson_str,
|
| 318 |
media_type="application/geo+json",
|
| 319 |
-
headers={"Content-Disposition": 'attachment; filename="
|
| 320 |
)
|
| 321 |
|
| 322 |
|
|
@@ -326,7 +378,7 @@ async def download_csv(job_id: str):
|
|
| 326 |
if not csv_p.exists():
|
| 327 |
return HTMLResponse("Not found", status_code=404)
|
| 328 |
return FileResponse(str(csv_p), media_type="text/csv",
|
| 329 |
-
filename="
|
| 330 |
|
| 331 |
|
| 332 |
@app.get("/results/{job_id}")
|
|
@@ -383,7 +435,7 @@ async def reexport(job_id: str):
|
|
| 383 |
csv_p = _csv_path(job_id)
|
| 384 |
if not csv_p.exists():
|
| 385 |
return JSONResponse({"error": "Job not found"}, status_code=404)
|
| 386 |
-
kml_p = JOBS_DIR / job_id / "
|
| 387 |
|
| 388 |
def _run():
|
| 389 |
with contextlib.redirect_stdout(io.StringIO()):
|
|
@@ -394,7 +446,8 @@ async def reexport(job_id: str):
|
|
| 394 |
|
| 395 |
|
| 396 |
class ChatbotProcessRequest(BaseModel):
|
| 397 |
-
response_json: str
|
|
|
|
| 398 |
|
| 399 |
|
| 400 |
@app.post("/chatbot-prepare")
|
|
@@ -402,6 +455,7 @@ async def chatbot_prepare(file: UploadFile = File(...)):
|
|
| 402 |
"""Receive an Instagram JSON/zip, run prefilter, return the chatbot export package."""
|
| 403 |
import zipfile as _zipfile
|
| 404 |
contents = await file.read()
|
|
|
|
| 405 |
|
| 406 |
if file.filename and file.filename.lower().endswith(".zip"):
|
| 407 |
try:
|
|
@@ -432,8 +486,10 @@ async def chatbot_prepare(file: UploadFile = File(...)):
|
|
| 432 |
json.dumps(result["post_mapping"]), encoding="utf-8"
|
| 433 |
)
|
| 434 |
|
|
|
|
| 435 |
with _lock:
|
| 436 |
-
_jobs[job_id] =
|
|
|
|
| 437 |
|
| 438 |
return {
|
| 439 |
"job_id": job_id,
|
|
@@ -443,6 +499,8 @@ async def chatbot_prepare(file: UploadFile = File(...)):
|
|
| 443 |
"skipped": result["skipped"],
|
| 444 |
"prompt": chatbot_mod.get_prompt(len(result["export_posts"])),
|
| 445 |
"warn_large": len(result["export_posts"]) > chatbot_mod.CHUNK_WARN_THRESHOLD,
|
|
|
|
|
|
|
| 446 |
}
|
| 447 |
|
| 448 |
|
|
@@ -459,8 +517,9 @@ async def chatbot_process(job_id: str, body: ChatbotProcessRequest):
|
|
| 459 |
post_mapping = json.load(f)
|
| 460 |
|
| 461 |
from pipeline import chatbot as chatbot_mod
|
|
|
|
| 462 |
try:
|
| 463 |
-
rows = chatbot_mod.
|
| 464 |
except ValueError as exc:
|
| 465 |
return JSONResponse({"error": str(exc)}, status_code=422)
|
| 466 |
|
|
@@ -470,7 +529,7 @@ async def chatbot_process(job_id: str, body: ChatbotProcessRequest):
|
|
| 470 |
status_code=422,
|
| 471 |
)
|
| 472 |
|
| 473 |
-
csv_path = job_dir / "
|
| 474 |
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
| 475 |
writer = csv.DictWriter(f, fieldnames=FIELDNAMES)
|
| 476 |
writer.writeheader()
|
|
@@ -490,24 +549,19 @@ async def chatbot_process(job_id: str, body: ChatbotProcessRequest):
|
|
| 490 |
async def roulette(job_id: str = "", lat: float = None, lng: float = None,
|
| 491 |
radius_km: float = 40, city: str = "", all_statuses: bool = False,
|
| 492 |
category: str = ""):
|
| 493 |
-
"""Pick a random
|
| 494 |
-
import random
|
| 495 |
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
if p.exists():
|
| 501 |
-
csv_path = p
|
| 502 |
|
| 503 |
-
if
|
| 504 |
-
|
| 505 |
-
key=lambda p: p.stat().st_mtime, reverse=True)
|
| 506 |
-
if candidates:
|
| 507 |
-
csv_path = candidates[0]
|
| 508 |
|
| 509 |
-
|
| 510 |
-
|
|
|
|
| 511 |
|
| 512 |
with open(csv_path, encoding="utf-8") as f:
|
| 513 |
rows = [r for r in csv.DictReader(f)
|
|
@@ -517,7 +571,7 @@ async def roulette(job_id: str = "", lat: float = None, lng: float = None,
|
|
| 517 |
if city:
|
| 518 |
rows = [r for r in rows if r.get("city", "").strip().lower() == city.lower()]
|
| 519 |
if not rows:
|
| 520 |
-
return {"error": f"No
|
| 521 |
elif lat is not None and lng is not None:
|
| 522 |
def _ok(r):
|
| 523 |
try:
|
|
|
|
| 37 |
|
| 38 |
OLLAMA_ENABLED = os.environ.get("OLLAMA_ENABLED", "true").lower() not in ("0", "false", "no")
|
| 39 |
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
| 40 |
+
JOB_TTL_HOURS = float(os.environ.get("JOB_TTL_HOURS", "24"))
|
| 41 |
|
| 42 |
_BENCHMARK_RESULTS_PATH = Path(__file__).parent.parent / "tests" / "benchmark_results.json"
|
| 43 |
|
|
|
|
| 49 |
except (FileNotFoundError, json.JSONDecodeError):
|
| 50 |
return {}
|
| 51 |
|
| 52 |
+
app = FastAPI(title="Instagram Places Mapper")
|
| 53 |
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
| 54 |
|
| 55 |
+
|
| 56 |
+
@app.on_event("startup")
|
| 57 |
+
def _on_startup() -> None:
|
| 58 |
+
_cleanup_old_jobs()
|
| 59 |
+
|
| 60 |
+
# In-memory job state, mirrored to <job_dir>/state.json so it survives a restart.
|
| 61 |
_jobs: dict[str, dict] = {}
|
| 62 |
_lock = threading.Lock()
|
| 63 |
|
| 64 |
|
| 65 |
+
def _state_path(job_id: str) -> Path:
|
| 66 |
+
return JOBS_DIR / job_id / "state.json"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _persist_state(job_id: str, state: dict) -> None:
|
| 70 |
+
"""Mirror a job's state to disk so the progress endpoint survives a restart."""
|
| 71 |
+
try:
|
| 72 |
+
job_dir = JOBS_DIR / job_id
|
| 73 |
+
job_dir.mkdir(exist_ok=True)
|
| 74 |
+
_state_path(job_id).write_text(json.dumps(state), encoding="utf-8")
|
| 75 |
+
except OSError:
|
| 76 |
+
pass # disk mirror is best-effort; in-memory state is authoritative
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _get_state(job_id: str) -> dict:
|
| 80 |
+
"""Return a job's state from memory, falling back to the on-disk mirror."""
|
| 81 |
+
with _lock:
|
| 82 |
+
if job_id in _jobs:
|
| 83 |
+
return dict(_jobs[job_id])
|
| 84 |
+
try:
|
| 85 |
+
return json.loads(_state_path(job_id).read_text(encoding="utf-8"))
|
| 86 |
+
except (OSError, json.JSONDecodeError):
|
| 87 |
+
return {"step": "unknown", "message": "Job not found"}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
def _update(job_id: str, **kwargs) -> None:
|
| 91 |
with _lock:
|
| 92 |
_jobs[job_id].update(kwargs)
|
| 93 |
+
snapshot = dict(_jobs[job_id])
|
| 94 |
+
_persist_state(job_id, snapshot)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _cleanup_old_jobs() -> None:
|
| 98 |
+
"""Delete job directories older than JOB_TTL_HOURS (best-effort)."""
|
| 99 |
+
import shutil
|
| 100 |
+
import time as _time
|
| 101 |
+
cutoff = _time.time() - JOB_TTL_HOURS * 3600
|
| 102 |
+
for d in JOBS_DIR.glob("*"):
|
| 103 |
+
if not d.is_dir():
|
| 104 |
+
continue
|
| 105 |
+
try:
|
| 106 |
+
if d.stat().st_mtime < cutoff:
|
| 107 |
+
shutil.rmtree(d, ignore_errors=True)
|
| 108 |
+
with _lock:
|
| 109 |
+
_jobs.pop(d.name, None)
|
| 110 |
+
except OSError:
|
| 111 |
+
pass
|
| 112 |
|
| 113 |
|
| 114 |
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
|
|
|
|
| 125 |
job_dir = JOBS_DIR / job_id
|
| 126 |
job_dir.mkdir(exist_ok=True)
|
| 127 |
json_path = job_dir / "saved_posts.json"
|
| 128 |
+
csv_path = job_dir / "places_full.csv"
|
| 129 |
+
kml_path = job_dir / "places_map.kml"
|
| 130 |
+
override_path = job_dir / "places_override.csv"
|
| 131 |
|
| 132 |
try:
|
| 133 |
json_path.write_bytes(json_bytes)
|
|
|
|
| 157 |
extracted = list(csv.DictReader(f))
|
| 158 |
|
| 159 |
_update(job_id, step="geocode", progress=55,
|
| 160 |
+
message=f"Found {len(extracted)} places β geocodingβ¦",
|
| 161 |
extracted=len(extracted))
|
| 162 |
|
| 163 |
# ββ Geocode ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 193 |
def _run_geocode_export(job_id: str) -> None:
|
| 194 |
"""Geocode + export an already-written CSV (no extraction step)."""
|
| 195 |
job_dir = JOBS_DIR / job_id
|
| 196 |
+
csv_path = job_dir / "places_full.csv"
|
| 197 |
+
kml_path = job_dir / "places_map.kml"
|
| 198 |
+
override_path = job_dir / "places_override.csv"
|
| 199 |
try:
|
| 200 |
_update(job_id, step="geocode", progress=30,
|
| 201 |
message="Geocoding placesβ¦")
|
|
|
|
| 242 |
|
| 243 |
|
| 244 |
def _csv_path(job_id: str) -> Path:
|
| 245 |
+
return JOBS_DIR / job_id / "places_full.csv"
|
| 246 |
|
| 247 |
|
| 248 |
def _read_csv(job_id: str) -> list[dict] | None:
|
|
|
|
| 315 |
active_model = model if model in MODELS else DEFAULT_MODEL
|
| 316 |
|
| 317 |
contents = await file.read()
|
| 318 |
+
_cleanup_old_jobs()
|
| 319 |
job_id = uuid.uuid4().hex
|
| 320 |
|
| 321 |
+
initial = {"step": "queued", "progress": 0, "message": "Startingβ¦"}
|
| 322 |
with _lock:
|
| 323 |
+
_jobs[job_id] = initial
|
| 324 |
+
_persist_state(job_id, initial)
|
| 325 |
|
| 326 |
thread = threading.Thread(
|
| 327 |
target=_run_pipeline,
|
|
|
|
| 340 |
async def _generate():
|
| 341 |
import asyncio
|
| 342 |
while True:
|
| 343 |
+
state = _get_state(job_id)
|
|
|
|
| 344 |
yield f"data: {json.dumps(state)}\n\n"
|
| 345 |
if state.get("step") in ("done", "error"):
|
| 346 |
break
|
|
|
|
| 352 |
|
| 353 |
@app.get("/download/{job_id}")
|
| 354 |
async def download(job_id: str):
|
| 355 |
+
kml = JOBS_DIR / job_id / "places_map.kml"
|
| 356 |
if not kml.exists():
|
| 357 |
return HTMLResponse("Not found", status_code=404)
|
| 358 |
return FileResponse(str(kml), media_type="application/vnd.google-earth.kml+xml",
|
| 359 |
+
filename="places_map.kml")
|
| 360 |
|
| 361 |
|
| 362 |
@app.get("/download/{job_id}/geojson")
|
|
|
|
| 368 |
return Response(
|
| 369 |
content=geojson_str,
|
| 370 |
media_type="application/geo+json",
|
| 371 |
+
headers={"Content-Disposition": 'attachment; filename="places_map.geojson"'},
|
| 372 |
)
|
| 373 |
|
| 374 |
|
|
|
|
| 378 |
if not csv_p.exists():
|
| 379 |
return HTMLResponse("Not found", status_code=404)
|
| 380 |
return FileResponse(str(csv_p), media_type="text/csv",
|
| 381 |
+
filename="places_full.csv")
|
| 382 |
|
| 383 |
|
| 384 |
@app.get("/results/{job_id}")
|
|
|
|
| 435 |
csv_p = _csv_path(job_id)
|
| 436 |
if not csv_p.exists():
|
| 437 |
return JSONResponse({"error": "Job not found"}, status_code=404)
|
| 438 |
+
kml_p = JOBS_DIR / job_id / "places_map.kml"
|
| 439 |
|
| 440 |
def _run():
|
| 441 |
with contextlib.redirect_stdout(io.StringIO()):
|
|
|
|
| 446 |
|
| 447 |
|
| 448 |
class ChatbotProcessRequest(BaseModel):
|
| 449 |
+
response_json: str = ""
|
| 450 |
+
responses: list[str] | None = None # one per export chunk (preferred)
|
| 451 |
|
| 452 |
|
| 453 |
@app.post("/chatbot-prepare")
|
|
|
|
| 455 |
"""Receive an Instagram JSON/zip, run prefilter, return the chatbot export package."""
|
| 456 |
import zipfile as _zipfile
|
| 457 |
contents = await file.read()
|
| 458 |
+
_cleanup_old_jobs()
|
| 459 |
|
| 460 |
if file.filename and file.filename.lower().endswith(".zip"):
|
| 461 |
try:
|
|
|
|
| 486 |
json.dumps(result["post_mapping"]), encoding="utf-8"
|
| 487 |
)
|
| 488 |
|
| 489 |
+
pending = {"step": "chatbot_pending", "progress": 0, "message": "Waiting for chatbot responseβ¦"}
|
| 490 |
with _lock:
|
| 491 |
+
_jobs[job_id] = pending
|
| 492 |
+
_persist_state(job_id, pending)
|
| 493 |
|
| 494 |
return {
|
| 495 |
"job_id": job_id,
|
|
|
|
| 499 |
"skipped": result["skipped"],
|
| 500 |
"prompt": chatbot_mod.get_prompt(len(result["export_posts"])),
|
| 501 |
"warn_large": len(result["export_posts"]) > chatbot_mod.CHUNK_WARN_THRESHOLD,
|
| 502 |
+
"chunk_size": chatbot_mod.CHUNK_SIZE,
|
| 503 |
+
"chunk_count": len(chatbot_mod.chunk_export(result["export_posts"])),
|
| 504 |
}
|
| 505 |
|
| 506 |
|
|
|
|
| 517 |
post_mapping = json.load(f)
|
| 518 |
|
| 519 |
from pipeline import chatbot as chatbot_mod
|
| 520 |
+
texts = body.responses if body.responses else [body.response_json]
|
| 521 |
try:
|
| 522 |
+
rows = chatbot_mod.parse_responses(texts, post_mapping)
|
| 523 |
except ValueError as exc:
|
| 524 |
return JSONResponse({"error": str(exc)}, status_code=422)
|
| 525 |
|
|
|
|
| 529 |
status_code=422,
|
| 530 |
)
|
| 531 |
|
| 532 |
+
csv_path = job_dir / "places_full.csv"
|
| 533 |
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
| 534 |
writer = csv.DictWriter(f, fieldnames=FIELDNAMES)
|
| 535 |
writer.writeheader()
|
|
|
|
| 549 |
async def roulette(job_id: str = "", lat: float = None, lng: float = None,
|
| 550 |
radius_km: float = 40, city: str = "", all_statuses: bool = False,
|
| 551 |
category: str = ""):
|
| 552 |
+
"""Pick a random place from a specific job's CSV.
|
|
|
|
| 553 |
|
| 554 |
+
A job_id is required β there is deliberately no "newest job wins" fallback,
|
| 555 |
+
which would leak one user's places to another on a shared deployment.
|
| 556 |
+
"""
|
| 557 |
+
import random
|
|
|
|
|
|
|
| 558 |
|
| 559 |
+
if not job_id:
|
| 560 |
+
return {"error": "No job selected. Run a pipeline first."}
|
|
|
|
|
|
|
|
|
|
| 561 |
|
| 562 |
+
csv_path = JOBS_DIR / job_id / "places_full.csv"
|
| 563 |
+
if not csv_path.exists():
|
| 564 |
+
return {"error": "No place data for this job yet. Run a pipeline first."}
|
| 565 |
|
| 566 |
with open(csv_path, encoding="utf-8") as f:
|
| 567 |
rows = [r for r in csv.DictReader(f)
|
|
|
|
| 571 |
if city:
|
| 572 |
rows = [r for r in rows if r.get("city", "").strip().lower() == city.lower()]
|
| 573 |
if not rows:
|
| 574 |
+
return {"error": f"No places found in {city}."}
|
| 575 |
elif lat is not None and lng is not None:
|
| 576 |
def _ok(r):
|
| 577 |
try:
|
|
@@ -3,7 +3,7 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<title>The Belagurian Β·
|
| 7 |
<style>
|
| 8 |
:root {
|
| 9 |
--ig1: #e1306c;
|
|
@@ -711,7 +711,7 @@
|
|
| 711 |
<div class="logo-ring">
|
| 712 |
<div class="logo-inner">π</div>
|
| 713 |
</div>
|
| 714 |
-
<h1>
|
| 715 |
<p class="tagline">Turn your Instagram saved posts into a Google My Maps import</p>
|
| 716 |
</div>
|
| 717 |
|
|
@@ -820,14 +820,12 @@
|
|
| 820 |
</ol>
|
| 821 |
<!-- Step outputs (hidden until prepare runs) -->
|
| 822 |
<div id="chatbot-step2" style="display:none;margin-top:16px;">
|
| 823 |
-
<div style="
|
| 824 |
-
<
|
| 825 |
-
β¬ Download posts JSON
|
| 826 |
-
</a>
|
| 827 |
<button class="btn btn-outline" id="chatbot-copy-prompt-btn" type="button">π Copy prompt</button>
|
| 828 |
</div>
|
| 829 |
-
<div id="chatbot-
|
| 830 |
-
|
| 831 |
</div>
|
| 832 |
<label style="font-size:0.82rem;font-weight:600;display:block;margin-bottom:6px;">
|
| 833 |
Paste the chatbot's response here:
|
|
@@ -835,6 +833,10 @@
|
|
| 835 |
<textarea id="chatbot-response-area" rows="6"
|
| 836 |
placeholder='[{"post_number": 1, "is_place": true, "category": "Restaurant", β¦}, β¦]'
|
| 837 |
style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:0.78rem;font-family:monospace;resize:vertical;"></textarea>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 838 |
<button class="btn" id="chatbot-process-btn" type="button" style="margin-top:10px;width:100%;" disabled>
|
| 839 |
Process response & build map β
|
| 840 |
</button>
|
|
@@ -895,7 +897,7 @@
|
|
| 895 |
<div class="steps">
|
| 896 |
<div class="step" id="s-extract">
|
| 897 |
<div class="step-icon">π€</div>
|
| 898 |
-
<span id="extract-step-label">Extract
|
| 899 |
</div>
|
| 900 |
<div class="step" id="s-geocode">
|
| 901 |
<div class="step-icon">π</div>
|
|
@@ -1254,6 +1256,14 @@ $('btn-chatbot').addEventListener('click', () => setProvider('chatbot'));
|
|
| 1254 |
// ββ Chatbot handoff ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1255 |
let _chatbotJobId = null;
|
| 1256 |
let _chatbotPromptText = '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1257 |
|
| 1258 |
$('chatbot-prepare-btn').addEventListener('click', async () => {
|
| 1259 |
const prepBtn = $('chatbot-prepare-btn');
|
|
@@ -1282,14 +1292,37 @@ $('chatbot-prepare-btn').addEventListener('click', async () => {
|
|
| 1282 |
const data = await res.json();
|
| 1283 |
_chatbotJobId = data.job_id;
|
| 1284 |
_chatbotPromptText = data.prompt;
|
|
|
|
|
|
|
| 1285 |
|
| 1286 |
-
|
| 1287 |
-
|
| 1288 |
-
|
| 1289 |
-
|
| 1290 |
-
|
| 1291 |
-
|
| 1292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1293 |
|
| 1294 |
$('chatbot-step2').style.display = 'block';
|
| 1295 |
prepBtn.innerHTML = 'β File ready β see steps below';
|
|
@@ -1303,14 +1336,31 @@ $('chatbot-copy-prompt-btn').addEventListener('click', () => {
|
|
| 1303 |
});
|
| 1304 |
});
|
| 1305 |
|
|
|
|
|
|
|
| 1306 |
$('chatbot-response-area').addEventListener('input', () => {
|
| 1307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1308 |
});
|
| 1309 |
|
| 1310 |
$('chatbot-process-btn').addEventListener('click', async () => {
|
| 1311 |
const processBtn = $('chatbot-process-btn');
|
| 1312 |
-
|
| 1313 |
-
|
|
|
|
|
|
|
|
|
|
| 1314 |
|
| 1315 |
processBtn.innerHTML = '<span class="spinner"></span>Processingβ¦';
|
| 1316 |
processBtn.disabled = true;
|
|
@@ -1320,7 +1370,7 @@ $('chatbot-process-btn').addEventListener('click', async () => {
|
|
| 1320 |
res = await fetch('/chatbot-process/' + _chatbotJobId, {
|
| 1321 |
method: 'POST',
|
| 1322 |
headers: { 'Content-Type': 'application/json' },
|
| 1323 |
-
body: JSON.stringify({
|
| 1324 |
});
|
| 1325 |
if (!res.ok) throw new Error(await res.text());
|
| 1326 |
} catch (err) {
|
|
@@ -1395,7 +1445,7 @@ runBtn.addEventListener('click', async () => {
|
|
| 1395 |
const modelLabel = activeProvider === 'ollama'
|
| 1396 |
? `Ollama / ${selectedOllamaModel()}`
|
| 1397 |
: `Claude / ${selectedModel()}`;
|
| 1398 |
-
$('extract-step-label').textContent = `Extract
|
| 1399 |
|
| 1400 |
$('upload-card').style.display = 'none';
|
| 1401 |
$('progress-section').style.display = 'block';
|
|
@@ -1545,7 +1595,7 @@ $('spin-btn').addEventListener('click', async () => {
|
|
| 1545 |
$('spin-btn').disabled = false;
|
| 1546 |
|
| 1547 |
// Auto-flip to "All places" when all unvisited spots are exhausted
|
| 1548 |
-
if (r.error && r.error.includes('No unvisited
|
| 1549 |
_setRouletteAllStatuses(true);
|
| 1550 |
$('spin-btn').click();
|
| 1551 |
return;
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>The Belagurian Β· Places Map</title>
|
| 7 |
<style>
|
| 8 |
:root {
|
| 9 |
--ig1: #e1306c;
|
|
|
|
| 711 |
<div class="logo-ring">
|
| 712 |
<div class="logo-inner">π</div>
|
| 713 |
</div>
|
| 714 |
+
<h1>Places Mapper</h1>
|
| 715 |
<p class="tagline">Turn your Instagram saved posts into a Google My Maps import</p>
|
| 716 |
</div>
|
| 717 |
|
|
|
|
| 820 |
</ol>
|
| 821 |
<!-- Step outputs (hidden until prepare runs) -->
|
| 822 |
<div id="chatbot-step2" style="display:none;margin-top:16px;">
|
| 823 |
+
<div style="margin-bottom:12px;">
|
| 824 |
+
<div id="chatbot-downloads" style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:10px;"></div>
|
|
|
|
|
|
|
| 825 |
<button class="btn btn-outline" id="chatbot-copy-prompt-btn" type="button">π Copy prompt</button>
|
| 826 |
</div>
|
| 827 |
+
<div id="chatbot-chunk-note" style="display:none;background:#eef4ff;border:1px solid #c7d9ff;border-radius:6px;padding:10px 12px;font-size:0.8rem;margin-bottom:12px;">
|
| 828 |
+
Your library is split into <strong id="chatbot-chunk-count"></strong> files so each fits a free chatbot's reply limit. Run <em>each</em> file through the chatbot, and paste every reply below with <strong>Add response</strong> before processing.
|
| 829 |
</div>
|
| 830 |
<label style="font-size:0.82rem;font-weight:600;display:block;margin-bottom:6px;">
|
| 831 |
Paste the chatbot's response here:
|
|
|
|
| 833 |
<textarea id="chatbot-response-area" rows="6"
|
| 834 |
placeholder='[{"post_number": 1, "is_place": true, "category": "Restaurant", β¦}, β¦]'
|
| 835 |
style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:0.78rem;font-family:monospace;resize:vertical;"></textarea>
|
| 836 |
+
<div style="display:flex;gap:10px;align-items:center;margin-top:8px;">
|
| 837 |
+
<button class="btn btn-outline" id="chatbot-add-response-btn" type="button" style="display:none;">β Add response</button>
|
| 838 |
+
<span id="chatbot-added-count" style="font-size:0.8rem;color:var(--muted);"></span>
|
| 839 |
+
</div>
|
| 840 |
<button class="btn" id="chatbot-process-btn" type="button" style="margin-top:10px;width:100%;" disabled>
|
| 841 |
Process response & build map β
|
| 842 |
</button>
|
|
|
|
| 897 |
<div class="steps">
|
| 898 |
<div class="step" id="s-extract">
|
| 899 |
<div class="step-icon">π€</div>
|
| 900 |
+
<span id="extract-step-label">Extract places with AI</span>
|
| 901 |
</div>
|
| 902 |
<div class="step" id="s-geocode">
|
| 903 |
<div class="step-icon">π</div>
|
|
|
|
| 1256 |
// ββ Chatbot handoff ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1257 |
let _chatbotJobId = null;
|
| 1258 |
let _chatbotPromptText = '';
|
| 1259 |
+
let _chatbotChunkCount = 1;
|
| 1260 |
+
let _chatbotResponses = []; // accumulated pasted replies (multi-chunk mode)
|
| 1261 |
+
|
| 1262 |
+
function _updateAddedCount() {
|
| 1263 |
+
const el = $('chatbot-added-count');
|
| 1264 |
+
if (_chatbotChunkCount <= 1) { el.textContent = ''; return; }
|
| 1265 |
+
el.textContent = `${_chatbotResponses.length} of ${_chatbotChunkCount} added`;
|
| 1266 |
+
}
|
| 1267 |
|
| 1268 |
$('chatbot-prepare-btn').addEventListener('click', async () => {
|
| 1269 |
const prepBtn = $('chatbot-prepare-btn');
|
|
|
|
| 1292 |
const data = await res.json();
|
| 1293 |
_chatbotJobId = data.job_id;
|
| 1294 |
_chatbotPromptText = data.prompt;
|
| 1295 |
+
_chatbotChunkCount = data.chunk_count || 1;
|
| 1296 |
+
_chatbotResponses = [];
|
| 1297 |
|
| 1298 |
+
// Split the export into chunk files and render a download link for each
|
| 1299 |
+
const posts = data.export_posts;
|
| 1300 |
+
const size = data.chunk_size || posts.length || 1;
|
| 1301 |
+
const dl = $('chatbot-downloads');
|
| 1302 |
+
dl.innerHTML = '';
|
| 1303 |
+
for (let c = 0; c < _chatbotChunkCount; c++) {
|
| 1304 |
+
const slice = posts.slice(c * size, (c + 1) * size);
|
| 1305 |
+
const blob = new Blob([JSON.stringify(slice, null, 2)], { type: 'application/json' });
|
| 1306 |
+
const a = document.createElement('a');
|
| 1307 |
+
a.className = 'btn btn-outline';
|
| 1308 |
+
a.style.textDecoration = 'none';
|
| 1309 |
+
a.download = _chatbotChunkCount > 1
|
| 1310 |
+
? `instagram_places_${c + 1}of${_chatbotChunkCount}.json`
|
| 1311 |
+
: 'instagram_places.json';
|
| 1312 |
+
a.href = URL.createObjectURL(blob);
|
| 1313 |
+
a.textContent = _chatbotChunkCount > 1
|
| 1314 |
+
? `β¬ File ${c + 1} of ${_chatbotChunkCount}`
|
| 1315 |
+
: 'β¬ Download posts JSON';
|
| 1316 |
+
dl.appendChild(a);
|
| 1317 |
+
}
|
| 1318 |
+
|
| 1319 |
+
const multi = _chatbotChunkCount > 1;
|
| 1320 |
+
$('chatbot-chunk-note').style.display = multi ? 'block' : 'none';
|
| 1321 |
+
$('chatbot-add-response-btn').style.display = multi ? 'inline-block' : 'none';
|
| 1322 |
+
if (multi) $('chatbot-chunk-count').textContent = _chatbotChunkCount;
|
| 1323 |
+
$('chatbot-process-btn').disabled = true;
|
| 1324 |
+
$('chatbot-response-area').value = '';
|
| 1325 |
+
_updateAddedCount();
|
| 1326 |
|
| 1327 |
$('chatbot-step2').style.display = 'block';
|
| 1328 |
prepBtn.innerHTML = 'β File ready β see steps below';
|
|
|
|
| 1336 |
});
|
| 1337 |
});
|
| 1338 |
|
| 1339 |
+
// Single-chunk: enable Process as soon as there's text. Multi-chunk: Process is
|
| 1340 |
+
// gated on accumulated responses, so the textarea only feeds "Add response".
|
| 1341 |
$('chatbot-response-area').addEventListener('input', () => {
|
| 1342 |
+
if (_chatbotChunkCount <= 1) {
|
| 1343 |
+
$('chatbot-process-btn').disabled = !$('chatbot-response-area').value.trim();
|
| 1344 |
+
}
|
| 1345 |
+
});
|
| 1346 |
+
|
| 1347 |
+
$('chatbot-add-response-btn').addEventListener('click', () => {
|
| 1348 |
+
const area = $('chatbot-response-area');
|
| 1349 |
+
const txt = area.value.trim();
|
| 1350 |
+
if (!txt) return;
|
| 1351 |
+
_chatbotResponses.push(txt);
|
| 1352 |
+
area.value = '';
|
| 1353 |
+
_updateAddedCount();
|
| 1354 |
+
$('chatbot-process-btn').disabled = _chatbotResponses.length === 0;
|
| 1355 |
});
|
| 1356 |
|
| 1357 |
$('chatbot-process-btn').addEventListener('click', async () => {
|
| 1358 |
const processBtn = $('chatbot-process-btn');
|
| 1359 |
+
// Include anything still sitting in the textarea (single-chunk, or a forgotten "Add")
|
| 1360 |
+
const pending = $('chatbot-response-area').value.trim();
|
| 1361 |
+
const responses = _chatbotResponses.slice();
|
| 1362 |
+
if (pending) responses.push(pending);
|
| 1363 |
+
if (responses.length === 0 || !_chatbotJobId) return;
|
| 1364 |
|
| 1365 |
processBtn.innerHTML = '<span class="spinner"></span>Processingβ¦';
|
| 1366 |
processBtn.disabled = true;
|
|
|
|
| 1370 |
res = await fetch('/chatbot-process/' + _chatbotJobId, {
|
| 1371 |
method: 'POST',
|
| 1372 |
headers: { 'Content-Type': 'application/json' },
|
| 1373 |
+
body: JSON.stringify({ responses }),
|
| 1374 |
});
|
| 1375 |
if (!res.ok) throw new Error(await res.text());
|
| 1376 |
} catch (err) {
|
|
|
|
| 1445 |
const modelLabel = activeProvider === 'ollama'
|
| 1446 |
? `Ollama / ${selectedOllamaModel()}`
|
| 1447 |
: `Claude / ${selectedModel()}`;
|
| 1448 |
+
$('extract-step-label').textContent = `Extract places with ${modelLabel}`;
|
| 1449 |
|
| 1450 |
$('upload-card').style.display = 'none';
|
| 1451 |
$('progress-section').style.display = 'block';
|
|
|
|
| 1595 |
$('spin-btn').disabled = false;
|
| 1596 |
|
| 1597 |
// Auto-flip to "All places" when all unvisited spots are exhausted
|
| 1598 |
+
if (r.error && r.error.includes('No unvisited places')) {
|
| 1599 |
_setRouletteAllStatuses(true);
|
| 1600 |
$('spin-btn').click();
|
| 1601 |
return;
|