用 Next.js 與 Google Maps 打造租屋地圖平台
分享「租窩 Zwoo」的開發歷程,從資料蒐集、清洗到地圖視覺化的完整技術實作紀錄,包含效能優化與成本控制經驗。
前言
找房子是每個人都會遇到的課題,但多數租屋平台的搜尋體驗並不直覺——你必須在一堆條列式的物件中來回切換,很難快速掌握物件的地理分佈。這就是我們開發「租窩 Zwoo」的初衷:用地圖的方式呈現租屋資訊,讓找房變得更直覺。
想像一下:你打開一個網站,眼前是整個台北市的地圖,上面密密麻麻的標記代表著不同的租屋物件。你可以縮放、拖動,看到特定區域有多少選擇;點擊任一標記,就能看到租金、坪數、照片等完整資訊。這就是「租窩 Zwoo」想要帶給使用者的體驗。
這篇文章會分享整個專案從零到上線的完整技術實作,包含我們遇到的挑戰與解決方案。
技術架構總覽
整個專案可以分為三大部分:資料蒐集、後端處理、前端呈現。
[租屋平台] → [Python 爬蟲] → [ETL Pipeline] → [PostgreSQL]
↓
[Next.js API Routes]
↓
[React + Google Maps]
技術選型
- 爬蟲:Python + Requests + BeautifulSoup + Playwright
- 資料庫:PostgreSQL(地理查詢支援 PostGIS)
- 後端:Next.js API Routes
- 前端:React + TypeScript + @vis.gl/react-google-maps
- 部署:Docker + GCP Cloud Run
選擇 Next.js 是因為它提供了完整的全端框架,API Routes 可以直接處理後端邏輯,不需要另外維護一個 Express 或 Fastify 伺服器。
資料蒐集
租屋資料來自公開的租屋平台,我們使用 Python 撰寫爬蟲程式,定期蒐集最新的物件資訊。蒐集的欄位包含:
- 物件標題與描述
- 租金、坪數、樓層
- 地址與經緯度座標
- 照片連結
- 發佈時間與更新時間
- 房型(套房、雅房、整層住家等)
- 附近設施(捷運站、學校、超商等)
爬蟲架構
爬蟲程式的設計考量了幾個面向:
class RentalScraper:
def __init__(self):
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 ...',
})
# 設定請求間隔,避免對目標網站造成負擔
self.request_interval = 2 # 秒
def scrape_listing(self, url: str) -> dict:
"""擷取單一物件的詳細資訊"""
response = self.session.get(url)
soup = BeautifulSoup(response.text, 'html.parser')
return {
'title': self._extract_title(soup),
'price': self._extract_price(soup),
'address': self._extract_address(soup),
'coordinates': self._extract_coordinates(soup),
# ...更多欄位
}
def scrape_list_page(self, page: int) -> list[str]:
"""擷取列表頁的所有物件連結"""
# ...
對於部分需要 JavaScript 渲染的頁面,我們使用 Playwright 來處理:
async def scrape_dynamic_page(url: str) -> str:
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto(url, wait_until='networkidle')
content = await page.content()
await browser.close()
return content
排程與監控
爬蟲透過 cron job 每天定時執行,並搭配簡單的監控機制:
- 記錄每次執行的成功/失敗數量
- 當失敗率超過閾值時發送通知
- 定期檢查資料的新鮮度,標記過期物件
資料清洗與結構化
原始資料往往不夠乾淨,常見的問題包括:
- 地址格式不一致:有些物件只有路名沒有門牌號碼,有些則包含樓層資訊
- 重複物件:同一個物件可能被多次刊登
- 缺少座標:部分物件沒有提供經緯度,需要透過 Geocoding API 轉換
- 異常數據:租金為 0、坪數為負數等明顯錯誤的資料
我們建立了一套 ETL Pipeline,用 Python 進行資料清洗:
def normalize_address(raw_address: str) -> str:
"""地址標準化"""
address = raw_address.strip()
# 統一「臺」和「台」
address = address.replace("臺", "台")
# 移除樓層資訊
address = re.sub(r"\d+樓.*$", "", address)
# 移除「之」後面的數字(如:5號之2)
address = re.sub(r"之\d+", "", address)
return address
def deduplicate(listings: list[dict]) -> list[dict]:
"""去除重複物件"""
seen = set()
unique = []
for item in listings:
# 用地址 + 租金 + 坪數作為唯一鍵
key = (item['address'], item['price'], item['area'])
if key not in seen:
seen.add(key)
unique.append(item)
return unique
def validate_listing(item: dict) -> bool:
"""驗證資料合理性"""
if item['price'] <= 0 or item['price'] > 200000:
return False
if item['area'] <= 0 or item['area'] > 200:
return False
if not item.get('coordinates'):
return False
return True
Geocoding 處理
對於缺少座標的物件,我們使用 Google Maps Geocoding API 將地址轉換為經緯度:
def geocode_address(address: str) -> tuple[float, float] | None:
"""透過 Google Geocoding API 取得座標"""
# 先檢查快取
cached = cache.get(address)
if cached:
return cached
response = requests.get(
'https://maps.googleapis.com/maps/api/geocode/json',
params={'address': address, 'key': GOOGLE_API_KEY}
)
data = response.json()
if data['status'] == 'OK':
location = data['results'][0]['geometry']['location']
result = (location['lat'], location['lng'])
# 寫入快取
cache.set(address, result)
return result
return None
前端地圖視覺化
前端使用 Next.js 搭配 @vis.gl/react-google-maps 來整合 Google Maps。每個物件會以 Marker 的形式顯示在地圖上,點擊後會彈出詳細資訊卡片。
基本地圖元件
import { APIProvider, Map, AdvancedMarker } from '@vis.gl/react-google-maps';
function RentalMap({ listings }: { listings: Listing[] }) {
const [selected, setSelected] = useState<Listing | null>(null);
return (
<APIProvider apiKey={process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!}>
<Map
defaultCenter={{ lat: 25.033, lng: 121.565 }}
defaultZoom={13}
mapId="rental-map"
>
{listings.map((listing) => (
<AdvancedMarker
key={listing.id}
position={{ lat: listing.lat, lng: listing.lng }}
onClick={() => setSelected(listing)}
/>
))}
</Map>
</APIProvider>
);
}
效能優化
當地圖上有數千個 Marker 時,瀏覽器的渲染效能會明顯下降。我們實作了幾個關鍵的優化策略:
1. Marker Clustering(標記群組化)
當地圖縮放層級較小時,將地理位置接近的 Marker 合併為一個群組標記,顯示該區域的物件數量。使用者放大地圖後,群組會自動展開為個別的 Marker。
2. Viewport Loading(視窗載入)
只載入目前地圖視窗範圍內的物件資料,而不是一次載入所有資料。當使用者拖動地圖時,動態載入新範圍的資料。
function useViewportListings(bounds: LatLngBounds | null) {
return useSWR(
bounds ? `/api/listings?${boundsToQuery(bounds)}` : null,
fetcher,
{ keepPreviousData: true }
);
}
3. Debounced Search(防抖搜尋)
使用者拖動地圖時,延遲觸發資料請求,避免過多的 API 呼叫:
const debouncedBounds = useDebouncedValue(bounds, 300);
4. 虛擬化列表
地圖旁邊的物件列表使用虛擬化渲染,只渲染可見區域內的項目,大幅減少 DOM 節點數量。
遇到的挑戰
Google Maps API 費用控制
Google Maps API 是按使用量計費的,Geocoding 和 Maps JavaScript API 都有各自的計價方式。為了控制成本,我們採取了以下策略:
- 快取 Geocoding 結果:同一個地址只會查詢一次,結果存入資料庫,下次直接讀取快取
- Server-side 渲染地圖初始狀態:減少客戶端的 API 呼叫次數
- 設定每日用量上限:在 Google Cloud Console 中設定預算警報,超過閾值自動通知
- 使用 Static Maps API:在列表頁使用靜態地圖圖片作為預覽,只有點擊進入詳細頁面時才載入互動式地圖
透過這些優化,我們將每月的 Google Maps API 費用控制在免費額度之內。
大量 Marker 的效能問題
初期我們嘗試一次載入所有物件的 Marker,結果在物件超過 3000 個時,地圖操作明顯卡頓。最終我們採用了 Marker Clustering 搭配 Viewport Loading 的組合方案,確保地圖在任何縮放層級都保持流暢。
資料即時性
租屋物件的狀態變化很快——新物件每天都在上架,已出租的物件則需要及時下架。我們設計了一個機制:
- 每天全量更新一次資料
- 標記超過 7 天未更新的物件為「可能已出租」
- 使用者可以手動回報已出租的物件
學到的經驗
資料品質決定產品品質
這個專案讓我們深刻體會到:前端再怎麼漂亮,如果底層的資料品質不好,使用者體驗就不會好。地址解析錯誤會導致 Marker 標在錯誤的位置,重複物件會讓使用者覺得資訊混亂。花在 ETL Pipeline 上的時間,絕對是值得的投資。
先做能動的版本,再優化
我們最初花了太多時間在設計完美的架構上,後來改變策略——先用最簡單的方式讓產品上線,收集使用者回饋後再針對性地優化。實際上線後才發現,使用者最在意的不是地圖的流暢度,而是資料的準確性和更新頻率。
成本意識
使用第三方 API(尤其是 Google Maps)時,成本控制是一個不容忽視的議題。在開發初期就應該設定好預算警報和用量上限,避免因為一個 bug 導致意外的高額帳單。
結語
「租窩 Zwoo」是一個從資料蒐集到前端視覺化的完整實踐。透過這個專案,我們深刻體會到:好的資料工程是產品體驗的基石。只有資料乾淨、結構化,前端的呈現才能真正為使用者帶來價值。
如果你也在做類似的地圖視覺化專案,歡迎參考我們的經驗,也歡迎透過社群與我們交流。
關於作者

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。