ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python爬取广东省租房数据全流程实战:清洗与存储是关键

Python爬取广东省租房数据全流程实战:清洗与存储是关键 先说结论这个项目看着简单真跑起来最花时间的不是爬虫本身而是数据清洗和存储设计。我花了两周时间把广东省21个地级市的租房数据抓了一轮落地了几万条结构化记录也踩了不少坑。这篇博文把整个项目从拆解到实操完整复盘一遍包含技术选型、代码实现、存储方案和异常处理希望能帮到你。如果你是刚接触Python爬虫的人或者想做个城市租房分析但不知道怎么下手又或者已经在爬数据但对“怎么存、怎么清理、怎么防断”没什么把握这篇文章都能给你一个完整的参考路径。1. 项目背景与核心需求拆解1.1 这个项目到底在做什么项目标题很直白Python抓取广东省各城市租房数据并存储。拆开看就是三个动作抓取、处理、存储。但落到实际操作里这三个动作之间还夹着一个经常被忽略的环节——数据清洗。租房数据从网页里抠出来的时候往往带着大量噪声价格单位不一样、面积字段混着“㎡”和“平”、户型写成“1室1厅1卫”或“整租1室”发布日期有的精确到秒有的只有“今天”两个字。这些数据不洗干净后面做分析就是灾难。我做这事的初衷是给自己做一份广东省各城市的租金参考。因为一线城市和新一线城市的数据好找但像清远、云浮这些地方公开的月度租金报告很难覆盖到区县级别。与其依赖零散的统计口径不如自己抓一批原始房源数据按城市、区县、户型维度做聚合。这个项目本质上是一个“低成本、可扩展”的城市租房数据分析前置步骤抓下来的数据是原料后面的统计和分析才是产品。1.2 数据源怎么选重点考虑什么抓租房数据第一步不是写代码而是选数据源。市面上的房源平台大概分三类中介类平台如链家/贝壳系房源信息结构化程度高字段完整有价格、面积、户型、朝向、楼层、发布时间非常适合爬取和分析。但这类平台反爬较强对请求频率敏感需要严格控制节奏。分类信息平台如58同城房源量大但字段混乱同一页面的信息格式可能都不一样清洗成本高。品牌公寓直租平台如自如、泊寓数据干净但覆盖城市有限多以一线和新一线为主做不了全省范围。我最终选择以某中介类平台作为主数据源辅助部分直租信息。选择的原因很简单结构化程度高能直接拿到“城市-区县-小区-价格-户型-面积”这条完整链路。爬虫最怕的不是网站难爬而是数据根本没法用来分析。信息再全如果字段乱到没法清洗那不如不爬。提示选数据源之前先看一下目标网站关于爬虫的约定文件robots.txt。如果允许抓取公开信息就合规地抓如果明确禁止建议换个数据源或直接使用平台提供的开放接口。1.3 广东省城市清单与数据量预估广东省共有21个地级市这里列一份我实战中用的城市清单一线城市广州、深圳新一线/强二线佛山、东莞、珠海、中山、惠州二线/三线江门、湛江、茂名、肇庆、汕头、揭阳、潮州、清远、韶关四线及以下河源、梅州、阳江、汕尾、云浮不同城市的房源量差异非常大。我实际跑下来广州、深圳的出租房源单日页面可达数百页而汕尾、云浮可能只有几十页。整体预估下来全省一次全量抓取大概能拿到几万到十几万条有效房源数据这个量级用单机爬虫完全够跑不需要上分布式。2. 技术方案选型从请求到存储的完整链路2.1 抓取层requests BeautifulSoup 为什么够用选型这件事很多人一上来就整Scrapy、Selenium、Playwright其实没必要。对于租房这类以服务端渲染为主的静态页面Python的requests加BeautifulSoup就是最轻量、最稳的组合。requests负责发起HTTP请求拿到HTML源码BeautifulSoup负责解析页面的DOM结构提取目标字段。整个流程用在单机脚本里非常顺手定位问题也容易——哪一步报错打印一下就知道。Scrapy虽然功能强大自带并发、中间件、管道但对这种中小规模项目来说上手成本和学习成本反而有点重。我实际用的依赖就这几个requests2.31.0 beautifulsoup44.12.0 lxml4.9.0 pandas2.0.0 SQLAlchemy2.0.0 pymysql1.1.0这里有个关键细节BeautifulSoup的解析器一定要指定lxml不要用默认的html.parser。后者在解析某些不规范HTML时速度慢容错性也差。换lxml之后单页解析时间能下降一半以上。2.2 动态页面怎么办Selenium 和 Playwright 的取舍虽说很多主流房源网站都是服务端渲染但总有部分页面是JavaScript动态加载内容用requests直接拿HTML是拿不到数据的。这种情况我一般分两步走先看页面里的数据是不是藏在嵌套的JSON接口里如果是直接请求那个接口拿到JSON后解析效率最高。如果接口加密或者不方便模拟再考虑用浏览器自动化工具。工具选择上Selenium成熟但笨重启动一个完整浏览器实例内存占用高爬得快反而容易被封。Playwright更现代支持异步运行速度也快而且能接管已打开的浏览器上下文。如果只是偶尔处理几个动态页面倒也没必要专门搞成浏览器集群写个辅助函数按需调用就好。不过我要提醒一句浏览器自动化是最后手段不是默认手段。能走接口走接口能走静态走静态把请求量降到最低才是细水长流的抓法。2.3 存储层选型CSV、SQLite、MySQL、MongoDB 怎么选这个项目名称里明确带了“存储”两个字那么存储层的设计绝对是重点。我实际对比过四种方案方案优点缺点适用场景CSV文件简单一行代码就能导出无索引、无去重、并发写入会坏一次性学习、数据量小SQLite单文件轻量支持SQL高并发写入性能一般单机小项目、原型验证MySQL稳定支持唯一索引、事务适合关系型数据需要安装配置数据库服务正式项目、需要长期增量抓取MongoDB灵活文档模型很适合原始JSON数据关系弱聚合计算不如SQL顺手接口返回的是嵌套JSON、结构经常变我的最终选择是MySQL。原因有三个第一个原因租房数据是典型的关系型数据一条记录里有城市、区县、小区、价格、面积、户型、朝向、发布时间等固定字段天然适合用表结构存储。第二个原因去重方便。MySQL能对url字段建唯一索引重复抓取时直接报错或忽略省去在代码里做一次去重的麻烦。第三个原因后续做分析方便。我需要按城市、区县、户型维度做聚合统计一句SQL就出来了如果用MongoDB还得先聚合管道处理一轮没必要。SQLite我用来做调试抓取代码在单个城市跑通时先用它验证确认数据没问题再切到MySQL跑全量。2.4 表结构设计与去重策略我把表命名为rent_data设计结构时重点考虑了三点字段覆盖度、字段规范、去重。CREATE TABLE rent_data ( id INT AUTO_INCREMENT PRIMARY KEY, city VARCHAR(50) NOT NULL COMMENT 城市, district VARCHAR(50) COMMENT 区县, community VARCHAR(100) COMMENT 小区名, price_month INT COMMENT 月租金单位元, area_sqm DECIMAL(8,2) COMMENT 面积单位平方米, layout VARCHAR(30) COMMENT 户型如3室2厅, orientation VARCHAR(20) COMMENT 朝向, floor_info VARCHAR(50) COMMENT 楼层信息, publish_date DATE COMMENT 发布日期, source_url VARCHAR(255) COMMENT 房源URL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 入库时间, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, UNIQUE KEY uk_source_url (source_url) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;去重策略的核心就是source_url的唯一索引同一套房源的链接不会重复入库。这个设计在实际运行中特别省心因为爬虫中断重启、同一页面重复抓取都是常态只要URL是唯一的数据就不会翻车。另外我加了一个updated_at字段用于追踪同一房源的租金变化情况。这个字段对后续做“租金环比变化”分析很有用比如同一套房上个月挂3000这个月挂2800说明房东在降价。3. 实操过程一个城市先跑通再做21城批量3.1 工程结构怎么搭爬虫代码写完之后越到后面越怕乱。我一开始在一个py文件里堆了所有函数跑到第三个城市时已经分不清哪个函数是清洗、哪个是存储。后来重构成了清晰的多模块结构给你参考rent_spider/ ├── config.py # 全局配置数据库连接、请求头、延迟区间 ├── spiders/ │ ├── __init__.py │ ├── base.py # 基础请求封装get_html、随机延迟、重试 │ ├── rent_spider.py # 核心解析逻辑列表页、详情页字段提取 │ └── city_list.py # 广东21市的基础信息映射 ├── storage/ │ ├── __init__.py │ ├── mysql_store.py # MySQL写入封装 │ ├── sqlite_store.py # SQLite写入封装调试用 │ └── pipeline.py # 数据统一入口先清洗再分发到不同存储 ├── utils/ │ ├── __init__.py │ ├── cleaner.py # 数据清洗单位转换、缺失值处理 │ ├── headers.py # User-Agent池和请求头生成器 │ └── logger.py # 日志封装 ├── run.py # 启动入口指定城市或全量运行 └── requirements.txt我强烈建议复杂一点的爬虫项目从一开始就按“请求、解析、清洗、存储”四层来组织代码。后面改任何一个环节都不影响其他环节。比如我后来换存储实现从SQLite切到MySQL只改pipeline.py里的分发逻辑解析和清洗代码完全没动。3.2 构造城市页面URL与分页规则不同平台的URL规则不一样但分页逻辑无非两种路径参数/zufang/pg2/和查询参数?page2。我这边遇到的是路径参数型构造起来很直接BASE_URL https://rent.example.com/{city}/pg{page}/ def build_list_url(city_pinyin: str, page: int) - str: return BASE_URL.format(citycity_pinyin, pagepage)这里的city_pinyin需要维护一份映射表。比如广州是guangzhou深圳是sz佛山可能是foshan不见得是标准拼音必须以平台实际URL为准。我在跑第一批城市之前先用浏览器手动访问了几个城市的不同页码把URL规则确认清楚才算正式开工。一个细节是分页不是无限循环的。我设置了一个停止条件如果当前页面解析出的房源列表为空或“下一页”按钮不存在/被禁用就终止该城市抓取。比根据总数预估页数要稳妥尤其是在平台房源量变动频繁的情况下。3.3 页面解析与数据提取解析部分我用BeautifulSoup提取房源卡片。这里的关键不是把代码写出来而是思路要清晰先找到所有房源卡片的公共容器再逐条提取子节点。我给个通用示例from bs4 import BeautifulSoup def parse_list_page(html: str): soup BeautifulSoup(html, lxml) items [] # 根据实际页面的class选择器调整 cards soup.select(div.rent-item) for card in cards: title card.select_one(p.title a) detail_str card.select_one(p.detail) price_str card.select_one(span.price) item { title: title.get_text(stripTrue) if title else , detail: detail_str.get_text(stripTrue) if detail_str else , price_raw: price_str.get_text(stripTrue) if price_str else , url: title.get(href) if title else , } items.append(item) return items解析时最容易出的问题就是某个房源卡片缺字段。比如有的房源是“车位出租”没有面积有的是“新盘首租”发布时间是“刚刚”。我一开始没做字段缺失保护直接卡在空对象上。后来每次取字段都先判断节点是否存在宁可在后面清洗时标记为None也不能让解析直接中断。页面解析的目标是拿到原始文本不要在这里做任何清洗。比如价格字段可能带着“元/月”“元/天”甚至“面议”面积可能写成“50㎡”这些都留到清洗层统一处理。解析层和清洗层职责分开能避免代码越写越乱。3.4 清洗处理单位统一、缺失值、异常值清洗是整个项目里最不起眼但最出活的部分。我在写cleaner.py时遇到了几类典型问题第一价格单位不统一。同一个平台上大多数房源标注“元/月”但也有直接标“3000”还有极少数“元/天”。我用正则提取数字再根据单位后缀换算成统一价格import re def normalize_price(price_raw: str): if not price_raw: return None match re.search(r(\d(?:\.\d)?), price_raw) if not match: return None value float(match.group(1)) if 天 in price_raw: value * 30 elif 季 in price_raw: value / 3 return int(value)第二面积字段带有各种单位有的写“50㎡”有的写“50平”。清洗后统一保留数字和㎡在表里以DECIMAL存储。第三户型字段互联网表达五花八门但核心都遵循“N室M厅K卫”的模式我提取出室的数量作为字段rooms方便后面按房间数聚合分析。清洗层有一个个人习惯所有清洗函数必须做成纯函数输入一个字符串返回一个格式化后的值不做数据库写入等副作用。这样测试容易后面想换成pandas批量清洗也方便。3.5 存储代码落地存储层我统一走pipeline.py核心逻辑是接收一条清洗后的dict记录先尝试插入如果报“唯一索引冲突”则忽略。具体实现from sqlalchemy import create_engine, text class MySQLStore: def __init__(self, conn_str): self.engine create_engine(conn_str) def save(self, record: dict): sql text( INSERT IGNORE INTO rent_data (city, district, community, price_month, area_sqm, layout, orientation, floor_info, publish_date, source_url) VALUES (:city, :district, :community, :price_month, :area_sqm, :layout, :orientation, :floor_info, :publish_date, :source_url) ) with self.engine.connect() as conn: conn.execute(sql, record) conn.commit()INSERT IGNORE配合唯一索引天然实现“已抓取的不重复入库”。这个方法在批量执行时性能也好我会在第四章详细讲批量写入的优化。存储层你不能把目标定为“能写进库里就行”得考虑后续增量抓取的需求。source_url唯一索引就是为增量抓取设计的今天抓全量明天只抓新发布的房源老数据不动新的自然入库。这样才能形成“长期可用”的数据资产而不是一次性快照。4. 批量抓取调度与断点续爬4.1 21个城市怎么调度城市多的时候最容易犯的错就是一个城市一次性抓到底结果中间挂了前面的城市场次都白费。我用的方式是城市循环页数控制状态记录for city in city_list: for page in range(1, max_page 1): try: html fetch_html(city, page) items parse_list_page(html) if not items: break for item in items: cleaned clean_item(item, city) store.save(cleaned) except Exception as e: log_error(city, page, e) break # 每个城市结束更新状态记录 update_state(city, finishedTrue)这个逻辑看上去简单但有两个关键点城市之间的顺序建议按房源量从多到少排。广州深圳这种大平台房源多先跑云浮汕尾这种小城市房源少后跑。这样即使在抓取中途因为网络或反爬被中断损失的多半也是数据量小的城市。第二个关键是最后那个update_state。我把每个城市的抓取状态维护在单独的crawl_state表里字段包括城市、已抓页数、是否完成。中断后重启直接从断点继续不用从头再来。4.2 请求频率控制与随机延迟反爬这件事本质上是“你不给对方服务器添堵对方就懒得理你”。我实测下来的经验是单城市顺序抓取每页请求之间加1到3秒的随机延迟基本不容易触发风控。如果平台对频率更敏感就把区间调到2到5秒。import time import random def random_delay(): time.sleep(random.uniform(1.5, 3.5))随机延迟的使用有两个技巧一是不要用固定间隔固定间隔反而容易被识别二是不要在代码里写死“我延迟了1秒”要延迟随机化像人类浏览行为。另外我维护了一个User-Agent池每次请求随机切换伪造不同浏览器的请求头。提示如果网站明确在robots.txt里禁止自动抓取或者你发现请求返回了验证码挑战页面请立刻降频或停止不要试图绕过。个人学习项目不值得因为爬数据惹上麻烦。4.3 断点续爬抓取中断不丢数据我遇到过几次问题家里断网、电脑休眠、平台短暂封IP。每次中断都导致已抓完的页面需要重跑。后来我做了两个层面的持久化第一层是MySQL的唯一索引去重。即使重复抓取同一套房源也不会重复入库业务上不受影响。第二层是crawl_state状态表记录每个城市抓到了第几页。CREATE TABLE crawl_state ( city VARCHAR(50) PRIMARY KEY, last_page INT DEFAULT 0, finished TINYINT DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );启动时读取这张表如果某个城市finished0就从last_page1继续。这个设计在整个抓取周期中救了我不下五次强烈建议你也加上。5. 常见问题与排查技巧实录5.1 403 Forbidden怎么破跑爬虫的人几乎没有没见过403的。这个状态码意味着服务器拒绝处理请求。我遇到403时排查顺序是检查User-Agent目标平台如果识别出是脚本请求很可能直接拒绝。我维护了一份真实浏览器的UA池请求时随机使用。检查Referer有些平台要求请求头携带上一页地址不带就拒绝。在请求头里加上Referer指向列表页首页即可。降低请求频率如果请求快随手把延迟从2秒提到5秒很多时候就好了。检查IP是否被临时限制被限制的话重新拨号更换IP但更稳妥的是停一会儿再说。5.2 页面结构改版导致解析失败平台页面改版是爬虫工作者的宿命。我遇到过一次房源卡片的class名全都换了原来的选择器全部失效解析结果直接变成空列表。排查方法是先不急着改代码把HTML保存到本地用BeautifulSoup交互式地找新结构确认好新的选择器再更新代码。# 保存HTML样本便于离线排查 python -c import requests; open(sample.html,w).write(requests.get(目标URL, headersheaders).text)解析器改版后一定要对旧数据进行兼容。我在代码里加了一层字段映射新老选择器都能适配避免一次大改导致整个爬虫瘫痪。5.3 中文乱码和编码问题租房数据里中文很多乱码问题高频出现。原因大多是页面编码判断有误。我建议直接以requests返回的encoding为准再用apparent_encoding做兜底resp requests.get(url, headersheaders) if resp.encoding is None or resp.encoding.lower() not in (utf-8, utf8): resp.encoding resp.apparent_encoding html resp.text入库连接字符串里必须加上charsetutf8mb4否则生僻字、特殊符号可能写入失败。我之前吃过这个亏接入库报编码错误愣是排查了半天才发现是连接参数少加了charset。5.4 数据量大时的写入性能问题单条插入在数据量上来之后明显吃力。几十万条数据逐条commit会非常慢。我用executemany批量插入每500条提交一次事务性能提升了至少一个数量级def save_batch(self, records: list): sql text( INSERT IGNORE INTO rent_data (city, district, community, price_month, area_sqm, layout, orientation, floor_info, publish_date, source_url) VALUES (:city, :district, :community, :price_month, :area_sqm, :layout, :orientation, :floor_info, :publish_date, :source_url) ) with self.engine.connect() as conn: for offset in range(0, len(records), 500): sub records[offset:offset 500] conn.execute(sql, sub) conn.commit()批量插入之前数据清洗一定要保证字段完整度特别是不为空的字段不能缺。否则报错会批量失败定位也不方便。6. 合规与安全边界6.1 Robots协议不是摆设我知道很多爬虫教程对robots.txt的态度是“看看就行不用管”这是不对的。做个人学习项目第一批正规的做法就是把目标网站的robots.txt读一遍明确哪些路径允许抓取哪些禁止然后老老实实地遵守。拒绝抓取的路径坚决不碰。6.2 频率、频率、频率反爬的本质不是技术对抗是资源博弈。你抓取频率越低双方越和谐。我给自己定了三条铁律单页间隔不少于1秒单城市请求总量控制遇到验证码立刻停止。把“技术对抗”换成“有礼貌地访问”项目能跑得久很多。6.3 数据使用边界抓下来的租房数据用于个人学习、城市数据分析、租房决策参考都是合理的场景。但如果要商用、对外公开就必须考虑数据来源的授权问题。不要抱着“先抓下来再说”的心态数据使用边界应该在做项目的第一天就想清楚。最后分享一点个人体会。这个项目跑完一轮后我最大的感受是爬虫的价值不在“抓”在“清洗”和“存储”。一个页面从URL到数据库记录中间每一个环节都可能出错而正是这些报错和修复过程让你真正理解数据是怎么从网页变成可分析资产的。这对我来说是这个项目最有收获的部分。
返回列表