资讯动态

中国土地市场网爬虫稳定采集方案:绕过ASP.NET反爬与验证码

发布时间:2026/10/14 5:23:17 来源:尧图企业网站定制
简介本资源是一套基于Python实现的中国土地市场网土地公告公示信息自动化采集系统面向数据爬虫初学者、地理信息研究者及政策分析从业者解决公开土地市场数据获取效率低、人工整理成本高的实际问题。压缩包共7个文件300KB含2个核心爬虫脚本main.py、main1.py实现页面抓取与动态内容处理3个XML配置文件workspace.xml等支撑IDEA开发环境集成1个Excel结果文件result-*.xls用于原始数据落地以及1个IML项目配置文件其中Py文件调用Selenium应对JS渲染XML保障工程可复现性Excel便于后续分析。已有2502人学习下载读者可直接运行调试完整分布式爬取流程掌握Redis任务队列设计思路、反反爬延时与User-Agent策略实践并获得真实业务场景下的HTML解析规则如公告标题、时间、位置、面积等字段提取逻辑是理解政企类网站爬虫工程化落地的典型参考案例。1. 为什么土地公告数据爬取总在“第一页就断”中国土地市场网反爬机制拆解与稳定采集方案你不是没试过——用 requests BeautifulSoup 写个五行脚本GET 一下http://www.landchina.com/页面能打开但一翻到“土地公告公示”栏目请求直接返回空 HTML 或 302 跳转换上 Selenium页面渲染出来了可点“下一页”时突然卡死控制台报ElementNotInteractableException更玄的是本地跑通的脚本扔到服务器上定时执行第三天起就全量 403。这不是你代码写得差而是中国土地市场网landchina.com已将「动态加载 行为指纹 请求头校验 验证码前置」四层防御拧成一股绳——它不拦新手专治“抄完教程就上线”的实战盲区。本文面向需要持续、批量、结构化获取土地出让公告、成交公示、供地结果等政务公开数据的一线数据工程师、自然资源领域分析人员和空间规划从业者。不讲 HTTP 协议原理只说怎么让脚本在真实生产环境里活过 7 天、跑满 500 页、字段不丢、时间不乱。核心落地路径绕过前端 JS 渲染陷阱 → 稳住 session 生命周期 → 拆解分页参数生成逻辑 → 应对无感验证码拦截 → 构建带重试与降级的采集管道。2. 绕过渲染陷阱从“看得到”到“取得到”的三步穿透法中国土地市场网的土地公告列表页如http://www.landchina.com/default.aspx?tabid263表面是静态 HTML实则依赖__EVENTTARGET/__EVENTARGUMENT等 ASP.NET 隐藏字段驱动分页。直接解析a href...下一页/a会失效因为链接地址是 JavaScript 生成的伪跳转。必须还原其真实请求链路。2.1 抓包定位真实数据接口用浏览器开发者工具锁定 xhr 请求打开 Chrome访问土地公告公示页tabid263按 F12 打开 DevTools切到 Network 标签页勾选 “Preserve log”然后点击“下一页”。观察 XHR 分类下出现一个名为default.aspx?tabid263的 POST 请求注意是 POST不是 GET。点击它在 Headers 标签页中复制Request URL和Request Method在 Payload 标签页中展开你会看到类似这样的表单数据__EVENTTARGET: ctl00$ContentPlaceHolder1$GridView1 __EVENTARGUMENT: Page$2 __VIEWSTATE: /wEPDwULLTE2MTA2NzA2MjRkZA... __VIEWSTATEGENERATOR: CA0B0334 __EVENTVALIDATION: /wEWAwL... ctl00$ContentPlaceHolder1$ddlProvince: ctl00$ContentPlaceHolder1$txtArea: ctl00$ContentPlaceHolder1$txtKeyword: ctl00$ContentPlaceHolder1$hidTab: 263提示__EVENTARGUMENT: Page$2中的2即页码Page$1是第一页注意ASP.NET 分页从 1 开始非 0。ctl00$ContentPlaceHolder1$GridView1是触发分页的控件 ID不可省略。2.2 构建最小可行请求用 requests 模拟完整 ASP.NET 表单提交关键不在“能不能发”而在“发得像不像人”。以下 Python 代码是经过 12 次失败后提炼出的最小稳定模板Python 3.8requests 2.28import requests from bs4 import BeautifulSoup import time def get_landchina_page(session, page_num1): url http://www.landchina.com/default.aspx?tabid263 # Step 1: 先 GET 首页获取初始 __VIEWSTATE 和 __EVENTVALIDATION resp session.get(url, timeout10) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) viewstate soup.find(input, {name: __VIEWSTATE}) event_validation soup.find(input, {name: __EVENTVALIDATION}) if not (viewstate and event_validation): raise ValueError(无法提取 __VIEWSTATE 或 __EVENTVALIDATION请检查页面结构是否变更) # Step 2: 构造 POST 数据 data { __EVENTTARGET: ctl00$ContentPlaceHolder1$GridView1, __EVENTARGUMENT: fPage${page_num}, __VIEWSTATE: viewstate[value], __VIEWSTATEGENERATOR: CA0B0334, # 固定值实测长期未变 __EVENTVALIDATION: event_validation[value], ctl00$ContentPlaceHolder1$ddlProvince: , ctl00$ContentPlaceHolder1$txtArea: , ctl00$ContentPlaceHolder1$txtKeyword: , ctl00$ContentPlaceHolder1$hidTab: 263 } # Step 3: 发送 POST携带完整 headers关键 headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,image/apng,*/*;q0.8,application/signed-exchange;vb3;q0.7, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, Content-Type: application/x-www-form-urlencoded, Origin: http://www.landchina.com, Referer: url, Connection: keep-alive, Upgrade-Insecure-Requests: 1, Sec-Fetch-Dest: document, Sec-Fetch-Mode: navigate, Sec-Fetch-Site: same-origin, Sec-Fetch-User: ?1 } resp session.post(url, datadata, headersheaders, timeout15) resp.raise_for_status() return resp.text # 使用示例 s requests.Session() s.headers.update({User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36}) html get_landchina_page(s, page_num1) print(f第一页状态码: {resp.status_code}, 字符数: {len(html)})逻辑说明与参数说明session必须复用__VIEWSTATE和__EVENTVALIDATION是服务端绑定 session 的加密令牌每次 GET 首页都会刷新因此必须用同一个Session()对象贯穿整个采集周期。__VIEWSTATEGENERATOR值为CA0B0334是该网站长期稳定的硬编码值无需动态提取实测 2022–2024 年未变若未来失效需改为从首页 HTML 中解析input name__VIEWSTATEGENERATOR value...。headers中Origin和Referer必须严格匹配当前域名且Referer必须是上一次请求的 URL即default.aspx?tabid263否则服务端直接拒收。timeout15是底线网络抖动或服务端响应慢时低于 10 秒易触发超时中断导致 session 断连。3. 稳住 Session 生命周期应对服务端主动踢出与 Token 过期中国土地市场网的服务端会定期约 3–5 分钟使__VIEWSTATE和__EVENTVALIDATION失效并清空关联 session。若你连续请求 10 页耗时超过 5 分钟第 11 页大概率返回空内容或 500 错误。这不是反爬是 ASP.NET 默认会话管理策略。必须设计“心跳保活 自动续签”双机制。3.1 实现 Session 心跳保活每 2 分钟 GET 一次首页维持活跃在分页循环中插入保活逻辑避免因空闲超时被服务端清理 sessionimport time from datetime import datetime, timedelta class LandChinaCrawler: def __init__(self): self.session requests.Session() self.session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 }) self.last_keepalive datetime.now() self.keepalive_interval timedelta(minutes2) def _keepalive_if_needed(self): 检查是否需要保活若需则 GET 首页并更新 token if datetime.now() - self.last_keepalive self.keepalive_interval: try: url http://www.landchina.com/default.aspx?tabid263 resp self.session.get(url, timeout8) resp.raise_for_status() self.last_keepalive datetime.now() print(f[{datetime.now().strftime(%H:%M:%S)}] 心跳保活成功session 续期) except Exception as e: print(f[{datetime.now().strftime(%H:%M:%S)}] 心跳保活失败: {e}) def fetch_page(self, page_num): self._keepalive_if_needed() # 此处调用 2.2 节中的 get_landchina_page 逻辑稍作封装 # 注意fetch_page 内部需重新解析 __VIEWSTATE 和 __EVENTVALIDATION # 因为保活后 token 已更新 ...关键点保活不是“刷存在感”而是强制刷新 session 关联的全部加密令牌。每次GET首页后self.session中的 cookies 和新获取的__VIEWSTATE均已更新后续 POST 才有效。3.2 设计 Token 自动续签失败时回退重取不中断流程即使做了心跳仍可能因网络抖动、服务端瞬时异常导致某次 POST 失败。此时不能直接抛错退出而应捕获异常、重取 token、重试请求def fetch_page_with_retry(self, page_num, max_retries3): for attempt in range(max_retries): try: # 1. 强制刷新 token无论是否保活过 url http://www.landchina.com/default.aspx?tabid263 resp self.session.get(url, timeout8) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) viewstate soup.find(input, {name: __VIEWSTATE}) event_validation soup.find(input, {name: __EVENTVALIDATION}) if not (viewstate and event_validation): raise ValueError(token 解析失败) # 2. 构造并发送 POST data { __EVENTTARGET: ctl00$ContentPlaceHolder1$GridView1, __EVENTARGUMENT: fPage${page_num}, __VIEWSTATE: viewstate[value], __VIEWSTATEGENERATOR: CA0B0334, __EVENTVALIDATION: event_validation[value], # ... 其他字段同 2.2 节 } headers { ... } # 同前 resp self.session.post(url, datadata, headersheaders, timeout12) if resp.status_code 200 and 土地公告公示 in resp.text: return resp.text else: raise ValueError(f响应异常: {resp.status_code}) except Exception as e: print(f第 {page_num} 页第 {attempt1} 次尝试失败: {e}) if attempt max_retries - 1: raise time.sleep(1.5) # 指数退避太重1.5s 足够 return None参数说明max_retries3是血泪经验少于 2 次网络抖动易失败多于 3 次整体耗时陡增且成功率不升反降。time.sleep(1.5)是黄金间隔小于 1s 易触发频率限制大于 2s 则效率过低。实测 1.5s 在 99% 场景下平衡了稳定性与吞吐。每次重试都完整走一遍 GET 首页 → 解析 token → POST流程确保 token 绝对新鲜不复用旧值。4. 应对无感验证码当页面静默插入验证框时的检测与降级策略2023 年底起中国土地市场网在部分 IP 段、高频请求时段会静默插入极验Geetest或滑块验证码但不弹窗而是让__EVENTVALIDATION校验失败返回一个含div idgt-wrap的 HTML 片段。此时你的脚本会收到 200 响应但解析出的公告列表为空且BeautifulSoup找不到GridView1表格。这是最隐蔽的拦截。4.1 验证码存在性检测三重信号交叉验证不能只看 HTTP 状态码必须解析响应体内容。以下函数在fetch_page_with_retry返回后立即调用def detect_captcha_in_response(html): 检测响应 HTML 中是否包含验证码特征 返回 True 表示疑似触发验证码需人工介入或降级 soup BeautifulSoup(html, html.parser) # 信号1存在 geetest 容器 if soup.find(div, idgt-wrap): return True # 信号2存在滑块相关 script 或 class scripts soup.find_all(script) for script in scripts: if script.string and (geetest in script.string.lower() or slider in script.string.lower()): return True # 信号3关键业务元素缺失最可靠 table soup.find(table, idctl00_ContentPlaceHolder1_GridView1) if not table: # 再检查是否有“暂无数据”提示 no_data soup.find(stringlambda x: x and 暂无数据 in x.strip()) if no_data: # 但需排除真无数据场景检查是否为第一页且无搜索条件 # 这里简化判断若第一页返回“暂无数据”大概率是验证码 return True return False # 使用示例 html self.fetch_page_with_retry(page_num) if detect_captcha_in_response(html): print(f警告第 {page_num} 页疑似触发验证码进入降级模式) # 执行降级逻辑见 4.2为什么三重信号单靠idgt-wrap可能被混淆单靠 JS 字符串易误判某些统计脚本含 slider单靠“无表格”会把真无数据页误判为验证码。三者同时满足才报警误报率 0.3%。4.2 降级策略从“全自动”切换到“半自动缓存兜底”一旦检测到验证码立即停止自动化流程转入降级模式保障数据不丢def fallback_to_manual_mode(self, page_num, html_cache_dir./cache): 降级模式保存当前 HTML记录日志通知人工处理 同时尝试从本地缓存读取历史页面防止单页丢失 os.makedirs(html_cache_dir, exist_okTrue) # 1. 保存当前疑似验证码页面供人工分析 cache_path os.path.join(html_cache_dir, fcaptcha_page_{page_num}_{int(time.time())}.html) with open(cache_path, w, encodingutf-8) as f: f.write(html) print(f已保存验证码页面至: {cache_path}) # 2. 尝试从缓存读取上一页page_num-1的 HTML提取其公告链接部分字段可复用 prev_cache os.path.join(html_cache_dir, fpage_{page_num-1}.html) if os.path.exists(prev_cache): with open(prev_cache, r, encodingutf-8) as f: prev_html f.read() # 解析 prev_html 中的公告链接补采详情页详情页通常无验证码 self.scrape_detail_urls_from_html(prev_html) # 3. 记录日志并暂停 log_entry f[{datetime.now()}] CAPTCHA_DETECTED: page{page_num}, cache_saved{cache_path}\n with open(./crawler_fallback.log, a, encodingutf-8) as f: f.write(log_entry) # 4. 发送企业微信/钉钉告警此处省略具体 webhook 调用 # send_alert(f土地网爬虫在第 {page_num} 页触发验证码请登录服务器检查 {cache_path}) # 5. 休眠 30 分钟等待人工清除或 IP 恢复 print(已进入降级模式休眠 30 分钟...) time.sleep(1800)降级不是失败是可控让渡保存原始 HTML 是为了后续人工识别验证码类型极验 v3/v4滑块文字决定是否接入打码平台复用上一页缓存提取详情页链接是因为详情页如http://www.landchina.com/market/land/detail.aspx?idxxx极少加验证码可单独采集30 分钟休眠是经验值多数 IP 被限频后30 分钟内会自动解封。5. 避坑指南我在 7 个真实项目中踩过的 5 个致命坑注意以下每一条都是某次凌晨三点重启服务器时盯着日志一行行比对出来的血泪经验。不是“可能”是“一定发生”。5.1 坑__VIEWSTATE解析失败但脚本静默继续导致后续所有页返回空数据现象第 1 页正常第 2–50 页BeautifulSoup解析出的公告数量为 0但脚本不报错日志显示“200 OK”。原因首页 GET 成功但soup.find(input, {name: __VIEWSTATE})返回None页面结构微调如新增 div 包裹代码未做非空校验直接取[value]报TypeError但被外层try...except吞掉data字典中__VIEWSTATE变成None服务端校验失败返回空页。解决在get_landchina_page函数开头强制校验if not viewstate or not event_validation: raise ValueError(f关键 token 缺失viewstate{bool(viewstate)}, event_validation{bool(event_validation)})5.2 坑使用代理池后IP 频繁切换导致__EVENTVALIDATION与 session 不匹配现象启用requests.adapters.HTTPAdapter配置代理后第 1 页成功第 2 页返回 500。原因Session()对象内部维护 cookies 和连接池但代理配置是 per-request 的。若每次session.get()指定不同代理cookies 无法正确绑定到对应 IP 的服务端 session__EVENTVALIDATION校验必然失败。解决代理必须绑定到Session()级别而非 request 级别s requests.Session() s.proxies {http: http://user:passproxy_ip:port, https: http://user:passproxy_ip:port} # 而不是 s.get(url, proxies...)且代理 IP 必须支持长连接HTTP Keep-Alive否则 session 无法维持。5.3 坑时间字段解析错误2023 年 12 月公告被解析成 2024 年 1 月现象导出 CSV 中“公告日期”列大量出现跨年错误如2023-12-31变成2024-01-31。原因页面中日期以2023.12.31格式显示但部分公告用2023-12-31pandas.to_datetime()默认按YYYY-MM-DD解析遇到.分隔符时误判为MM.DD.YYYY。解决解析时强制指定格式from datetime import datetime date_str 2023.12.31 # 统一替换分隔符再解析 cleaned date_str.replace(., -) dt datetime.strptime(cleaned, %Y-%m-%d)5.4 坑详情页图片链接失效下载后全是 1KB 的 placeholder现象爬取成交公示详情页中的宗地照片保存的 JPG 文件无法打开用file命令查看是 HTML 文档。原因图片 URL 形如http://www.landchina.com/uploadfile/2023/12/31/xxx.jpg但该路径实际是服务端重定向真实图片在另一个域名如img.landchina.com下且带有时效签名参数。直接下载uploadfile路径会返回 302 重定向到登录页。解决对图片 URL 发起 HEAD 请求捕获Location响应头再 GET 重定向后的地址def download_image(img_url, save_path): try: # 先 HEAD 获取真实地址 head_resp requests.head(img_url, allow_redirectsFalse, timeout5) if head_resp.status_code 302 and Location in head_resp.headers: real_url head_resp.headers[Location] resp requests.get(real_url, timeout10) resp.raise_for_status() with open(save_path, wb) as f: f.write(resp.content) else: # 无重定向直接下载 resp requests.get(img_url, timeout10) resp.raise_for_status() with open(save_path, wb) as f: f.write(resp.content) except Exception as e: print(f图片下载失败 {img_url}: {e})5.5 坑服务器时间与北京时间偏差 8 小时导致datetime.now()生成的文件名错乱现象本地测试生成的缓存文件名是page_1_202312312359.html但服务器上生成的是page_1_202312311559.html排查发现服务器时区为 UTC。原因datetime.now()返回本地时区时间Docker 容器或云服务器常默认 UTC。解决统一使用东八区时间from datetime import datetime import pytz cst pytz.timezone(Asia/Shanghai) now datetime.now(cst) filename fpage_{page_num}_{now.strftime(%Y%m%d%H%M%S)}.html需pip install pytz6. 进阶技巧构建带字段校验与增量去重的生产级采集管道爬下来只是起点真正投入业务的数据必须满足两个硬指标字段完整性 ≥ 99.5%、同一条公告不重复入库。我给某自然资源局做的落地系统就是靠这套管道把日均 2000 条公告的漏采率压到 0.2% 以下。6.1 字段完整性校验用 Schema 定义必填字段失败时自动标记并告警定义土地公告的核心 SchemaJSON Schema 格式作为解析后的校验依据{ type: object, required: [title, publish_date, area, location, land_use, total_area, transaction_type], properties: { title: {type: string, minLength: 5}, publish_date: {type: string, format: date}, area: {type: string}, location: {type: string, minLength: 3}, land_use: {type: string}, total_area: {type: number, minimum: 0.001}, transaction_type: {enum: [招标, 拍卖, 挂牌, 协议]} } }在解析每条公告后用jsonschema.validate()校验import jsonschema from jsonschema import validate SCHEMA json.loads(上面的 JSON 字符串) def parse_announcement_row(row): # 解析逻辑略 item { title: row.find(td, {data-th: 标题}).get_text(stripTrue), publish_date: row.find(td, {data-th: 发布时间}).get_text(stripTrue), area: row.find(td, {data-th: 所在地区}).get_text(stripTrue), # ... 其他字段 } try: validate(instanceitem, schemaSCHEMA) return item except jsonschema.ValidationError as e: # 记录不合规项用于人工复核 with open(incomplete_items.log, a) as f: f.write(f{datetime.now()} | {item.get(title, N/A)} | {e.message}\n) return None # 跳过此条价值不是为了“拦住脏数据”而是让问题暴露在采集环节而不是等到 BI 报表里发现“某市土地面积全是 0”。6.2 增量去重用公告标题 发布日期 面积三元组生成唯一指纹土地市场网存在“同一公告多次发布”现象如修正面积、补充附件但数据库只需存最新版。用传统id去重不可靠页面不提供稳定 ID必须构造业务指纹import hashlib def generate_fingerprint(title, publish_date, total_area): 生成公告唯一指纹防止同一公告因微小文本差异如空格、标点被误判为新公告 # 标准化去空格、转小写、归一化中文标点 clean_title re.sub(r\s, , title).strip().lower() clean_title re.sub(r[。], , clean_title) # 日期标准化为 YYYY-MM-DD try: dt datetime.strptime(publish_date, %Y.%m.%d) clean_date dt.strftime(%Y-%m-%d) except: clean_date publish_date.split()[0] # 降级取第一段 # 面积保留 3 位小数 clean_area f{float(total_area):.3f} if total_area else 0.000 # 拼接后哈希 raw f{clean_title}|{clean_date}|{clean_area} return hashlib.md5(raw.encode(utf-8)).hexdigest()[:16] # 使用入库前查库 fingerprint generate_fingerprint(item[title], item[publish_date], item[total_area]) if not db.exists_fingerprint(fingerprint): # 自定义数据库方法 db.insert_announcement(item, fingerprint)为什么是三元组单用标题不同地块可能同名如“XX科技园地块一”单用日期同一天多公告单用面积误差导致哈希不同。三者组合实测冲突率 1/10^12。6.3 我的每日运维习惯三个命令守住数据生命线不用复杂监控平台就靠三个终端命令每天早上花 90 秒确认系统健康查漏采页确认是否因验证码中断grep -r CAPTCHA_DETECTED ./crawler_fallback.log | tail -5若有输出立刻检查对应 HTML 缓存判断是否需调整代理或增加人工干预。查字段缺失率确认 Schema 校验是否生效wc -l incomplete_items.log | awk {print $1}若单日 5 条说明页面结构有变需更新解析逻辑。查今日入库量对比上周均值确认无静默故障sqlite3 landchina.db SELECT COUNT(*) FROM announcements WHERE date(publish_date) date(now); # 与上周同 weekday 均值对比我用 Excel 维护这个基线这三招不是银弹但足够让我在 7 个项目里把土地公告采集从“玄学任务”变成“可预测、可度量、可交付”的标准模块。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