Skip to content

Commit 5b3a675

Browse files
authored
Merge pull request #13 from YuWei-CH/blueground
Blueground
2 parents a765854 + 0fb2519 commit 5b3a675

File tree

12 files changed

+812
-266
lines changed

12 files changed

+812
-266
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ EasyRelocate is an open-source, non-commercial decision-support tool for housing
33

44
When relocating for an internship, research visit, or new job, housing information is often fragmented across multiple platforms, making comparison slow and error-prone. EasyRelocate helps users organize and compare housing options by focusing on what matters most: where to live, not where to book.
55

6-
Users collect listings while browsing platforms such as Airbnb, BlueGround, facebook group using a lightweight browser extension. EasyRelocate then aggregates the minimal, user-authorized information needed to visualize listings on a single map and compare them by price, location, and commute time to a chosen workplace.
6+
Users collect listings while browsing platforms such as Airbnb, Blueground, facebook group using a lightweight browser extension. EasyRelocate then aggregates the minimal, user-authorized information needed to visualize listings on a single map and compare them by price, location, and commute time to a chosen workplace.
77

88
EasyRelocate does not scrape platforms server-side, host listings, process payments, or replace original marketplaces. It exists solely to help users make better relocation decisions, while respecting platform boundaries and directing all final actions back to the original sources.
99

@@ -86,7 +86,8 @@ Set your workplace target by:
8686
4. Select the `extension/` folder
8787
5. In the extension **Options**, set API base URL to `http://localhost:8000` (default)
8888

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

9192
## Google Maps setup (required)
9293
EasyRelocate uses Google Maps Platform for:

backend/app/schemas.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
)
1313

1414

15-
ListingSource = Literal["airbnb"]
15+
ListingSource = Literal["airbnb", "blueground"]
1616
PricePeriod = Literal["night", "month", "total", "unknown"]
1717

1818

backend/tests/test_targets_compare.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,24 @@ def test_listings_summary_empty_then_one() -> None:
187187
assert data["count"] == 1
188188
assert data["latest_id"] == listing_id
189189
assert data["latest_captured_at"] == "2026-01-30T10:00:00Z"
190+
191+
192+
def test_upsert_listing_accepts_blueground_source() -> None:
193+
with TestClient(main.app) as client:
194+
created = client.post(
195+
"/api/listings",
196+
json={
197+
"source": "blueground",
198+
"source_url": "https://www.theblueground.com/p/furnished-apartments/sfo-1622",
199+
"title": "Blueground test",
200+
"currency": "USD",
201+
"price_period": "unknown",
202+
"captured_at": "2026-01-30T10:00:00Z",
203+
"lat": 37.4052047,
204+
"lng": -122.104919,
205+
"location_text": "Mountain View, CA",
206+
},
207+
)
208+
assert created.status_code == 200, created.text
209+
data = created.json()
210+
assert data["source"] == "blueground"

docs/PLATFORM_ORGANIZATION.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,7 @@ If/when needed, add a thin `backend/app/platforms/` layer for:
4949
- Optional “source metadata” (still minimal; avoid storing full descriptions/images)
5050

5151
## Current status
52-
- The extension uses a platform folder for Airbnb at `extension/platforms/airbnb/content.js`.
53-
52+
- Shared overlay UI lives at `extension/shared/overlay.js`.
53+
- Platform extraction scripts:
54+
- Airbnb: `extension/platforms/airbnb/content.js`
55+
- Blueground: `extension/platforms/blueground/content.js`

extension/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,18 @@
1414
Note: the extension does **not** read `.env` files. Its API base URL is stored in Chrome sync storage
1515
and can differ from the frontend’s `VITE_API_BASE_URL` if needed.
1616

17-
## Notes on Airbnb location
17+
## Notes on supported platforms
18+
### Airbnb
1819
Airbnb typically does **not** show precise street addresses. The extension tries to capture:
1920
- Lat/lng (when available in structured data / meta tags)
2021
- A rough location string (city/region)
2122

