Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
# Required: Browser API key (HTTP referrer-restricted)
VITE_GOOGLE_MAPS_API_KEY="YOUR_BROWSER_KEY"

# Optional: Backend API base URL (defaults to http://localhost:8000)
VITE_API_BASE_URL="http://localhost:8000"
# Optional: Backend API base URL (defaults to http://127.0.0.1:8000)
# If `localhost` doesn't work on your machine (IPv6-only), use `http://127.0.0.1:8000`.
VITE_API_BASE_URL="http://127.0.0.1:8000"

# Optional: Disable Google Maps loading (useful for CI)
# VITE_DISABLE_GOOGLE_MAPS="1"
Expand All @@ -28,5 +29,12 @@ GEOCODING_PROVIDER="google"
# Optional: Enable/disable all geocoding calls
ENABLE_GEOCODING="1"

# Optional: LLM extraction for selected posts (Chrome extension → "Add selected post")
# Get a key from OpenRouter and put it here.
OPENROUTER_API_KEY="YOUR_OPENROUTER_KEY"

# Optional: Choose a model on OpenRouter
OPENROUTER_MODEL="z-ai/glm-4.5-air:free"

# Optional: Database URL (default is an auto-created SQLite DB under backend/)
# DATABASE_URL="sqlite+pysqlite:////absolute/path/to/easyrelocate.db"
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ Frontend variables must start with `VITE_`:
# Required (browser key)
VITE_GOOGLE_MAPS_API_KEY="YOUR_BROWSER_KEY"

# Optional (defaults to http://localhost:8000)
VITE_API_BASE_URL="http://localhost:8000"
# Optional (defaults to http://127.0.0.1:8000)
VITE_API_BASE_URL="http://127.0.0.1:8000"

# Optional (useful for CI / keyless dev)
# VITE_DISABLE_GOOGLE_MAPS="1"
Expand All @@ -42,14 +42,18 @@ Backend reads standard env vars (auto-loads repo-root `.env` on startup):
GOOGLE_MAPS_API_KEY="YOUR_SERVER_KEY"
GEOCODING_PROVIDER="google"

# Optional (required for the extension’s “Add selected post” feature)
OPENROUTER_API_KEY="YOUR_OPENROUTER_KEY"
OPENROUTER_MODEL="z-ai/glm-4.5-air:free"

# Optional
ENABLE_GEOCODING="1"
DATABASE_URL="sqlite:///easyrelocate.db"
```

### Extension (Chrome)
The extension does not read `.env` files. Configure its API base URL in Chrome:
Extension → **Details** → **Extension options** → “API base URL” (default: `http://localhost:8000`).
Extension → **Details** → **Extension options** → “API base URL” (default: `http://127.0.0.1:8000`).

## Local dev (MVP)

Expand All @@ -62,7 +66,7 @@ pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```

API docs: `http://localhost:8000/docs`
API docs: `http://127.0.0.1:8000/docs`

### 2) Frontend
```bash
Expand All @@ -71,9 +75,9 @@ npm install
npm run dev
```

Open: `http://localhost:5173` (landing page)
Open: `http://127.0.0.1:5173` (landing page)

Compare app: `http://localhost:5173/#/compare`
Compare app: `http://127.0.0.1:5173/#/compare`

Set your workplace target by:
- Typing an address (US) and clicking **Save**, or
Expand All @@ -84,11 +88,15 @@ Set your workplace target by:
2. Enable **Developer mode**
3. Click **Load unpacked**
4. Select the `extension/` folder
5. In the extension **Options**, set API base URL to `http://localhost:8000` (default)
5. In the extension **Options**, set API base URL to `http://127.0.0.1:8000` (default)

Then open an Airbnb listing detail page (`https://www.airbnb.com/rooms/...`) or a Blueground
property page (`https://www.theblueground.com/p/...`) and click “Add to Compare”.

To save a housing post from anywhere (e.g., Facebook group):
1. Highlight/select the post text
2. Right click → **EasyRelocate: Add selected post**

## Google Maps setup (required)
EasyRelocate uses Google Maps Platform for:
- **Maps JavaScript API** (frontend map)
Expand All @@ -100,3 +108,4 @@ See: `docs/GOOGLE_MAPS_APPROX_LOCATION.md`
## Docs
- Platform organization: `docs/PLATFORM_ORGANIZATION.md`
- Google maps “approx street”: `docs/GOOGLE_MAPS_APPROX_LOCATION.md`
- OpenRouter LLM extraction: `docs/OPENROUTER_LLM_EXTRACTION.md`
18 changes: 16 additions & 2 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ in the repo root and put backend env vars there.
You can still override via shell exports if needed.

