Python爬取香港租房数据完整教程 - 28Hse抓取+Claude分析Pipeline代码实现

2026-06-05 更新:本文 Python 代码已在 28Hse 公开页面上实测可跑通。如果你打开文章时 28Hse 改版导致 CSS 类名变化,请回到文末「踩坑记录」第 1 条。


在这里插入图片描述

一、问题背景

我是 2022 年来港的 FinTech 工程师,刚来时租房全靠中介报价,完全不知道市场价分位。后来把 28Hse 上的在租盘自己爬了一遍,再让 Claude 做归因分析,才搞清楚"将军澳到底有没有被低估"这种问题。

整个 Pipeline 分 4 步:抓取 → 清洗 → Claude 归因 → 可视化。下面给出可复现的完整代码。


二、环境信息

依赖 版本 作用
Python 3.11.6 解释器
requests 2.32.3 HTTP 请求
beautifulsoup4 4.12.3 HTML 解析
pandas 2.2.2 数据清洗
anthropic 0.39.0 Claude API 客户端
matplotlib 3.9.0 图表绘制
pip install requests beautifulsoup4 pandas anthropic matplotlib

API key 请到 Anthropic Console 申请,通过环境变量 ANTHROPIC_API_KEY 注入。不要把 key 写在代码里。


三、Pipeline 流程图

requests + BS4

pandas 清洗

Claude API

matplotlib

28Hse 公开页

原始 HTML

标准化 CSV

Markdown 报告

柱状图 PNG

飞书/小红书/朋友圈


四、抓取 28Hse 公开租盘(核心代码)

# hk_rent_pipeline.py
import requests
from bs4 import BeautifulSoup
import pandas as pd
import time
import random
import re
from pathlib import Path

DISTRICTS = [
    "central", "wan-chai", "causeway-bay",
    "mong-kok", "yau-ma-tei", "hung-hom",
    "tseung-kwan-o", "sha-tin", "tai-wai",
]
BASE_URL = "https://www.28hse.com/zh-hk/rent/residential/{district}"
HEADERS = {
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                  "AppleWebKit/537.36 (KHTML, like Gecko) "
                  "Chrome/126.0.0.0 Safari/537.36",
    "Accept-Language": "zh-HK,zh;q=0.9,en;q=0.8",
}

def fetch_district(district: str, max_pages: int = 3) -> list[dict]:
    rows = []
    for page in range(1, max_pages + 1):
        url = BASE_URL.format(district=district) + f"?page={page}"
        resp = requests.get(url, headers=HEADERS, timeout=15)
        resp.raise_for_status()
        soup = BeautifulSoup(resp.text, "html.parser")

        for item in soup.select(".item-info-wrap"):
            try:
                title = item.select_one(".item-title").get_text(strip=True)
                price_text = item.select_one(".item-price").get_text(strip=True)
                area_text = item.select_one(".item-area").get_text(strip=True)
                rooms = item.select_one(".item-room").get_text(strip=True)
                price = int(re.sub(r"[^\d]", "", price_text))
                sqft = int(re.search(r"(\d+)\s*呎", area_text).group(1))
                rows.append({
                    "district": district, "title": title, "rooms": rooms,
                    "sqft": sqft, "rent_hkd": price,
                    "rent_per_sqft": round(price / sqft, 2),
                })
            except (AttributeError, ValueError):
                continue
        time.sleep(random.uniform(8, 15))
    return rows

def main():
    all_rows = []
    for d in DISTRICTS:
        print(f"抓取 {d} ...")
        all_rows.extend(fetch_district(d))
    df = pd.DataFrame(all_rows)
    df.to_csv("hk_rent_raw.csv", index=False, encoding="utf-8-sig")
    print(f"写入 hk_rent_raw.csv,共 {len(df)} 条")

if __name__ == "__main__":
    main()

关键点:限速 8-15 秒随机,参考人类浏览节奏。爬得太快会被封 IP。


五、清洗与聚合

import pandas as pd
from pathlib import Path

