diff --git a/.env.example b/.env.example index 79aa97d..eafd028 100644 --- a/.env.example +++ b/.env.example @@ -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" @@ -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" diff --git a/README.md b/README.md index 2a2750e..017c9a3 100644 --- a/README.md +++ b/README.md @@ -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" @@ -42,6 +42,10 @@ 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" @@ -49,7 +53,7 @@ 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) @@ -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 @@ -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 @@ -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) @@ -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` diff --git a/backend/README.md b/backend/README.md index 17333b6..499619a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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: @@ -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` diff --git a/backend/app/main.py b/backend/app/main.py index 694cc04..b165ff4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 @@ -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, @@ -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]: @@ -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()))) diff --git a/backend/app/openrouter.py b/backend/app/openrouter.py new file mode 100644 index 0000000..5eb6e11 --- /dev/null +++ b/backend/app/openrouter.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass + +import httpx + + +OPENROUTER_BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1").rstrip( + "/" +) +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") +OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "z-ai/glm-4.5-air:free") +OPENROUTER_TIMEOUT_S = float(os.getenv("OPENROUTER_TIMEOUT_S", "25")) + +# Optional (recommended by OpenRouter; helps you see usage attribution in their dashboard) +OPENROUTER_APP_URL = os.getenv("OPENROUTER_APP_URL") +OPENROUTER_APP_NAME = os.getenv("OPENROUTER_APP_NAME", "EasyRelocate") + + +class OpenRouterError(RuntimeError): + pass + + +class OpenRouterConfigError(OpenRouterError): + pass + + +class OpenRouterProviderError(OpenRouterError): + pass + + +@dataclass(frozen=True) +class HousingPostExtraction: + title: str | None + location_text: str | None + price_value: float | None + currency: str | None + price_period: str | None + + +def _extract_json_object(text: str) -> dict[str, object]: + raw = text.strip() + if raw.startswith("```"): + raw = re.sub(r"^```[a-zA-Z0-9_-]*\n?", "", raw) + raw = re.sub(r"\n?```$", "", raw) + raw = raw.strip() + + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + pass + + start = raw.find("{") + end = raw.rfind("}") + if start == -1 or end == -1 or end <= start: + raise OpenRouterProviderError("Model did not return a JSON object") + + snippet = raw[start : end + 1] + try: + parsed2 = json.loads(snippet) + except json.JSONDecodeError as e: + raise OpenRouterProviderError("Model returned invalid JSON") from e + if not isinstance(parsed2, dict): + raise OpenRouterProviderError("Model did not return a JSON object") + return parsed2 + + +def _as_str(v: object) -> str | None: + if not isinstance(v, str): + return None + s = v.strip() + return s or None + + +def _as_float(v: object) -> float | None: + if v is None: + return None + if isinstance(v, (int, float)): + f = float(v) + if f == f and abs(f) != float("inf"): + return f + return None + if isinstance(v, str): + cleaned = v.strip().replace(",", "") + m = re.search(r"-?\d+(?:\.\d+)?", cleaned) + if not m: + return None + try: + f = float(m.group(0)) + except ValueError: + return None + if f == f and abs(f) != float("inf"): + return f + return None + + +def _normalize_currency(v: object) -> str | None: + s = _as_str(v) + if not s: + return None + if s in {"$", "USD"}: + return "USD" + if s in {"€", "EUR"}: + return "EUR" + if s in {"£", "GBP"}: + return "GBP" + if re.fullmatch(r"[A-Z]{3}", s): + return s + return None + + +def _normalize_price_period(v: object) -> str | None: + s = _as_str(v) + if not s: + return None + s2 = s.lower() + if s2 in {"month", "monthly", "per month", "/month"}: + return "month" + if s2 in {"night", "nightly", "per night", "/night"}: + return "night" + if s2 in {"total"}: + return "total" + return None + + +def extract_housing_post(text: str, *, page_url: str | None = None) -> HousingPostExtraction: + if not OPENROUTER_API_KEY: + raise OpenRouterConfigError("OPENROUTER_API_KEY is not set") + + selection = text.strip() + if not selection: + return HousingPostExtraction( + title=None, + location_text=None, + price_value=None, + currency=None, + price_period=None, + ) + + selection = selection[:7000] + + system = ( + "You extract housing listing info from user-selected text. " + "Return ONLY a JSON object (no markdown, no backticks). " + "Use null for missing values. " + "Do not invent facts that are not present in the text." + ) + user = ( + "Extract the best possible housing listing fields from the selected text.\n" + "\n" + "Rules:\n" + "- Focus on MONTHLY rent only. Ignore deposits, application fees, and one-time fees.\n" + "- If the post gives weekly/daily pricing, convert to an estimated monthly rent:\n" + " - weekly -> weekly * 4.345\n" + " - nightly/daily -> nightly * 30\n" + "- If multiple rents are mentioned, pick the primary rent.\n" + "- If currency is unclear, use USD.\n" + "- Location: prefer the most specific geocodable, privacy-preserving location mentioned.\n" + " Priority:\n" + " 1) Cross-street / intersection / \"Near X & Y\" (best)\n" + " 2) Neighborhood or ZIP + city/state\n" + " 3) City/state\n" + " Formatting:\n" + " - If the text contains \"Near X & Y\" (or \"Near X and Y\"), set location_text to:\n" + " \"X & Y, City, State ZIP, Country\" when available.\n" + " - If a US 5-digit ZIP code is present, assume Country = USA.\n" + " If you recognize the state for that ZIP/city, include the state abbreviation.\n" + " - If the text says \"Near 101 & McLaughlin Ave\" in a US context, normalize \"101\" -> \"US-101\".\n" + " - Do NOT include personal contact details in location_text (phone/email).\n" + "\n" + "Return JSON with keys:\n" + '- title: string|null (short name)\n' + '- location_text: string|null (best location string per rules above)\n' + '- price_value: number|null (monthly)\n' + '- currency: string|null (USD/EUR/GBP/...) \n' + '- price_period: string|null (must be \"month\" when price_value exists, else null)\n' + "\n" + "Examples:\n" + "- Input: \"Near 101 & McLaughlin Ave) San Jose 95121\" ->\n" + " location_text: \"US-101 & McLaughlin Ave, San Jose, CA 95121, USA\" (if CA/USA is implied)\n" + "\n" + f"page_url: {page_url or ''}\n" + "\n" + "selected_text:\n" + f"{selection}\n" + ) + + headers: dict[str, str] = {"Authorization": f"Bearer {OPENROUTER_API_KEY}"} + if OPENROUTER_APP_URL: + headers["HTTP-Referer"] = OPENROUTER_APP_URL + if OPENROUTER_APP_NAME: + headers["X-Title"] = OPENROUTER_APP_NAME + + res = httpx.post( + f"{OPENROUTER_BASE_URL}/chat/completions", + headers=headers, + json={ + "model": OPENROUTER_MODEL, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "temperature": 0.0, + }, + timeout=OPENROUTER_TIMEOUT_S, + ) + res.raise_for_status() + data = res.json() + if not isinstance(data, dict): + raise OpenRouterProviderError("OpenRouter returned an invalid response") + + choices = data.get("choices") + if not isinstance(choices, list) or not choices: + raise OpenRouterProviderError("OpenRouter returned no choices") + + msg = choices[0].get("message") + if not isinstance(msg, dict): + raise OpenRouterProviderError("OpenRouter returned an invalid message") + + content = msg.get("content") + if not isinstance(content, str) or not content.strip(): + raise OpenRouterProviderError("OpenRouter returned empty content") + + obj = _extract_json_object(content) + + title = _as_str(obj.get("title")) + location_text = _as_str(obj.get("location_text")) + if location_text: + location_text = location_text.strip().strip(",").strip() + price_value = _as_float(obj.get("price_value")) + currency = _normalize_currency(obj.get("currency")) or ("USD" if price_value else None) + + price_period = _normalize_price_period(obj.get("price_period")) + if price_value is not None: + price_period = "month" + else: + price_period = None + + return HousingPostExtraction( + title=title, + location_text=location_text, + price_value=price_value, + currency=currency, + price_period=price_period, + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 46926fe..92ebe53 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -12,7 +12,7 @@ ) -ListingSource = Literal["airbnb", "blueground"] +ListingSource = Literal["airbnb", "blueground", "post"] PricePeriod = Literal["night", "month", "total", "unknown"] @@ -58,6 +58,11 @@ class ListingSummaryOut(BaseModel): latest_captured_at: datetime | None = None +class ListingFromTextIn(BaseModel): + text: str = Field(min_length=1, max_length=20000) + page_url: str = Field(min_length=1, max_length=2048) + + class TargetUpsert(BaseModel): id: str | None = None name: str = Field(min_length=1, max_length=256) diff --git a/docs/OPENROUTER_LLM_EXTRACTION.md b/docs/OPENROUTER_LLM_EXTRACTION.md new file mode 100644 index 0000000..b86adba --- /dev/null +++ b/docs/OPENROUTER_LLM_EXTRACTION.md @@ -0,0 +1,54 @@ +# OpenRouter LLM extraction (selected posts) + +EasyRelocate can turn **user-selected text** (e.g., a Facebook group housing post) into a saved listing by +using an LLM via **OpenRouter**. + +This feature is intentionally **user-driven**: +- We only analyze text the user explicitly selects. +- We do not scrape platforms server-side. + +## Data flow +1. User highlights a post (any site), then: + - Right click → **EasyRelocate: Add selected post**, or + - On Facebook groups pages, click the floating **Add Selected Post** button. +2. The extension sends `{ text, page_url }` to the backend. +3. Backend calls OpenRouter Chat Completions and parses JSON output. +4. Backend optionally geocodes the extracted `location_text` to get `lat/lng`. +5. Backend stores only the minimal fields (title/price/location/coords), not the full post. + +## Backend endpoint +`POST /api/listings/from_text` + +Request body: +```json +{ + "text": "…selected text…", + "page_url": "https://www.facebook.com/groups/…" +} +``` + +## Environment variables +Put these in the **repo-root** `.env`: +- `OPENROUTER_API_KEY` (required) +- `OPENROUTER_MODEL` (optional; default: `z-ai/glm-4.5-air:free`) +- `OPENROUTER_BASE_URL` (optional; default: `https://openrouter.ai/api/v1`) +- `OPENROUTER_TIMEOUT_S` (optional; default: `25`) +- `OPENROUTER_APP_URL` / `OPENROUTER_APP_NAME` (optional; attribution headers) + +## Model choice +Default is `z-ai/glm-4.5-air:free` to keep costs low while we iterate on the MVP. + +If extraction quality isn’t good enough, switch to a paid model by setting `OPENROUTER_MODEL` +in `.env` (restart backend after changes). + +## Prompt engineering (what we optimize for) +The backend prompt is designed to: +- Extract **monthly rent** only (ignore deposits, background check fees, and other one-time fees). +- Convert weekly/daily pricing to monthly estimates when needed. +- Extract a **geocodable, privacy-preserving** `location_text`: + - Prefer cross-street / intersection / “Near X & Y” when available. + - Format as: `X & Y, City, State ZIP, Country` when possible. + - Example: `US-101 & McLaughlin Ave, San Jose, CA 95121, USA` + +Implementation reference: `backend/app/openrouter.py`. + diff --git a/docs/PLATFORM_ORGANIZATION.md b/docs/PLATFORM_ORGANIZATION.md index fb9580e..6e71bac 100644 --- a/docs/PLATFORM_ORGANIZATION.md +++ b/docs/PLATFORM_ORGANIZATION.md @@ -18,8 +18,6 @@ extension/ content.js blueground/ content.js - facebook/ - content.js background.js options.html options.js @@ -31,6 +29,10 @@ Each platform content script should: - Extract the same minimal listing schema (`source`, `source_url`, `title`, `price`, `lat/lng`, …) - POST to the same backend endpoint (`POST /api/listings`) +For “Facebook group / any site” posts, prefer a selection-based flow (no site-specific content script): +- User highlights text → right click → **EasyRelocate: Add selected post** +- Extension calls `POST /api/listings/from_text` (backend uses OpenRouter to extract location + monthly rent) + Shared code (later) can live in `extension/shared/` (e.g., overlay UI, storage helpers). ### Backend @@ -53,3 +55,7 @@ If/when needed, add a thin `backend/app/platforms/` layer for: - Platform extraction scripts: - Airbnb: `extension/platforms/airbnb/content.js` - Blueground: `extension/platforms/blueground/content.js` +- Facebook groups: `extension/platforms/facebook/content.js` +- Selected-post extraction: + - Extension context menu: `extension/background.js` + - Backend LLM endpoint: `POST /api/listings/from_text` diff --git a/extension/README.md b/extension/README.md index 7c82f46..276e26a 100644 --- a/extension/README.md +++ b/extension/README.md @@ -9,7 +9,7 @@ ## Configure API base URL 1. In the extension card, click **Details** 2. Click **Extension options** -3. Set the API base URL (default: `http://localhost:8000`) +3. Set the API base URL (default: `http://127.0.0.1:8000`) Note: the extension does **not** read `.env` files. Its API base URL is stored in Chrome sync storage and can differ from the frontend’s `VITE_API_BASE_URL` if needed. @@ -29,3 +29,16 @@ Blueground property pages include a map location. The extension typically captur ## UI - The “Add to Compare” button is draggable; drop it where you like and it will remember the position. + +## Selected posts (Facebook group / any site) +You can also save a housing post from any website: +1. Highlight/select the post text +2. Right click → **EasyRelocate: Add selected post** + +Or on Facebook groups pages, use the floating button: +- Open a group page like `https://www.facebook.com/groups/...` +- Select text in a post +- Click **Add Selected Post** + +This uses the backend endpoint `POST /api/listings/from_text`, which requires `OPENROUTER_API_KEY` +to be set in the repo-root `.env`. diff --git a/extension/api.js b/extension/api.js index bb8446f..838e2a5 100644 --- a/extension/api.js +++ b/extension/api.js @@ -1,4 +1,4 @@ -const DEFAULT_API_BASE_URL = 'http://localhost:8000' +const DEFAULT_API_BASE_URL = 'http://127.0.0.1:8000' function storageGet(keysWithDefaults) { return new Promise((resolve) => { @@ -47,4 +47,3 @@ async function postToApi(path, payload) { const full = `${base}${path.startsWith('/') ? '' : '/'}${path}` return postJson(full, payload) } - diff --git a/extension/background.js b/extension/background.js index 25805fc..b40c260 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,10 +1,68 @@ importScripts('api.js') +const MENU_ID_ADD_SELECTION = 'easyrelocate_add_selection_v1' + +function setActionBadge(text, color) { + try { + chrome.action.setBadgeText({ text }) + chrome.action.setBadgeBackgroundColor({ color }) + } catch { + // ignore + } +} + +function setupContextMenus() { + try { + chrome.contextMenus.removeAll(() => { + chrome.contextMenus.create({ + id: MENU_ID_ADD_SELECTION, + title: 'EasyRelocate: Add selected post', + contexts: ['selection'], + }) + }) + } catch { + // ignore + } +} + +chrome.runtime.onInstalled.addListener(() => setupContextMenus()) +chrome.runtime.onStartup?.addListener(() => setupContextMenus()) + +chrome.contextMenus.onClicked.addListener((info, tab) => { + if (info.menuItemId !== MENU_ID_ADD_SELECTION) return + + const text = String(info.selectionText || '').trim() + const pageUrl = String(info.pageUrl || tab?.url || '').trim() + if (!text) return + if (!pageUrl) return + + setActionBadge('…', '#64748b') + + ;(async () => { + await postToApi('/api/listings/from_text', { text: text.slice(0, 20000), page_url: pageUrl }) + setActionBadge('✓', '#16a34a') + setTimeout(() => setActionBadge('', '#64748b'), 2500) + })().catch((_err) => { + setActionBadge('!', '#dc2626') + setTimeout(() => setActionBadge('', '#64748b'), 3500) + }) +}) + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { - if (!message || message.type !== 'EASYRELOCATE_ADD_LISTING') return + if (!message || !message.type) return ;(async () => { - const data = await postToApi('/api/listings', message.payload) + const path = + message.type === 'EASYRELOCATE_ADD_LISTING_FROM_TEXT' + ? '/api/listings/from_text' + : message.type === 'EASYRELOCATE_ADD_LISTING' + ? '/api/listings' + : null + if (!path) { + sendResponse({ ok: false, error: 'Unsupported message type' }) + return + } + const data = await postToApi(path, message.payload) sendResponse({ ok: true, data }) })().catch((err) => { sendResponse({ ok: false, error: String(err?.message ?? err) }) diff --git a/extension/manifest.json b/extension/manifest.json index f1bb7c8..7994ed9 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,15 +2,17 @@ "manifest_version": 3, "name": "EasyRelocate", "version": "0.1.0", - "description": "Save listings (Airbnb, Blueground) to compare in EasyRelocate.", - "permissions": ["storage"], + "description": "Save listings (Airbnb, Blueground) and selected posts to compare in EasyRelocate.", + "permissions": ["storage", "contextMenus"], "host_permissions": [ "http://localhost/*", "http://127.0.0.1/*", "https://airbnb.com/*", "https://*.airbnb.com/*", "https://theblueground.com/*", - "https://*.theblueground.com/*" + "https://*.theblueground.com/*", + "https://facebook.com/*", + "https://*.facebook.com/*" ], "background": { "service_worker": "background.js" @@ -28,6 +30,14 @@ ], "js": ["shared/overlay.js", "platforms/blueground/content.js"], "run_at": "document_idle" + }, + { + "matches": [ + "https://facebook.com/groups/*", + "https://*.facebook.com/groups/*" + ], + "js": ["shared/overlay.js", "platforms/facebook/content.js"], + "run_at": "document_idle" } ], "options_page": "options.html", diff --git a/extension/options.html b/extension/options.html index ee2c901..62cc0ed 100644 --- a/extension/options.html +++ b/extension/options.html @@ -71,10 +71,14 @@
Set where the extension POSTs listing snapshots.
++ Tip: to save a housing post from anywhere, highlight text and right click → “EasyRelocate: + Add selected post”. +
EasyRelocate is a non-commercial decision-support tool for interns/students relocating to a new city. Save listings while you browse (starting with - Airbnb and Blueground), then compare them on one map with price + distance + Airbnb, Blueground and LLM extraction method), then compare them on one map with price + distance filters.