2223
If only lat/lng is available, the backend may reverse-geocode it to a rough location (city/state).
2324

25+
### Blueground
26+
Blueground property pages include a map location. The extension typically captures:
27+
- Lat/lng (from the page’s embedded data)
28+
- A location string (often including street/building + city)
29+
2430
## UI
2531
- The “Add to Compare” button is draggable; drop it where you like and it will remember the position.

extension/manifest.json

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,31 @@
22
"manifest_version": 3,
33
"name": "EasyRelocate",
44
"version": "0.1.0",
5-
"description": "Save Airbnb listings to compare in EasyRelocate.",
5+
"description": "Save listings (Airbnb, Blueground) to compare in EasyRelocate.",
66
"permissions": ["storage"],
77
"host_permissions": [
88
"http://localhost/*",
99
"http://127.0.0.1/*",
1010
"https://airbnb.com/*",
11-
"https://*.airbnb.com/*"
11+
"https://*.airbnb.com/*",
12+
"https://theblueground.com/*",
13+
"https://*.theblueground.com/*"
1214
],
1315
"background": {
1416
"service_worker": "background.js"
1517
},
1618
"content_scripts": [
1719
{
1820
"matches": ["https://airbnb.com/rooms/*", "https://*.airbnb.com/rooms/*"],
19-
"js": ["platforms/airbnb/content.js"],
21+
"js": ["shared/overlay.js", "platforms/airbnb/content.js"],
22+
"run_at": "document_idle"
23+
},
24+
{
25+
"matches": [
26+
"https://theblueground.com/p/*",
27+
"https://*.theblueground.com/p/*"
28+
],
29+
"js": ["shared/overlay.js", "platforms/blueground/content.js"],
2030
"run_at": "document_idle"
2131
}
2232
],

extension/platforms/airbnb/content.js