raw = pd.read_csv("hk_rent_raw.csv")
clean = raw[
    (raw["rent_per_sqft"] >= 20) & (raw["rent_per_sqft"] <= 100)
].copy()

DISTRICT_CN = {
    "central": "中环", "wan-chai": "湾仔", "causeway-bay": "铜锣湾",
    "mong-kok": "旺角", "yau-ma-tei": "油麻地", "hung-hom": "红磡",
    "tseung-kwan-o": "将军澳", "sha-tin": "沙田", "tai-wai": "大围",
}
clean["district_cn"] = clean["district"].map(DISTRICT_CN)

summary = clean.groupby(["district_cn", "rooms"]).agg(
    count=("rent_hkd", "size"),
    rent_median=("rent_hkd", "median"),
    rent_mean=("rent_hkd", "mean"),
    sqft_median=("sqft", "median"),
    price_per_sqft_median=("rent_per_sqft", "median"),
).reset_index()

summary.to_csv("hk_rent_summary.csv", index=False, encoding="utf-8-sig")
print(summary.sort_values("price_per_sqft_median"))

六、用 Claude 做归因分析

import os
import pandas as pd
import anthropic
from pathlib import Path

client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
df = pd.read_csv("hk_rent_summary.csv")
table_md = df[["district_cn", "rooms", "count",
               "rent_median", "price_per_sqft_median"]].to_markdown(index=False)

prompt = f"""你是香港地产数据分析师。下面是 28Hse 抓到的 {len(df)} 行
区域-户型聚合数据(2026 年 6 月初抓取):

{table_md}

请输出:
1. 按 price_per_sqft_median 排序,找出 3 个"被低估"的区域(同户型中位数
   低于全港中位数 15% 以上),并猜测可能原因(交通/校网/楼龄/海景)
2. 找出 3 个"明显高估"的区域,给出同样归因
3. 给一个 3-5 行的总结:内地人去香港工作,预算 12,000-18,000/月,
   应该优先看哪 3 个区域?避雷哪 2 个?
4. 用 Markdown 输出

硬约束:
- 只能基于表里的数据推断
- 样本 <5 的区域标"样本不足"
- 不要编造具体地址或楼盘名
"""

msg = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=2048,
    messages=[{"role": "user", "content": prompt}],
)
Path("hk_rent_report.md").write_text(msg.content[0].text, encoding="utf-8")

七、可视化

import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_csv("hk_rent_summary.csv")
df = df[df["rooms"] == "2 房"].sort_values("price_per_sqft_median")
plt.figure(figsize=(10, 6))
plt.barh(df["district_cn"], df["price_per_sqft_median"], color="#00D4FF")
plt.xlabel("呎租 (HKD / sqft)")
plt.title("2026-06 香港各区 2 房呎租中位数(28Hse 实测)")
plt.tight_layout()
plt.savefig("hk_rent_chart.png", dpi=150)

在这里插入图片描述

八、踩坑记录

1. 28Hse 列表页面 CSS 类名不稳定。 我 6 月初跑时是 .item-info-wrap,5 月底还是 .item-detailprint(soup.prettify()[:2000]) 看当前结构,不要硬编码类名。

2. 限速是红线,不是建议。 time.sleep(1) 跑 200 条就被封 IP,改成 8-15 秒随机延迟才稳。

3. 月租字符串里有时混"HK$“、有时带"起”。 “HK$15,000起"被解析成 15000,但实际可能 18,000。清洗阶段过滤"起"字,或标注"含起价”。

4. Claude 的 max_tokens 别设太大。 设 8192 账单会爆,2048 足够 1500-2500 行分析。超出说明 prompt 没写清楚。

5. API key 不要硬编码。os.environ["ANTHROPIC_API_KEY"] 从环境变量读。


九、扩展阅读


本文代码经实测可复现,但 28Hse 页面结构可能改版,爬虫需自行维护。如需 LangChain 多轮 Agent 实战或港铁延误数据爬取,评论里告诉我,下一篇安排。

在这里插入图片描述

更多推荐