Spaces:
Runtime error
Runtime error
Commit ·
8221e29
1
Parent(s): 9d3aa90
Feat: /capture endpoint core (Phase A) — single-post extraction
Browse filesRefactors _run_url_pipeline's extract core into shared _places_from_caption()
(analyze_batch → split_multi_venue → location-tag coords). New POST /capture
accepts embed_html (the iOS Shortcut fetches /embed/captioned/ on-device so the
server never touches Instagram), or a ready caption, or a url (local fetch),
plus optional lat/lng for a free location-tag pin. Returns {places, count}.
BYOK api_key. tests/test_capture.py added to CI; e2e still green (refactor safe).
Inbox delivery (Phase B) and the Shortcut itself (Phase C) are next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- .github/workflows/benchmark.yml +3 -0
- CLAUDE.md +6 -0
- README.md +1 -0
- tests/test_capture.py +104 -0
- web/app.py +110 -20
.github/workflows/benchmark.yml
CHANGED
|
@@ -39,6 +39,9 @@ jobs:
|
|
| 39 |
- name: Merge — living-library merge keeps curation
|
| 40 |
run: python3 tests/test_merge.py
|
| 41 |
|
|
|
|
|
|
|
|
|
|
| 42 |
benchmark:
|
| 43 |
name: Benchmark smoke test (Ollama)
|
| 44 |
runs-on: ubuntu-latest
|
|
|
|
| 39 |
- name: Merge — living-library merge keeps curation
|
| 40 |
run: python3 tests/test_merge.py
|
| 41 |
|
| 42 |
+
- name: Capture — single-post extraction (Shortcut path)
|
| 43 |
+
run: python3 tests/test_capture.py
|
| 44 |
+
|
| 45 |
benchmark:
|
| 46 |
name: Benchmark smoke test (Ollama)
|
| 47 |
runs-on: ubuntu-latest
|
CLAUDE.md
CHANGED
|
@@ -49,6 +49,12 @@ web/app.py FastAPI: pipeline, SSE progress, results browser, resolve_pro
|
|
| 49 |
POST /import — KML or CSV re-import (no extraction, skip to tabs)
|
| 50 |
POST /extract-url — single Instagram URL → caption fetch → LLM + geocode
|
| 51 |
POST /url-chatbot-prepare — same but returns chatbot export package
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
CAPTION_PROXY_URL env var: reserved for a future residential proxy
|
| 53 |
web/static/ served at /static/ via FastAPI StaticFiles mount
|
| 54 |
app.css all styles (~1300 lines, no Jinja)
|
|
|
|
| 49 |
POST /import — KML or CSV re-import (no extraction, skip to tabs)
|
| 50 |
POST /extract-url — single Instagram URL → caption fetch → LLM + geocode
|
| 51 |
POST /url-chatbot-prepare — same but returns chatbot export package
|
| 52 |
+
POST /capture — single shared post → place(s) JSON (iOS Shortcut path).
|
| 53 |
+
Accepts embed_html (Shortcut fetches /embed/captioned/ ON-DEVICE so the
|
| 54 |
+
server never touches Instagram) / caption / url, + optional lat,lng
|
| 55 |
+
(IG location tag → free coords). Core extraction is the shared
|
| 56 |
+
_places_from_caption() (refactored out of _run_url_pipeline; both use it).
|
| 57 |
+
BYOK api_key. Inbox delivery (token) lands in Phase B.
|
| 58 |
CAPTION_PROXY_URL env var: reserved for a future residential proxy
|
| 59 |
web/static/ served at /static/ via FastAPI StaticFiles mount
|
| 60 |
app.css all styles (~1300 lines, no Jinja)
|
README.md
CHANGED
|
@@ -426,6 +426,7 @@ tests/
|
|
| 426 |
test_prefilter.py Guardrail: prefilter zero-false-negatives against oracle (CI)
|
| 427 |
test_persistence.py Guardrail: cache/restore CSV round-trip is lossless (CI)
|
| 428 |
test_merge.py Guardrail: living-library merge keeps curation (CI)
|
|
|
|
| 429 |
benchmark.py Evaluate any model against the oracle
|
| 430 |
create_fixture.py Sample 50 posts for the benchmark fixture
|
| 431 |
generate_ground_truth.py Label posts with Opus 4.8 (the oracle)
|
|
|
|
| 426 |
test_prefilter.py Guardrail: prefilter zero-false-negatives against oracle (CI)
|
| 427 |
test_persistence.py Guardrail: cache/restore CSV round-trip is lossless (CI)
|
| 428 |
test_merge.py Guardrail: living-library merge keeps curation (CI)
|
| 429 |
+
test_capture.py Guardrail: /capture single-post extraction (CI)
|
| 430 |
benchmark.py Evaluate any model against the oracle
|
| 431 |
create_fixture.py Sample 50 posts for the benchmark fixture
|
| 432 |
generate_ground_truth.py Label posts with Opus 4.8 (the oracle)
|
tests/test_capture.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
POST /capture — single-post extraction for the iOS Shortcut path (Phase A).
|
| 3 |
+
|
| 4 |
+
The Shortcut fetches /embed/captioned/ on-device and POSTs the HTML (or a ready
|
| 5 |
+
caption, or a URL for local server-fetch). /capture runs the shared
|
| 6 |
+
_places_from_caption and returns the extracted place(s). Optional lat/lng carry
|
| 7 |
+
an Instagram location tag → coordinates without geocoding.
|
| 8 |
+
|
| 9 |
+
Only the LLM is mocked. Run: python3 tests/test_capture.py
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
ROOT = Path(__file__).parent.parent
|
| 16 |
+
sys.path.insert(0, str(ROOT))
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
from starlette.testclient import TestClient
|
| 20 |
+
except Exception as exc:
|
| 21 |
+
print(f"SKIP: TestClient unavailable ({exc}). `pip install httpx` to run.")
|
| 22 |
+
sys.exit(0)
|
| 23 |
+
|
| 24 |
+
from pipeline import extract as extract_mod
|
| 25 |
+
from web import app as webapp
|
| 26 |
+
|
| 27 |
+
failures: list[str] = []
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def check(label: str, cond: bool) -> None:
|
| 31 |
+
print(("PASS" if cond else "FAIL"), "-", label)
|
| 32 |
+
if not cond:
|
| 33 |
+
failures.append(label)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _place(name, city="Tokyo", country="Japan", category="Cafe"):
|
| 37 |
+
d = {k: "UNKNOWN" for k in ("name", "city", "state", "country", "address",
|
| 38 |
+
"cuisine", "price_range", "highlight", "occasion")}
|
| 39 |
+
d.update(name=name, city=city, country=country, category=category)
|
| 40 |
+
return d
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def fake_analyze_batch(client, posts, model=None, provider="anthropic", ollama_url=""):
|
| 44 |
+
"""Deterministic stand-in: a place when the caption looks place-y, else None."""
|
| 45 |
+
out = []
|
| 46 |
+
for p in posts:
|
| 47 |
+
cap = (p.get("caption") or "").lower()
|
| 48 |
+
if "ichiran" in cap or "ramen" in cap or "cafe" in cap or "koffee" in cap:
|
| 49 |
+
out.append(_place("Koffee Mameya" if "koffee" in cap else "Ichiran"))
|
| 50 |
+
else:
|
| 51 |
+
out.append(None)
|
| 52 |
+
return out
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
_orig_ab = extract_mod.analyze_batch
|
| 56 |
+
extract_mod.analyze_batch = fake_analyze_batch
|
| 57 |
+
try:
|
| 58 |
+
with TestClient(webapp.app) as client:
|
| 59 |
+
|
| 60 |
+
# ── caption path ──────────────────────────────────────────────────────
|
| 61 |
+
r = client.post("/capture", data={"caption": "Amazing @koffee_mameya cafe in Tokyo",
|
| 62 |
+
"url": "https://instagram.com/p/aaa",
|
| 63 |
+
"api_key": "sk-test-unused"})
|
| 64 |
+
check("caption path: 200", r.status_code == 200)
|
| 65 |
+
body = r.json()
|
| 66 |
+
check("caption path: 1 place", body.get("count") == 1)
|
| 67 |
+
check("caption path: place name extracted", body["places"][0]["name"] == "Koffee Mameya")
|
| 68 |
+
check("caption path: instagram_url carried", body["places"][0]["instagram_url"] == "https://instagram.com/p/aaa")
|
| 69 |
+
|
| 70 |
+
# ── location tag → coords without geocoding ─────────────────────────────
|
| 71 |
+
r2 = client.post("/capture", data={"caption": "Ichiran ramen", "url": "https://instagram.com/p/bbb",
|
| 72 |
+
"lat": "35.6595", "lng": "139.7005", "api_key": "x"})
|
| 73 |
+
p2 = r2.json()["places"][0]
|
| 74 |
+
check("location tag: lat applied", p2["lat"].startswith("35.6595"))
|
| 75 |
+
check("location tag: lng applied", p2["lng"].startswith("139.7005"))
|
| 76 |
+
|
| 77 |
+
# ── embed_html path (server parses the Caption block) ───────────────────
|
| 78 |
+
embed = ('<html><body><div class="Caption">'
|
| 79 |
+
'<a class="CaptionUsername" href="#">timeouttokyo_</a>'
|
| 80 |
+
'Best ramen — Ichiran in Tokyo</div></body></html>')
|
| 81 |
+
r3 = client.post("/capture", data={"embed_html": embed,
|
| 82 |
+
"url": "https://instagram.com/p/ccc", "api_key": "x"})
|
| 83 |
+
check("embed_html path: 200", r3.status_code == 200)
|
| 84 |
+
check("embed_html path: place extracted", r3.json()["places"][0]["name"] == "Ichiran")
|
| 85 |
+
|
| 86 |
+
# ── error: no caption ───────────────────────────────────────────────────
|
| 87 |
+
r4 = client.post("/capture", data={"api_key": "x"})
|
| 88 |
+
check("no caption → 422", r4.status_code == 422)
|
| 89 |
+
|
| 90 |
+
# ── error: caption with no place ────────────────────────────────────────
|
| 91 |
+
r5 = client.post("/capture", data={"caption": "my gym workout routine today", "api_key": "x"})
|
| 92 |
+
check("non-place caption → 422", r5.status_code == 422)
|
| 93 |
+
|
| 94 |
+
finally:
|
| 95 |
+
extract_mod.analyze_batch = _orig_ab
|
| 96 |
+
|
| 97 |
+
print()
|
| 98 |
+
if failures:
|
| 99 |
+
print(f"FAIL — {len(failures)} capture check(s) failed:")
|
| 100 |
+
for f in failures:
|
| 101 |
+
print(f" ✗ {f}")
|
| 102 |
+
sys.exit(1)
|
| 103 |
+
print("PASS — /capture extracts place(s) from a single shared post.")
|
| 104 |
+
sys.exit(0)
|
web/app.py
CHANGED
|
@@ -285,6 +285,39 @@ def _run_pipeline(job_id: str, json_bytes: bytes, model: str,
|
|
| 285 |
|
| 286 |
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
def _run_url_pipeline(job_id: str, url: str, model: str,
|
| 289 |
provider: str = "anthropic", ollama_url: str = "http://localhost:11434",
|
| 290 |
api_key: str | None = None) -> None:
|
|
@@ -319,29 +352,13 @@ def _run_url_pipeline(job_id: str, url: str, model: str,
|
|
| 319 |
|
| 320 |
_update(job_id, step="extract", progress=40, message="Extracting place with AI…")
|
| 321 |
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
post = {"caption": meta["caption"], "hashtags": []}
|
| 328 |
-
results = extract_mod.analyze_batch(client, [post],
|
| 329 |
-
model=model, provider=provider, ollama_url=ollama_url)
|
| 330 |
-
# analyze_batch returns [None] for non-place; multi-venue posts return
|
| 331 |
-
# multiple results for the single input post.
|
| 332 |
-
infos = [r for r in results if r is not None]
|
| 333 |
-
if not infos:
|
| 334 |
_update(job_id, step="error", progress=0, message="No place found in this post.")
|
| 335 |
return
|
| 336 |
|
| 337 |
-
rows = extract_mod.split_multi_venue([
|
| 338 |
-
{**info, "creator": meta["creator"], "instagram_url": url, "lat": "", "lng": ""}
|
| 339 |
-
for info in infos
|
| 340 |
-
])
|
| 341 |
-
if meta["location"] and len(rows) == 1:
|
| 342 |
-
rows[0]["lat"] = f"{meta['location']['lat']:.7f}"
|
| 343 |
-
rows[0]["lng"] = f"{meta['location']['lng']:.7f}"
|
| 344 |
-
|
| 345 |
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
| 346 |
writer = csv.DictWriter(f, fieldnames=extract_mod.FIELDNAMES)
|
| 347 |
writer.writeheader()
|
|
@@ -618,6 +635,79 @@ async def extract_url_route(
|
|
| 618 |
return {"job_id": job_id}
|
| 619 |
|
| 620 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
@app.post("/url-chatbot-prepare")
|
| 622 |
async def url_chatbot_prepare(url: str = Form(...)):
|
| 623 |
"""Fetch caption via proxy/yt-dlp and return a chatbot export package."""
|
|
|
|
| 285 |
|
| 286 |
|
| 287 |
|
| 288 |
+
def _places_from_caption(caption: str, url: str, creator: str = "",
|
| 289 |
+
location: dict | None = None, *,
|
| 290 |
+
provider: str = "anthropic", model: str = DEFAULT_MODEL,
|
| 291 |
+
api_key: str | None = None,
|
| 292 |
+
ollama_url: str = "http://localhost:11434") -> list[dict]:
|
| 293 |
+
"""Extract place row(s) from one post's caption — shared by the single-URL
|
| 294 |
+
pipeline and POST /capture.
|
| 295 |
+
|
| 296 |
+
Returns FIELDNAMES-shaped rows (empty if no place found). Multi-venue posts
|
| 297 |
+
yield one row per venue (split_multi_venue). When the post carries an
|
| 298 |
+
Instagram location tag and resolves to a single venue, its coords are used
|
| 299 |
+
directly (no geocoding needed).
|
| 300 |
+
"""
|
| 301 |
+
client = None
|
| 302 |
+
if provider == "anthropic":
|
| 303 |
+
import anthropic as _anthropic
|
| 304 |
+
client = _anthropic.Anthropic(api_key=api_key) if api_key else _anthropic.Anthropic()
|
| 305 |
+
|
| 306 |
+
results = extract_mod.analyze_batch(client, [{"caption": caption, "hashtags": []}],
|
| 307 |
+
model=model, provider=provider, ollama_url=ollama_url)
|
| 308 |
+
infos = [r for r in results if r is not None]
|
| 309 |
+
if not infos:
|
| 310 |
+
return []
|
| 311 |
+
rows = extract_mod.split_multi_venue([
|
| 312 |
+
{**info, "creator": creator, "instagram_url": url, "lat": "", "lng": ""}
|
| 313 |
+
for info in infos
|
| 314 |
+
])
|
| 315 |
+
if location and len(rows) == 1:
|
| 316 |
+
rows[0]["lat"] = f"{location['lat']:.7f}"
|
| 317 |
+
rows[0]["lng"] = f"{location['lng']:.7f}"
|
| 318 |
+
return rows
|
| 319 |
+
|
| 320 |
+
|
| 321 |
def _run_url_pipeline(job_id: str, url: str, model: str,
|
| 322 |
provider: str = "anthropic", ollama_url: str = "http://localhost:11434",
|
| 323 |
api_key: str | None = None) -> None:
|
|
|
|
| 352 |
|
| 353 |
_update(job_id, step="extract", progress=40, message="Extracting place with AI…")
|
| 354 |
|
| 355 |
+
rows = _places_from_caption(meta["caption"], url, meta["creator"], meta["location"],
|
| 356 |
+
provider=provider, model=model, api_key=api_key,
|
| 357 |
+
ollama_url=ollama_url)
|
| 358 |
+
if not rows:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
_update(job_id, step="error", progress=0, message="No place found in this post.")
|
| 360 |
return
|
| 361 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
| 363 |
writer = csv.DictWriter(f, fieldnames=extract_mod.FIELDNAMES)
|
| 364 |
writer.writeheader()
|
|
|
|
| 635 |
return {"job_id": job_id}
|
| 636 |
|
| 637 |
|
| 638 |
+
@app.post("/capture")
|
| 639 |
+
async def capture(
|
| 640 |
+
embed_html: str = Form(""),
|
| 641 |
+
caption: str = Form(""),
|
| 642 |
+
url: str = Form(""),
|
| 643 |
+
creator: str = Form(""),
|
| 644 |
+
lat: str = Form(""),
|
| 645 |
+
lng: str = Form(""),
|
| 646 |
+
model: str = Form(DEFAULT_MODEL),
|
| 647 |
+
provider: str = Form("anthropic"),
|
| 648 |
+
ollama_model: str = Form(DEFAULT_OLLAMA_MODEL),
|
| 649 |
+
ollama_url: str = Form(None),
|
| 650 |
+
api_key: str = Form(""),
|
| 651 |
+
):
|
| 652 |
+
"""Extract place(s) from a single shared post — the iOS Shortcut path.
|
| 653 |
+
|
| 654 |
+
The caller fetches /embed/captioned/ ON-DEVICE (residential IP, no CORS) and
|
| 655 |
+
POSTs `embed_html`; alternatively send a ready `caption`, or a `url` for the
|
| 656 |
+
server to fetch (local builds only — the hosted server is IP-walled).
|
| 657 |
+
Optional `lat`/`lng` carry an Instagram location tag for free coordinates.
|
| 658 |
+
Returns {places, count}. Inbox delivery (token) is added in Phase B.
|
| 659 |
+
"""
|
| 660 |
+
provider, active_model = resolve_provider(
|
| 661 |
+
provider, model, ollama_model, ollama_enabled=OLLAMA_ENABLED
|
| 662 |
+
)
|
| 663 |
+
|
| 664 |
+
location = None
|
| 665 |
+
if lat and lng:
|
| 666 |
+
try:
|
| 667 |
+
location = {"lat": float(lat), "lng": float(lng)}
|
| 668 |
+
except ValueError:
|
| 669 |
+
location = None
|
| 670 |
+
|
| 671 |
+
if embed_html:
|
| 672 |
+
if _too_large(embed_html.encode()):
|
| 673 |
+
return JSONResponse({"error": f"Payload too large (max {MAX_UPLOAD_MB:.0f} MB)."}, status_code=413)
|
| 674 |
+
from pipeline.transcribe import _parse_embed_caption
|
| 675 |
+
parsed = _parse_embed_caption(embed_html)
|
| 676 |
+
caption = caption or parsed["caption"]
|
| 677 |
+
creator = creator or parsed["creator"]
|
| 678 |
+
elif url and not caption:
|
| 679 |
+
if CLIENT_GEOCODE and not CAPTION_PROXY_URL:
|
| 680 |
+
return JSONResponse(
|
| 681 |
+
{"error": "The server can't fetch Instagram on the hosted build — "
|
| 682 |
+
"the Shortcut should POST embed_html fetched on your device."},
|
| 683 |
+
status_code=422)
|
| 684 |
+
from pipeline.transcribe import fetch_post_metadata
|
| 685 |
+
try:
|
| 686 |
+
meta = await asyncio.to_thread(fetch_post_metadata, url, None, CAPTION_PROXY_URL)
|
| 687 |
+
except ValueError as exc:
|
| 688 |
+
return JSONResponse({"error": str(exc)}, status_code=422)
|
| 689 |
+
caption = meta["caption"]
|
| 690 |
+
creator = creator or meta["creator"]
|
| 691 |
+
location = location or meta["location"]
|
| 692 |
+
|
| 693 |
+
if not caption.strip():
|
| 694 |
+
return JSONResponse({"error": "No caption found in the post."}, status_code=422)
|
| 695 |
+
|
| 696 |
+
try:
|
| 697 |
+
rows = await asyncio.to_thread(
|
| 698 |
+
_places_from_caption, caption, url, creator, location,
|
| 699 |
+
provider=provider, model=active_model,
|
| 700 |
+
api_key=api_key.strip() or None, ollama_url=ollama_url or OLLAMA_URL,
|
| 701 |
+
)
|
| 702 |
+
except Exception as exc:
|
| 703 |
+
return JSONResponse({"error": safe_err(exc)}, status_code=502)
|
| 704 |
+
|
| 705 |
+
if not rows:
|
| 706 |
+
return JSONResponse({"error": "No place found in this post."}, status_code=422)
|
| 707 |
+
|
| 708 |
+
return {"places": rows, "count": len(rows)}
|
| 709 |
+
|
| 710 |
+
|
| 711 |
@app.post("/url-chatbot-prepare")
|
| 712 |
async def url_chatbot_prepare(url: str = Form(...)):
|
| 713 |
"""Fetch caption via proxy/yt-dlp and return a chatbot export package."""
|