← 返回部落格
·12 min 閱讀·專案紀錄

用 Next.js 與 Google Maps 打造租屋地圖平台

分享「租窩 Zwoo」的開發歷程,從資料蒐集、清洗到地圖視覺化的完整技術實作紀錄,包含效能優化與成本控制經驗。

Next.jsGoogle Maps資料視覺化專案紀錄
GP Wang
GP Wang
GWP4 STUDIO 創辦人 · 軟體 / 資料工程師

前言

找房子是每個人都會遇到的課題,但多數租屋平台的搜尋體驗並不直覺——你必須在一堆條列式的物件中來回切換,很難快速掌握物件的地理分佈。這就是我們開發「租窩 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」是一個從資料蒐集到前端視覺化的完整實踐。透過這個專案,我們深刻體會到:好的資料工程是產品體驗的基石。只有資料乾淨、結構化,前端的呈現才能真正為使用者帶來價值。

如果你也在做類似的地圖視覺化專案,歡迎參考我們的經驗,也歡迎透過社群與我們交流。

關於作者

GP Wang
GP Wang

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

了解更多 →