Lines changed: 4 additions & 236 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,3 @@
1-
const ROOT_ID = 'easyrelocate-root'
2-
const OVERLAY_POS_KEY = 'easyrelocate_overlay_pos_v1'
3-
const OVERLAY_MARGIN_PX = 10
4-
5-
function sleep(ms) {
6-
return new Promise((r) => setTimeout(r, ms))
7-
}
8-
9-
function storageGet(keysWithDefaults) {
10-
return new Promise((resolve) => {
11-
try {
12-
chrome.storage.sync.get(keysWithDefaults, (items) => resolve(items))
13-
} catch {
14-
resolve(keysWithDefaults)
15-
}
16-
})
17-
}
18-
19-
function storageSet(items) {
20-
return new Promise((resolve) => {
21-
try {
22-
chrome.storage.sync.set(items, () => resolve())
23-
} catch {
24-
resolve()
25-
}
26-
})
27-
}
28-
291
function canonicalAirbnbUrl(href) {
302
try {
313
const url = new URL(href)
@@ -41,10 +13,6 @@ function parseNumber(value) {
4113
return Number.isFinite(n) ? n : null
4214
}
4315

44-
function clamp(n, min, max) {
45-
return Math.min(max, Math.max(min, n))
46-
}
47-
4816
function currencyFromSymbol(sym) {
4917
if (sym === '$') return 'USD'
5018
if (sym === '€') return 'EUR'
@@ -458,209 +426,9 @@ function extractListingSnapshot() {
458426
}
459427
}
460428

461-
function showToast(message, kind = 'info') {
462-
const el = document.createElement('div')
463-
el.textContent = message
464-
el.style.position = 'fixed'
465-
el.style.right = '16px'
466-
el.style.bottom = '16px'
467-
el.style.zIndex = '2147483647'
468-
el.style.padding = '10px 12px'
469-
el.style.borderRadius = '10px'
470-
el.style.border = '1px solid rgba(0,0,0,0.08)'
471-
el.style.boxShadow = '0 6px 20px rgba(0,0,0,0.12)'
472-
el.style.background = kind === 'error' ? '#fee2e2' : '#ffffff'
473-
el.style.color = '#0f172a'
474-
el.style.fontFamily = 'system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial'
475-
el.style.fontSize = '13px'
476-
document.documentElement.appendChild(el)
477-
setTimeout(() => el.remove(), 2400)
478-
}
479-
480-
function clampOverlayPosition(root, left, top) {
481-
const rect = root.getBoundingClientRect()
482-
const maxLeft = window.innerWidth - rect.width - OVERLAY_MARGIN_PX
483-
const maxTop = window.innerHeight - rect.height - OVERLAY_MARGIN_PX
484-
return {
485-
left: clamp(left, OVERLAY_MARGIN_PX, Math.max(OVERLAY_MARGIN_PX, maxLeft)),
486-
top: clamp(top, OVERLAY_MARGIN_PX, Math.max(OVERLAY_MARGIN_PX, maxTop)),
487-
}
488-
}
489-
490-
function applyOverlayPosition(root, left, top) {
491-
const clamped = clampOverlayPosition(root, left, top)
492-
root.style.left = `${clamped.left}px`
493-
root.style.top = `${clamped.top}px`
494-
root.style.right = 'auto'
495-
root.style.bottom = 'auto'
496-
}
497-
498-
async function loadOverlayPosition(root) {
499-
const items = await storageGet({ [OVERLAY_POS_KEY]: null })
500-
const pos = items[OVERLAY_POS_KEY]
501-
if (!pos || typeof pos !== 'object') return
502-
const left = Number(pos.left)
503-
const top = Number(pos.top)
504-
if (!Number.isFinite(left) || !Number.isFinite(top)) return
505-
applyOverlayPosition(root, left, top)
506-
}
507-
508-
function createOverlay() {
509-
if (document.getElementById(ROOT_ID)) return
510-
511-
const root = document.createElement('div')
512-
root.id = ROOT_ID
513-
root.style.position = 'fixed'
514-
root.style.top = '16px'
515-
root.style.right = '16px'
516-
root.style.zIndex = '2147483647'
517-
root.style.userSelect = 'none'
518-
root.style.touchAction = 'none'
519-
520-
const btn = document.createElement('button')
521-
btn.textContent = 'Add to Compare'
522-
btn.style.display = 'inline-flex'
523-
btn.style.alignItems = 'center'
524-
btn.style.gap = '8px'
525-
btn.style.border = '1px solid rgba(15,23,42,0.14)'
526-
btn.style.background = 'linear-gradient(135deg, #0f172a 0%, #1d4ed8 120%)'
527-
btn.style.color = '#ffffff'
528-
btn.style.borderRadius = '999px'
529-
btn.style.padding = '10px 14px'
530-
btn.style.fontSize = '13px'
531-
btn.style.fontWeight = '650'
532-
btn.style.cursor = 'pointer'
533-
btn.style.boxShadow = '0 10px 26px rgba(2,6,23,0.18)'
534-
btn.style.letterSpacing = '0.2px'
535-
btn.style.fontFamily =
536-
'system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial'
537-
btn.style.transition = 'filter 140ms ease, transform 80ms ease'
538-
539-
const plus = document.createElement('span')
540-
plus.textContent = '+'
541-
plus.style.display = 'inline-grid'
542-
plus.style.placeItems = 'center'
543-
plus.style.width = '18px'
544-
plus.style.height = '18px'
545-
plus.style.borderRadius = '999px'
546-
plus.style.background = 'rgba(255,255,255,0.18)'
547-
plus.style.fontWeight = '800'
548-
plus.style.lineHeight = '1'
549-
plus.style.flexShrink = '0'
550-
btn.prepend(plus)
551-
552-
let isDragging = false
553-
let didDrag = false
554-
let startX = 0
555-
let startY = 0
556-
let startLeft = 0
557-
let startTop = 0
558-
559-
function ensureLeftTopPositioning() {
560-
if (root.style.left) return
561-
const rect = root.getBoundingClientRect()
562-
root.style.left = `${rect.left}px`
563-
root.style.top = `${rect.top}px`
564-
root.style.right = 'auto'
565-
root.style.bottom = 'auto'
566-
}
567-
568-
btn.addEventListener('pointerdown', (e) => {
569-
if (e.button !== 0) return
570-
btn.setPointerCapture(e.pointerId)
571-
ensureLeftTopPositioning()
572-
const rect = root.getBoundingClientRect()
573-
startX = e.clientX
574-
startY = e.clientY
575-
startLeft = rect.left
576-
startTop = rect.top
577-
isDragging = true
578-
didDrag = false
579-
btn.style.transform = 'scale(0.98)'
580-
})
581-
582-
btn.addEventListener('pointermove', (e) => {
583-
if (!isDragging) return
584-
const dx = e.clientX - startX
585-
const dy = e.clientY - startY
586-
if (!didDrag && Math.hypot(dx, dy) < 6) return
587-
didDrag = true
588-
applyOverlayPosition(root, startLeft + dx, startTop + dy)
589-
})
590-
591-
async function finishDrag(e) {
592-
if (!isDragging) return
593-
isDragging = false
594-
btn.style.transform = ''
595-
try {
596-
btn.releasePointerCapture(e.pointerId)
597-
} catch {
598-
// ignore
599-
}
600-
if (!didDrag) return
601-
const rect = root.getBoundingClientRect()
602-
await storageSet({
603-
[OVERLAY_POS_KEY]: { left: rect.left, top: rect.top },
604-
})
605-
}
606-
607-
btn.addEventListener('pointerup', (e) => void finishDrag(e))
608-
btn.addEventListener('pointercancel', (e) => void finishDrag(e))
609-
610-
btn.addEventListener('mouseenter', () => {
611-
btn.style.filter = 'brightness(1.03)'
429+
if (globalThis.EasyRelocateOverlay && typeof globalThis.EasyRelocateOverlay.boot === 'function') {
430+
void globalThis.EasyRelocateOverlay.boot({
431+
extractListingSnapshot,
432+
initialDelayMs: 1200,
612433
})
613-
btn.addEventListener('mouseleave', () => {
614-
btn.style.filter = ''
615-
btn.style.transform = ''
616-
})
617-
618-
btn.addEventListener('click', async () => {
619-
if (didDrag) {
620-
didDrag = false
621-
return
622-
}
623-
btn.disabled = true
624-
btn.style.opacity = '0.75'
625-
try {
626-
const payload = extractListingSnapshot()
627-
const res = await new Promise((resolve) => {
628-
chrome.runtime.sendMessage({ type: 'EASYRELOCATE_ADD_LISTING', payload }, (resp) =>
629-
resolve(resp),
630-
)
631-
})
632-
if (res && res.ok) {
633-
showToast('Saved to EasyRelocate.')
634-
} else {
635-
showToast(`Failed: ${res?.error ?? 'Unknown error'}`, 'error')
636-
}
637-
} catch (e) {
638-
showToast(`Failed: ${String(e?.message ?? e)}`, 'error')
639-
} finally {
640-
btn.disabled = false
641-
btn.style.opacity = '1'
642-
}
643-
})
644-
645-
root.appendChild(btn)
646-
document.documentElement.appendChild(root)
647-
648-
void loadOverlayPosition(root)
649-
window.addEventListener(
650-
'resize',
651-
() => {
652-
if (!root.style.left) return
653-
const rect = root.getBoundingClientRect()
654-
applyOverlayPosition(root, rect.left, rect.top)
655-
},
656-
{ passive: true },
657-
)
658434
}
659-
660-
async function boot() {
661-
// Wait a bit for Airbnb client rendering to settle.
662-
await sleep(1200)
663-
createOverlay()
664-
}
665-
666-
void boot()

0 commit comments

Comments
 (0)