Spaces:
Sleeping
Sleeping
File size: 6,430 Bytes
acad071 57a81bc acad071 57a81bc acad071 57a81bc acad071 57a81bc acad071 57a81bc 6e46465 57a81bc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 | """Shared-state API for the Tokyo Flat Finder viewer.
Stores the couple's likes/dislikes/notes and syncs them across devices.
State is held in memory and persisted to a PRIVATE HF Dataset (state.json) so
it survives Space restarts. Every request must carry the shared X-App-Key.
Env (set as Space secrets):
HF_TOKEN write token for the dataset
DATASET_REPO e.g. ArthurZ/tokyo-flat-finder-state
APP_SECRET shared key the viewer sends as X-App-Key
"""
from __future__ import annotations
import html as _html
import json
import os
import pathlib
import threading
from fastapi import FastAPI, Header, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from huggingface_hub import HfApi, hf_hub_download
from pydantic import BaseModel
# Slim public listing data for the shareable preview page (baked at deploy).
try:
PUBLIC = json.loads(pathlib.Path("public_listings.json").read_text())
except Exception:
PUBLIC = {}
HF_TOKEN = os.environ.get("HF_TOKEN")
DATASET_REPO = os.environ.get("DATASET_REPO", "")
APP_SECRET = os.environ.get("APP_SECRET", "")
STATE_FILE = "state.json"
api = HfApi(token=HF_TOKEN)
_lock = threading.Lock()
_state: dict = {"userStates": {}, "notes": {}}
def _load() -> None:
global _state
try:
path = hf_hub_download(DATASET_REPO, STATE_FILE, repo_type="dataset",
token=HF_TOKEN)
with open(path) as f:
data = json.load(f)
_state = {"userStates": data.get("userStates", {}), "notes": data.get("notes", {})}
except Exception:
_state = {"userStates": {}, "notes": {}}
def _persist() -> None:
buf = json.dumps(_state, ensure_ascii=False).encode("utf-8")
api.upload_file(path_or_fileobj=buf, path_in_repo=STATE_FILE,
repo_id=DATASET_REPO, repo_type="dataset",
commit_message="update shared state")
app = FastAPI()
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
)
@app.on_event("startup")
def _startup() -> None:
_load()
def _auth(key: str | None) -> None:
if not APP_SECRET or key != APP_SECRET:
raise HTTPException(status_code=403, detail="bad key")
@app.get("/health")
def health() -> dict:
return {"ok": True, "listings": len(_state["userStates"]), "notes": len(_state["notes"])}
@app.get("/state")
def get_state(x_app_key: str | None = Header(default=None)) -> dict:
_auth(x_app_key)
return _state
class UserStateIn(BaseModel):
listingId: str
handle: str # "me" | "partner"
state: str # "unseen" | "like" | "dislike" | "veto"
@app.post("/user_state")
def set_user_state(body: UserStateIn, x_app_key: str | None = Header(default=None)) -> dict:
_auth(x_app_key)
with _lock:
us = _state["userStates"]
cur = us.get(body.listingId, {})
if body.state == "unseen":
cur.pop(body.handle, None)
else:
cur[body.handle] = body.state
if cur:
us[body.listingId] = cur
else:
us.pop(body.listingId, None)
_persist()
return {"ok": True}
class NoteIn(BaseModel):
listingId: str
note: str
@app.post("/note")
def set_note(body: NoteIn, x_app_key: str | None = Header(default=None)) -> dict:
_auth(x_app_key)
with _lock:
if body.note.strip():
_state["notes"][body.listingId] = body.note
else:
_state["notes"].pop(body.listingId, None)
_persist()
return {"ok": True}
# --- Public shareable preview page (no key — anyone with the link) -----------
@app.get("/p/{listing_id}", response_class=HTMLResponse)
def preview(listing_id: str) -> str:
d = PUBLIC.get(listing_id)
if not d:
return HTMLResponse("<h1>Listing not found</h1>", status_code=404)
e = _html.escape
yen = f"¥{d['rent_jpy']:,}" if d.get("rent_jpy") else "—"
eur = f"€{round(d['rent_jpy'] / 186):,}" if d.get("rent_jpy") else "" # ponytail: fixed 186 rate
lat, lon = d.get("lat"), d.get("lon")
ote = (f"{d['ote_min']} min" + (" · " + "→".join(d["ote_lines"]) if d.get("ote_lines") else "")) \
if d.get("ote_min") is not None else "—"
photos = "".join(f'<img src="{e(u)}" loading="lazy">' for u in d.get("photos", []))
gmap = f"https://www.google.com/maps/search/?api=1&query={lat},{lon}"
osm = (f"https://www.openstreetmap.org/export/embed.html?bbox={lon-0.012},{lat-0.01},"
f"{lon+0.012},{lat+0.01}&layer=mapnik&marker={lat},{lon}") if lat and lon else ""
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{e(d.get('title') or 'Listing')}</title>
<style>
body{{margin:0;font:16px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:#f5f3ee;color:#2a2a2a}}
.wrap{{max-width:560px;margin:0 auto;background:#fff;min-height:100vh}}
.photos{{display:flex;overflow-x:auto;scroll-snap-type:x mandatory}}
.photos img{{width:100%;flex:0 0 100%;height:240px;object-fit:cover;scroll-snap-align:center}}
.pad{{padding:16px 18px}}
h1{{font-size:19px;margin:0 0 8px}}
.badges{{color:#555;font-size:14px;margin-bottom:12px}}
.rent{{font-size:24px;font-weight:700}}.rent small{{font-size:14px;color:#888;font-weight:400}}
.row{{display:flex;gap:8px;margin:10px 0;font-size:15px}}.row b{{min-width:96px;color:#555;font-weight:600}}
iframe{{width:100%;height:260px;border:0;border-radius:10px;margin-top:8px}}
a.btn{{display:block;text-align:center;background:#28a55b;color:#fff;text-decoration:none;
padding:14px;border-radius:10px;font-weight:600;margin-top:14px}}
</style></head><body><div class="wrap">
<div class="photos">{photos}</div>
<div class="pad">
<h1>🏠 {e(d.get('title') or '')}</h1>
<div class="badges">{e(d.get('layout') or '?')} · {d.get('size_m2') or '?'} m² · {e(d.get('ward') or '')}</div>
<div class="rent">{eur} <small>/mo · {yen}</small></div>
<div class="row"><b>→ Otemachi</b><span>{e(ote)}</span></div>
{f'<iframe src="{osm}"></iframe>' if osm else ''}
<a class="btn" href="{gmap}" target="_blank" rel="noopener">📍 Open in Google Maps</a>
{f'<a class="btn" style="background:#3b82f6" href="{e(d["url"])}" target="_blank" rel="noopener">↗ View original listing</a>' if d.get("url") else ''}
</div></div></body></html>"""
|