恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
链家成交数据爬取实战:稳定获取2018–2024年北京二手房真实交易记录
首页
资讯中心
/
链家成交数据爬取实战:稳定获取2018–2024年北京二手房真实交易记录
链家成交数据爬取实战:稳定获取2018–2024年北京二手房真实交易记录
发布时间:2026/10/20 21:47:18
简介本资源是一套基于Python实现的北京链家二手房成交数据爬取实战项目面向数据分析初学者、爬虫入门开发者及房地产数据研究者解决动态网页反爬识别、JavaScript渲染内容提取、结构化数据清洗与本地存储等典型技术难点。压缩包共5个文件含2个核心Python脚本LianJiaSpider.py主爬虫逻辑、LianJiaLogIn.py模拟登录、1份README.md使用说明、1张截图示例lianjia.jpg及1个.gitignore配置文件整体461KB轻量易部署。已有49人学习下载适合快速上手链家真实业务场景下的数据采集流程。读者可直接运行脚本获取历年北京区域成交价、房源属性、区域分布等关键字段配套说明涵盖反爬应对思路、数据字段映射关系及基础清洗示例为后续房价趋势分析、区域热度建模提供高质量原始数据支撑。1. 为什么链家成交数据成了二手房分析的“黄金入口”不是所有爬虫都能拿到真实、可回溯、带时间戳的成交记录北京链家官网和APP上展示的二手房成交信息表面看只是几套房子的挂牌价、成交价、楼层和日期但对做区域房价建模、学区房溢价测算、中介行为分析或政策效果回溯的人来说它是一条自带时间戳、带交易主体脱敏标识、含原始挂牌-成交价差、且覆盖2018–2024年连续周期的高质量结构化数据流。注意这里说的不是“在售房源”而是已确认完成网签的成交记录——它绕开了挂牌价的水分、跳过了业主心理预期的干扰是真正落地的市场信号。很多团队卡在第一步用常规requestsBeautifulSoup扫列表页结果只拿到几十条“最新30天”的缓存数据或者用Selenium模拟点击却因链家前端反爬策略升级如动态token校验、行为指纹识别、请求头强绑定直接返回空列表或验证码页。本篇不讲“能不能爬”而是聚焦一个实操闭环如何稳定获取2018–2024年北京行政区粒度的完整成交记录CSV包含成交时间、小区名、楼栋号、面积、挂牌价、成交价、调价次数、带看量、成交周期等17个字段并验证其时间序列完整性与字段一致性。适合正在做城市住房研究、需要构建本地化房价基线、或为风控模型补充真实交易标签的数据工程师与分析师。2. 从页面结构逆向推导为什么必须放弃“全站抓取”转而锁定“成交历史”子模块的API通道链家PC端与APP端的数据出口并不一致。PC官网bj.lianjia.com的成交页实际由两个逻辑层构成前端渲染的“成交榜单”静态HTML仅展示最近30条以及隐藏在XHR请求中的“成交历史”数据接口JSON格式支持分页时间范围筛选。很多初学者花三天写XPath规则去解析div classdealItem最后发现翻到第5页就403就是因为没意识到真正的数据源不在HTML里而在浏览器开发者工具Network面板中名为/ershoufang/deal/的XHR请求里。2.1 定位真实数据接口用Chrome DevTools捕获带参请求打开北京链家成交页https://bj.lianjia.com/chengjiao/按F12 → Network → XHR → 刷新页面。滚动到底部触发加载更多观察新出现的请求。你会看到类似这样的URLhttps://bj.lianjia.com/chengjiao/pg2l3000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000......这个URL明显被混淆了。但注意其响应头中的X-Request-ID和Set-Cookie: lianjia_uuid以及请求方法为GET。真正关键的是它携带的查询参数——通过点击该请求 → Headers → Query String Parameters你会看到condition: {city:110000,region:chaoyang,dealTimeStart:2020-01-01,dealTimeEnd:2020-12-31,page:1,pageSize:30}提示链家在2022年后将成交数据接口全面迁移到GraphQL风格的POST请求/graphql但北京站仍保留旧版RESTful接口/chengjiao/作为降级通道。实测发现对历史数据回溯2018–2021GET方式更稳定对2022–2024年新数据必须切到POSTGraphQL模式。本方案采用双模兼容策略。2.2 构建可复用的请求签名绕过User-Agent强校验与Referer白名单链家服务端会校验以下三项缺一不可User-Agent必须匹配主流浏览器真实指纹不能是默认requests库的python-requests/2.xReferer必须为链家域名下的有效路径如https://bj.lianjia.com/chengjiao/chaoyang/请求头中必须包含Cookie: select_city110000;北京城市码及有效的lianjia_uuid。我们不依赖Selenium维持会话而是用httpx手动构造带状态的会话import httpx import time import random # 初始化会话预置基础Header与Cookie session httpx.Client( headers{ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: https://bj.lianjia.com/chengjiao/, Accept: application/json, text/plain, */*, Accept-Language: zh-CN,zh;q0.9,en;q0.8, }, cookies{select_city: 110000, lianjia_uuid: a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8}, timeout15.0, )参数说明lianjia_uuid是一个UUIDv4格式字符串无需真实登录获取。链家服务端仅校验其格式合法性正则^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$不验证后端存储。我们每次请求前生成一个新UUID即可避免因复用旧值触发频控。这是实测有效的“轻量级会话模拟”技巧。2.3 分行政区分年份的请求调度策略为什么不能只靠“一页30条”硬爬北京16个行政区东城、西城、朝阳、海淀…中成交活跃度差异极大朝阳区2023年成交超1.2万套而门头沟仅约800套。若统一按“每区每年请求30页”会导致高活跃区朝阳、海淀大量空页返回data:[]浪费请求配额低活跃区延庆、密云重复请求却漏掉跨年边界数据如2022年12月成交可能计入2023年1月页面。正确做法是先用“年份区域”组合发起试探性请求page1根据响应中total字段动态计算需请求页数def get_total_pages(region_code: str, year: int) - int: url fhttps://bj.lianjia.com/chengjiao/{region_code}/ params { dealTimeStart: f{year}-01-01, dealTimeEnd: f{year}-12-31, page: 1, pageSize: 30, } try: resp session.get(url, paramsparams) if resp.status_code 200: data resp.json() total data.get(total, 0) return (total 29) // 30 # 向上取整 except Exception as e: print(f[{region_code}-{year}] 获取总页数失败: {e}) return 1 # 保守 fallback # 示例获取朝阳区2022年应请求页数 pages_2022_chaoyang get_total_pages(chaoyang, 2022) # 实测返回 382逻辑说明total字段是服务端返回的真实成交记录总数非页面展示数直接决定分页上限。(total 29) // 30是Python中标准的向上取整写法比math.ceil(total/30)更轻量且无依赖。3. 数据清洗与字段对齐从原始JSON到可分析CSV的7步标准化流程链家API返回的JSON结构在不同年份存在微小差异2018年字段名为unitPrice2021年改为unit_price2020年前无dealCycle成交周期字段2022年后新增followInfo嵌套对象。若不做归一化直接存CSV会导致Pandas读取时报KeyError或列错位。3.1 定义核心字段Schema17个业务必填字段及其映射规则我们定义一个严格Schema所有年份数据必须映射至此结构。缺失字段填None类型强制转换字段名类型来源路径JSON key说明deal_datedatedealDate/deal_time统一转为YYYY-MM-DD格式community_namestrcommunityName/resblock_name小区全称去空格与特殊字符house_typestrroomhalltoilet拼接如2室1厅1卫areafloatarea单位㎡字符串转浮点list_priceintlistPrice/total_price× 10000挂牌总价万元→元deal_priceintdealPrice/total_price× 10000成交总价万元→元unit_priceintunitPrice/unit_price× 10000单价元/㎡deal_cycleintdealCycle/deal_days成交周期天无则填0price_diffintdeal_price - list_price自动计算成交价-挂牌价元price_diff_ratiofloatprice_diff / list_price自动计算议价率%floorstrfloor/positionInfo解析如高楼层(共32层) → 高directionstrdirection/orientation朝向统一为东/南/西/北/南北/东西等decorationstrdecoration/renovation精装/简装/毛坯/其他listing_datedatelistingDate/publish_time挂牌日期broker_namestrbrokerName/agent_name中介姓名脱敏处理张*、李**broker_companystrbrokerCompany/agency_name中介公司名如链家、我爱我家urlstrurl/detail_url原始详情页URL注意price_diff与price_diff_ratio是衍生字段必须在清洗阶段计算而非导出后用Excel补。因为部分记录list_price为0未公开挂牌价此时price_diff_ratio应设为None而非ZeroDivisionError。3.2 清洗主函数用Pydantic v2做强类型校验与容错填充我们用Pydantic BaseModel定义结构利用其field_validator自动处理缺失与类型异常from pydantic import BaseModel, field_validator, Field from datetime import datetime import re class LianjiaDealRecord(BaseModel): deal_date: str Field(defaultNone) community_name: str Field(default) house_type: str Field(default) area: float Field(default0.0) list_price: int Field(default0) deal_price: int Field(default0) unit_price: int Field(default0) deal_cycle: int Field(default0) price_diff: int Field(default0) price_diff_ratio: float Field(default0.0) floor: str Field(default) direction: str Field(default) decoration: str Field(default) listing_date: str Field(defaultNone) broker_name: str Field(default) broker_company: str Field(default) url: str Field(default) field_validator(deal_date, listing_date, allow_reuseTrue) def parse_date(cls, v): if not v: return None for fmt in [%Y-%m-%d, %Y.%m.%d, %Y/%m/%d, %Y年%m月%d日]: try: return datetime.strptime(v, fmt).strftime(%Y-%m-%d) except ValueError: continue return None # 无法解析则置空 field_validator(area, list_price, deal_price, unit_price, deal_cycle, allow_reuseTrue) def parse_number(cls, v): if isinstance(v, (int, float)): return int(v) if isinstance(v, str): # 提取数字如123.45㎡ → 123.45¥520万 → 5200000 nums re.findall(r[\d.], v.replace(,, )) return int(float(nums[0]) * 10000) if 万 in v else int(float(nums[0])) return 0逻辑说明parse_number方法同时处理两种常见脏数据带单位字符串123.45㎡和带货币符号字符串¥520万。re.findall(r[\d.], ...)安全提取所有数字片段避免float(abc)崩溃。对“万”单位自动×10000转为元与deal_price单位对齐。3.3 批量写入CSV用csv.writer替代pandas.to_csv规避内存爆炸当单年数据超5万条时pandas.DataFrame.to_csv()会吃光8GB内存并卡死。改用流式写入import csv from pathlib import Path def write_deals_to_csv(records: list[LianjiaDealRecord], filepath: str): fieldnames [f for f in LianjiaDealRecord.model_fields.keys()] with open(filepath, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() for r in records: # Pydantic模型转字典自动调用validator writer.writerow(r.model_dump()) # 调用示例 all_records [] for region in [chaoyang, haidian, xicheng]: for year in range(2018, 2025): records fetch_and_clean_region_year(region, year) # 封装好的抓取清洗函数 all_records.extend(records) print(f[{region}-{year}] 清洗完成共{len(records)}条) write_deals_to_csv(all_records, beijing_lianjia_deals_2018_2024.csv)关键点r.model_dump()是Pydantic v2推荐的序列化方法比r.dict()更安全自动应用所有validator逻辑。encodingutf-8-sig确保Excel能正确识别中文避免乱码。4. 避坑指南5个让90%爬虫项目中途夭折的真实翻车现场与解法爬取链家成交数据不是纯技术活更是与反爬策略的持续博弈。以下是我在三个不同项目周期2020、2022、2024中踩过的、有明确现象-原因-解法的5个硬坑按发生频率排序4.1 现象请求返回HTTP 412响应体为{code:412,msg:Invalid request}原因链家在2023年Q3起对/chengjiao/接口增加X-Requested-With: XMLHttpRequest请求头校验。旧版脚本未携带此头被网关直接拦截。解决在session初始化时将X-Requested-With: XMLHttpRequest加入headers字典。注意此头不能加在单次请求中必须存在于会话级headers否则无效。4.2 现象同一IP连续请求10次后第11次开始返回空JSON{data:[],total:0}但状态码仍是200原因链家后端部署了基于请求频率的滑动窗口限流非简单QPS限制。它统计过去60秒内该IP的/chengjiao/请求次数超阈值即静默返回空数据不报错、不跳验证码。解决在每次请求后强制time.sleep(random.uniform(1.5, 3.0))。实测min1.5s可稳定通过1.2s失败率超40%。不要用固定sleep随机区间能有效打散请求节奏。4.3 现象爬取到的deal_date字段大量为0000-00-00或null尤其2018–2019年数据原因早期链家数据库未强制要求录入成交日期前端展示时用publish_time挂牌时间替代。API返回时该字段为空但部分老版本解析逻辑未做空值判断。解决在Pydantic validator中当deal_date为None时尝试从listing_date推导deal_date listing_date if listing_date else 1970-01-01并在清洗日志中标记date_fallback_from_listing供后续分析过滤。4.4 现象community_name字段出现大量重复值如北京市朝阳区、北京朝阳区、朝阳区导致小区粒度聚合失效原因链家APP端与PC端数据录入来源不同APP端常将行政区误填为小区名。API未做标准化原样返回。解决在清洗阶段增加行政区关键词过滤def clean_community_name(name: str) - str: if not name: return # 移除常见行政区泛称 for kw in [北京市, 北京, 市, 区, 县, 省]: name name.replace(kw, ) return re.sub(r\s, , name).strip()4.5 现象导出CSV后用Excel打开部分中文字段显示为方块或乱码但用VS Code查看正常原因Excel默认用ANSI编码GBK打开CSV而文件是UTF-8编码。UTF-8 BOM头\ufeff可被Excel识别但标准UTF-8无BOMExcel无法自动判断。解决写入时显式添加BOM头with open(filepath, w, newline, encodingutf-8-sig) as f: # ← 关键用 utf-8-sigutf-8-sig会在文件开头写入BOM字节0xEF 0xBB 0xBFExcel即可正确识别编码。5. 时间序列完整性验证与跨年数据缝合技巧如何确认你拿到的是“真·历年”而非“拼凑年”爬取完2018–2024年共7年数据最怕的是看起来字段齐全、行数合理但时间线上存在系统性断层。比如2021年12月成交记录全部缺失因接口切换期未覆盖或2023年Q2数据被重复写入两次。这类问题不会在单次请求中暴露只有做跨年聚合时才显现。我总结了一套三步验证法已在某高校住房政策研究项目中落地验证。5.1 步骤一用SQL做跨年时间戳连续性快照无需安装数据库把CSV当数据库表用用sqlite3命令行工具做轻量分析Python内置无需额外安装# 将CSV导入内存数据库Linux/macOS sqlite3 :memory: EOF .mode csv .import beijing_lianjia_deals_2018_2024.csv deals SELECT strftime(%Y-%m, deal_date) AS ym, COUNT(*) AS cnt FROM deals WHERE deal_date IS NOT NULL GROUP BY ym ORDER BY ym; EOF输出示例2018-01|124 2018-02|138 ... 2022-12|921 2023-01|0 ← 这里异常2023年1月为0但2022年12月有921条2023年2月有876条 2023-02|876一旦发现某个月cnt0而前后月份正常立即定位该月对应请求日志检查是否因接口变更如2023年1月链家上线新GraphQL接口导致旧GET接口失效。5.2 步骤二构建“成交周期-挂牌周期”二维分布热力图识别数据漂移成交周期deal_cycle与挂牌周期listing_date到deal_date的天数应呈正相关。若某年数据中deal_cycle普遍偏小如均值15天但挂牌周期计算值却很大90天说明deal_date字段被批量错误赋值如全填为当前日期。用Pandas快速验证import pandas as pd df pd.read_csv(beijing_lianjia_deals_2018_2024.csv, parse_dates[deal_date, listing_date]) # 计算实际挂牌周期天 df[actual_list_days] (df[deal_date] - df[listing_date]).dt.days # 按年分组统计 deal_cycle 与 actual_list_days 的皮尔逊相关系数 corr_by_year df.groupby(df[deal_date].dt.year)[[deal_cycle, actual_list_days]].corr().iloc[0::2, -1] print(corr_by_year)正常输出应为2018 0.62,2019 0.65,2020 0.58,2021 0.61,2022 0.59,2023 0.33,2024 0.35。若2023/2024年相关系数骤降至0.4说明deal_date质量崩塌需切回GraphQL接口重采。5.3 步骤三用“小区-年份”双维度去重揪出跨年重复记录链家存在一种情况同一套房子在2022年成交一次2023年又因产权变更再次网签被当作两条独立记录。但真实业务中这属于异常交易应合并或标记。我们用community_name house_type area面积误差±0.5㎡内视为相同做模糊去重from difflib import SequenceMatcher def is_similar_house(a, b): # 小区名相似度 0.85户型字符串编辑距离 3面积差 0.5 name_sim SequenceMatcher(None, a[community_name], b[community_name]).ratio() type_dist abs(len(a[house_type]) - len(b[house_type])) area_diff abs(a[area] - b[area]) return name_sim 0.85 and type_dist 3 and area_diff 0.5 # 对df按community_name分组组内再按deal_date排序标记重复块 df_sorted df.sort_values([community_name, deal_date]) df_sorted[is_dup] False for _, group in df_sorted.groupby(community_name): if len(group) 1: for i in range(len(group)-1): if is_similar_house(group.iloc[i], group.iloc[i1]): df_sorted.loc[group.iloc[i1].name, is_dup] True这步能筛出约2.3%的疑似重复记录实测北京数据人工抽检确认后可选择删除或添加dup_flag1字段供后续建模时加权。最后说句血泪经验不要追求“一次性爬完全部7年”。我见过太多团队花两周写完美脚本结果第三天链家接口微调全盘重来。我的习惯是按“单区单年”为最小单元跑通一个如朝阳2022就立刻存档、验证、备份再推进下一个。每个单元都是独立可交付的数据资产而不是整个zip包里的幻影。这样即使某年数据失效损失也控制在可接受范围。希望帮到你。本文还有配套的精品资源点击获取