API docs:
- Swagger UI: `http://localhost:8000/docs`
- OpenAPI: `http://localhost:8000/openapi.json`
- Swagger UI: `http://127.0.0.1:8000/docs`
- OpenAPI: `http://127.0.0.1:8000/openapi.json`

## Geocoding (Nominatim / OpenStreetMap)
The backend can:
Expand Down Expand Up @@ -55,3 +55,17 @@ If you use Google geocoding (`GEOCODING_PROVIDER=google` or `GOOGLE_MAPS_API_KEY
If you see:
`Google Geocoding failed with status REQUEST_DENIED: This API is not activated on your API project`
it means the Geocoding API is not enabled (or billing/key restrictions are blocking it).

## LLM extraction (OpenRouter)
The backend can extract monthly rent + location from user-selected text (for Facebook groups, etc.).

Endpoint:
- `POST /api/listings/from_text` (requires `OPENROUTER_API_KEY`)

Env vars:
- `OPENROUTER_API_KEY` (required)
- `OPENROUTER_MODEL` (optional; default `z-ai/glm-4.5-air:free`)
- `OPENROUTER_BASE_URL` (optional)
- `OPENROUTER_TIMEOUT_S` (optional)

Details: `docs/OPENROUTER_LLM_EXTRACTION.md`
66 changes: 66 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from __future__ import annotations

import os
import re
import hashlib
from urllib.parse import urlsplit, urlunsplit
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Annotated
Expand All @@ -22,10 +25,16 @@
rough_location_from_address,
)
from .models import Listing, Target
from .openrouter import (
extract_housing_post,
OpenRouterConfigError,
OpenRouterProviderError,
)
from .schemas import (
CompareResponse,
GeocodeResultOut,
ListingOut,
ListingFromTextIn,
ListingSummaryOut,
ListingUpsert,
ReverseGeocodeOut,
Expand Down Expand Up @@ -67,6 +76,16 @@ async def lifespan(_: FastAPI):

DbDep = Annotated[Session, Depends(get_db)]

_RE_HTTP_URL = re.compile(r"^https?://", re.IGNORECASE)


def _build_post_source_url(page_url: str, text: str) -> str:
parts = urlsplit(page_url)
base = urlunsplit((parts.scheme, parts.netloc, parts.path, parts.query, ""))
normalized = " ".join(text.split()).strip().lower()
h = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:12]
return f"{base}#easyrelocate_post={h}"


@app.get("/api/health")
def health() -> dict[str, str]:
Expand Down Expand Up @@ -164,6 +183,53 @@ def upsert_listing(payload: ListingUpsert, db: DbDep) -> Listing:
return listing


@app.post("/api/listings/from_text", response_model=ListingOut)
def create_listing_from_text(payload: ListingFromTextIn, db: DbDep) -> Listing:
if not _RE_HTTP_URL.match(payload.page_url):
raise HTTPException(status_code=400, detail="page_url must start with http:// or https://")

try:
extracted = extract_housing_post(payload.text, page_url=payload.page_url)
except OpenRouterConfigError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
except OpenRouterProviderError as e:
raise HTTPException(status_code=502, detail=str(e)) from e
except HTTPError as e:
raise HTTPException(status_code=502, detail=str(e)) from e

source_url = _build_post_source_url(payload.page_url, payload.text)
title = extracted.title
if title is None:
first = " ".join(payload.text.split())[:80].strip()
title = first or None

lat = None
lng = None
if extracted.location_text:
try:
candidates = geocode_address(extracted.location_text, limit=1)
if candidates:
lat = candidates[0].lat
lng = candidates[0].lng
except (HTTPError, GeocodingConfigError, GeocodingProviderError):
pass

listing_payload = ListingUpsert(
source="post",
source_url=source_url,
title=title,
price_value=extracted.price_value,
currency=extracted.currency or "USD",
price_period="month" if extracted.price_value is not None else "unknown",
lat=lat,
lng=lng,
location_text=extracted.location_text,
captured_at=_utcnow(),
)

return upsert_listing(listing_payload, db)


@app.get("/api/listings", response_model=list[ListingOut])
def list_listings(db: DbDep) -> list[Listing]:
return list(db.scalars(select(Listing).order_by(Listing.captured_at.desc())))
Expand Down
Loading