简介:基于landchina的爬虫项目,聚焦土地市场结果公告及市场交易数据的自动采集,适合爬虫入门与Java综合项目实践,尤其适用于毕业设计、课程设计场景。资源共178个文件,压缩包约2.13MB,包含45个Python脚本、36个编译后的pyc文件、33个XML配置、11个XLS表格及8个CSV数据文件等,脚本负责抓取解析,表格用于存储结果,配置与文档说明完整。项目代码经过测试运行成功,并附有详细文档,方便对照理解抓取流程、字段结构及异常处理思路;已有72人学习下载,适合作为Java方向爬虫项目的参考模板,也可在现有基础上二次开发。对于希望快速搭建土地数据采集演示或储备项目经验的学习者,项目能节省大量前期调研与排错时间。
1. landchina 爬虫要抓的「结果公告」与「市场交易」是两个什么入口
做城市土地出让分析时,最耗时的不是建模,而是把 landchina 上的公告一条条攒下来。手动翻列表页不仅慢,结果公告和市场交易两个入口的查询条件还不一样,截图存 Excel 的方式撑不过一千条就乱套。“选项资料齐全”指的是把两个入口的下拉条件、隐藏表单字段、分页参数一次性配齐,让爬虫在任意行政区、任意时间范围、任意交易方式下都能稳定翻页;“文档详细”则要求交付物不止有代码,还要有参数清单、数据字典和报错对照,三个月后接手的人依然改得动。
这套爬虫适合三类人:地产投资岗想批量拿地数据做比价,数据分析师需要把公告解析成结构化字段,以及接单写 python 爬虫的工程师——后者尤其需要“选项资料齐全+文档详细”这种能直接交给甲方的形态。下面按我平时做这类 requests 爬虫的顺序,把表单参数、请求差异、详情解析、容错与交付逐层讲清楚,每一步都给出可以直接抄的代码和参数说明。
2. 爬 landchina 前先补齐表单参数与选项下拉的依赖
2.1 为什么选项资料是 landchina 爬虫的第一道坎
landchina 是 ASP.NET 系站点,查询页是一个服务端表单,翻页和筛选本质都是 POST 回传。这类页面的特点是:除了真正关心的查询条件(行政区、用途、公告类型、时间范围),表单里还藏着一批动态字段,常见的是__VIEWSTATE、__VIEWSTATEGENERATOR、__EVENTVALIDATION,由服务端在下一次 POST 前生成并校验。
如果只带日期和关键字就提交,最常见的现象是返回的 HTML 仍是默认查询结果,或者直接提示“查询条件错误”。不少新手在这里误判成被限流,其实是没有把隐藏字段原样回传。所谓“选项资料齐全”,第一步就是把查询页里的 hidden input 全部抓下来,连同选中的下拉选项一起 POST 回去,缺一个都可能翻不了页。
2.1.1 用 requests 抓取查询页里的全部 hidden 字段
常见做法是先 GET 查询页,解析所有<input type="hidden">,提交时原样带上:
import requests from lxml import etree def get_hidden_fields(session: requests.Session, page_url: str) -> dict: """GET 查询页,提取表单中的 hidden 字段,返回参数字典""" resp = session.get(page_url, timeout=15) resp.raise_for_status() html = etree.HTML(resp.text) fields = {} # 抓页面里所有隐藏字段,包括 __VIEWSTATE、__EVENTVALIDATION 等 for inp in html.xpath('//input[@type="hidden"]'): name = inp.get('name') value = inp.get('value', '') if name: fields[name] = value return fields这段代码的逻辑是先用同一个session访问查询页拿到最新的隐藏字段,再在提交时把fields合并进 POST 数据。注意不要从浏览器里复制value写死到代码里,__VIEWSTATE每次会话都可能变化,写死的脚本第二天就会失效。
2.2 把下拉选项做成参数表而不是堆在 if 里
“选项资料齐全”的第二层含义是下拉选项本身。结果公告页的公告类型、行政区、用途,市场交易页的交易方式、资源类型,每个下拉框的name和可选值都可以人工切一遍,从浏览器 DevTools 的 Network 面板里记录真实的 POST 体。我更推荐在项目里维护一张参数表,把每个入口的选项名和取值固化下来:
| 参数角色 | 入口 | 取值特征 | 失败时的表现 |
|---|---|---|---|
__VIEWSTATE等隐藏字段 | 两个入口共用 | 每次 GET 动态生成 | 漏带时返回默认列表或参数错误 |
入口标识TAB | 两个入口共用 | 固定字符串 | 缺了会串到另一个栏目 |
查询条件condition | 两个入口共用 | JSON 串或拼接串 | 格式不正确时列表为空 |
| 用途/行政区编码 | 两个入口共用 | 下拉选项值 | 编码错误时无数据或报错 |
| 交易方式 | 市场交易 | 下拉选项值 | 缺省时包含全部方式,结果膨胀 |
| 页码参数 | 两个入口共用 | 自增数字 | 从 1 开始,超出范围返回空页 |
上面这组参数名以页面当前实际字段为准,不必逐字对应,但结构是稳定的:隐藏字段做底座,固定值区分入口,下拉选项决定过滤范围,页码控制翻页。把它们提取成配置后,新增“按工业用地抓市场交易”的需求只需加一组参数值,解析和入库逻辑完全不用动。
2.3 用 Session 保持会话,避免换页就丢选项
手动浏览时,登录态和会话由 Cookie 维持;爬虫里对应的是requests.Session()。所有 GET 和 POST 都用同一个 Session 对象,服务端才会认为你是在连续操作同一个浏览器会话。
session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://www.landchina.com/", }) # 先拿隐藏字段,再带字段去翻页 hidden = get_hidden_fields(session, QUERY_PAGE_URL) data = {**hidden, "TAB": "result_announcement", "pageNo": "1"} resp = session.post(QUERY_PAGE_URL, data=data, timeout=15)这里User-Agent和Referer是常规请求头,属于模拟正常浏览器访问公开数据的标准做法。真正容易踩的坑是每次翻页都重新 GET 一遍查询页——这虽然能拿到新的隐藏字段,却也清空了当前查询条件,导致第二页起结果和第一页对不上。正确做法是:首次请求拿 hidden 字段,后续翻页只更新pageNo,其他参数保持原样,直到列表抓完再重新触发新一轮查询。
3. 切换 landchina 结果公告与市场交易:请求差异与详情解析
3.1 先分清两个入口在业务语义上的差别
结果公告对应已成交的出让结果,核心信息是竞得人、成交价格、成交时间;市场交易对应正在公告期或交易过程中的地块,核心信息是起始价、保证金、报名截止时间。两者虽然都叫“地块列表”,但详情页字段差别很大,解析规则必须分开写。
如果共用同一套解析,很容易出现结果公告抓下来的成交价格正常,切到市场交易后该字段变成空值或默认值。业务语义决定解析分支,这是“选项资料齐全”之外容易被忽略的一环。
3.2 两个入口的 POST 差异对照
在统一封装请求之前,先把差异做成对照表,实现时按入口选择参数即可:
| 维度 | 结果公告 | 市场交易 |
|---|---|---|
| 列表语义 | 已成交的出让结果 | 公告中/交易中的地块 |
| 关键过滤条件 | 公告类型、成交方式 | 交易方式、资源类型、公告期 |
| 详情页必采字段 | 竞得人、成交价格、成交日期 | 起始价、保证金、报名截止时间 |
| 时间字段含义 | 成交时间 | 公告/挂牌开始时间 |
| 翻页参数结构 | 共用同一套分页参数 | 与左侧结构一致 |
抓取时我一般把两个入口配置成两个数据源对象,每个对象维护自己的 POST 参数模板和解析函数,但共用同一个请求函数。这样代码不重复,业务差异又各自隔离。
3.3 统一封装列表页请求函数
def post_list_page(session, base_params: dict, page_no: int, entry: str): """按入口和页码拼接 POST 数据并请求列表页""" if entry == "result": # 结果公告 params = {**base_params, "TAB": "result", "pageNo": str(page_no)} elif entry == "market": # 市场交易 params = {**base_params, "TAB": "market", "pageNo": str(page_no)} else: raise ValueError(f"unknown entry: {entry}") resp = session.post(QUERY_PAGE_URL, data=params, timeout=20) resp.raise_for_status() return etree.HTML(resp.text)这里的base_params是最初从查询页抓到的 hidden 字段加上人工确认的下拉选项,entry决定TAB参数。注意pageNo每次都要重新构造字符串,部分 ASP.NET 页面把页码当字符串比较,传 int 可能触发类型校验失败。
3.4 详情页解析与入库结构
列表页解析出的是详情链接,二次请求详情页后按下述分支提取。以结果公告为例,常见做法是取出地块编号、位置、面积、用途、出让年限、竞得人、成交价格、公告日期,再把列表页的行政区合并进记录:
def parse_detail(html): """从详情页提取字段,字段名以页面实际布局为准""" item = {} # 每一行形如“宗地编号:xxxx”或“位置:xxx” for row in html.xpath('//div[contains(@class,"detail")]//tr'): tds = row.xpath('./td/text()') if len(tds) == 2: key = tds[0].strip().replace(":", "") value = tds[1].strip() if key and value: item[key] = value return item入库我倾向用 SQLite,零部署且便于交付。表结构至少要包含业务唯一键和抓取状态字段:
CREATE TABLE IF NOT EXISTS land_detail ( id INTEGER PRIMARY KEY AUTOINCREMENT, entry TEXT NOT NULL, -- result / market biz_no TEXT NOT NULL, -- 宗地编号或公告编号 title TEXT, region TEXT, -- 行政区 price TEXT, -- 成交价/起始价按入口解释 detail_json TEXT, -- 完整详情字段冗余存储 crawled_at TEXT DEFAULT (datetime('now', 'localtime')), UNIQUE(entry, biz_no) -- 去重键 );detail_json的作用是防止解析规则升级后历史数据丢失,原始详情先冗余一份,后续要改字段再重解析 JSON,而不是重新抓全站。这个设计在接单交付场景里尤其重要:甲方后续要新增字段,不需要重新跑一遍完整爬虫。
4. landchina 爬虫的容错、限速与断点续爬
4.1 先分清是参数错误还是被限流
爬虫工程师最容易犯的错是看到状态码异常就怀疑被封,实际上 landchina 这类站点大多数异常来自参数或页面结构变化。状态码 400 通常意味着 POST 参数缺失,可能是隐藏字段过期;503 可能是网关瞬时错误或请求过密;200 但列表为空则大概率是查询条件没生效。
处理顺序应该是:先验证单条请求是否正常,再谈加代理和延时。验证方法很直接,把当前 POST 参数原样复制到浏览器的 Network 面板里重放一次,如果浏览器也返回空,说明参数模板本身需要重新抓取。
4.2 请求封装:随机 UA、指数退避、低频限速
import random import time def request_with_retry(session, url, data=None, max_retry=3): """带指数退避的 POST 封装,区分可重试与不可重试的状态码""" for attempt in range(max_retry): try: resp = session.post(url, data=data, timeout=20) if resp.status_code in (429, 503): # 服务端繁忙,可重试 time.sleep(2 ** attempt + random.random()) continue resp.raise_for_status() # 其他错误直接抛出 return resp except requests.ConnectionError: time.sleep(2 ** attempt + random.random()) raise RuntimeError(f"retry failed: {url}")状态码和处置策略建议固定成一张表,写入项目文档:
| 状态码 | 含义 | 处置 |
|---|---|---|
| 200 | 正常 | 解析 |
| 400 | 参数错误 | 不重试,重新抓 hidden 字段 |
| 404 | 详情页已下架 | 记录日志,跳过 |
| 429 | 请求过密 | 指数退避 2s/4s/8s |
| 503 | 网关繁忙 | 指数退避 2s/4s/8s |
| 5xx 其他 | 服务端异常 | 重试 1 次后失败并告警 |
限速不是可选项。公开数据的抓取要控制频率,我一般把相邻请求间隔设成 2 到 5 秒的随机值,既能稳定翻完整个列表,也不会给源站造成压力。对接到一个晚上能跑完的体量来说,慢比快可靠。
4.3 断点续爬与去重
列表页爬到一半断网是常态。做法是维护一张批次表,记录每个入口、起始日期和已完成的页码:
CREATE TABLE IF NOT EXISTS crawl_state ( entry TEXT NOT NULL, start_date TEXT NOT NULL, page_no INTEGER NOT NULL, updated_at TEXT DEFAULT (datetime('now', 'localtime')), PRIMARY KEY (entry, start_date) );恢复时先查crawl_state,从上次完成的page_no + 1继续;详情入库时依赖UNIQUE(entry, biz_no)去重,重复解析直接忽略。两个机制叠加后,即使列表页重复抓取,详情数据也不会产生重复记录,这就是断点续爬的完整闭环。
5. 让 landchina 的选项资料齐全变成配置化与文档化
5.1 用一份配置文件管理所有查询选项
把第 2 章列出的参数表落成 YAML 配置,爬虫启动时读取并组装 POST 数据:
entries: result: tab: result filters: announcement_type: "招拍挂出让结果公告" region: "110000" parse: result_parser market: tab: market filters: transaction_type: "挂牌出让" resource_type: "国有建设用地使用权" parse: market_parser crawl: start_date: "2024-01-01" interval: [2, 5] retry: 3配置化带来的直接好处是:甲方要换行政区、换交易方式,只改 YAML 不用碰代码。“选项资料齐全”从代码约束变成了配置约束,回归风险大幅下降。
5.2 文档交付的清单与模板骨架
接单场景里,文档详细比代码技巧更重要。一份合格的交付文档至少包含:运行环境与依赖安装、配置文件字段说明、参数表与实际页面的对照、SQLite 表结构说明、常见报错对照表。README 里把目录和命令写清楚,对方照着跑就能出数据。
# landchina 爬虫交付文档 ## 1. 环境要求(Python 3.10+、依赖清单) ## 2. 安装与启动命令 ## 3. config.yaml 字段说明 ## 4. 两个入口的请求参数差异 ## 5. 输出表结构与数据字典 ## 6. 常见报错与处理办法5.3 用校验任务锁住“选项资料齐全”
最后一层保障是数据校验:抓完一个批次后统计总页数与入库条数,对比源站显示的结果数量是否一致;再抽检详情页关键字段的非空率。把校验脚本放进 cron,每周跑一次,任何参数过期或页面改版都会在字段级暴露出来,而不是等甲方发现数据缺了一大块才回来排查。
本文还有配套的精品资源,点击获取