简介:你将获得什么

房地产市场靠数字说话。有人找低于市场价的公寓,有人评估新盘的竞争对手,有人为投资者做报告。他们有一个共同点:需要房源和价格的实时数据,而手动收集根本不可能。这时候就轮到自己动手写一个ЦИАН采集器了。

在本指南中,你将从头搭建一个能做三件事的实用工具。第一:按指定筛选条件翻遍历页列表,采集房源的价格、地址、面积和链接。第二:进入每个房源的详情页,抓取楼层、建成年份和房屋类型等细节。第三:也是最有价值的,把数据存入数据库,日复一日积累价格历史,让你看到哪些房源降了价、哪些下架了,以及某个区域的市场走向。

最终成果是这样:你有一个装着Python脚本的文件夹、一个SQLite数据库文件,以及一张可以用Excel或Google Sheets打开的表。早上运行脚本,拿到最新快照。运行一周后,你就有了动态趋势。

本指南适合谁

  • 房产中介的市场和分析人员,需要监控各区域价格,又不想买昂贵的报告。
  • 套利者和企业主,在寻找细分机会,想用数字理解供需。
  • 想通过一个生动易懂的例子入门爬虫的初级开发者。
  • 想比别人更早发现降价房源的投资者和个人买家。

事先需要知道什么

编程经验不是必须的。我们会逐行讲解代码,解释每一行的作用。只要会安装软件、打开命令行、复制文本就行。如果你至少打开过浏览器的开发者工具,那就更轻松了。如果没打开过,我们会告诉你在哪。

唯一的要求:细心。爬虫对类名和地址里的拼写错误非常敏感。多一个字母,脚本就会返回空列表。别慌:每一步都有检查点,让你确认一切按计划进行。

需要多少时间

准备环境大约30分钟。第一个能抓列表的采集器你一个小时就能写出来。详情页、代理和数据库还要再花一个半到两个小时。如果不赶时间,总共三到四个小时。价格历史从第二次运行起就会自动积累。

前期准备:工具和环境

写代码之前,先把需要的东西备齐。这一节别跳过:新手一半的问题都出在Python装错或缺少库上。

系统要求

  • Windows 10/11、macOS或Linux电脑。近八年的任何笔记本都行。
  • 至少4GB内存。用浏览器自动化方案的话,最好8GB。
  • 大约2GB空闲磁盘空间,装Python、库和数据库。
  • 稳定的网络连接。

需要安装什么

  1. Python 3.11或更高版本。从官网python.org下载安装包。Windows安装时,务必勾选第一个界面下方的Add Python to PATH。不勾的话,终端里的python命令用不了。macOS通常自带Python,但还是装个新版本更好。
  2. 代码编辑器。推荐Visual Studio Code:免费、语法高亮、能显示错误。在内置扩展商店(左侧面板四个方块图标)安装Python扩展。
  3. Chrome或Edge浏览器。我们需要开发者工具来研究页面结构。
  4. Python库。下面通过终端安装。
  5. 移动代理访问权限。第五步会用到。你需要服务器地址、端口、用户名、密码,以及切换IP的链接。买代理时在后台都能拿到。如果暂时没有代理,前几步不装也能做。

创建工作目录和虚拟环境

  1. 在磁盘上创建一个名为cian_parser的文件夹。路径里避免中文和空格:它们有时会搞坏工具。
  2. 打开终端。Windows按Win+R,输入cmd回车。macOS用Spotlight打开Terminal。
  3. 用cd命令和路径进入文件夹,例如Windows:cd C:\projects\cian_parser,macOS:cd ~/projects/cian_parser。
  4. 用python -m venv venv创建虚拟环境。这是Python的隔离副本,避免项目库和系统库冲突。
  5. 激活环境。Windows:venv\Scripts\activate。macOS和Linux:source venv/bin/activate。终端行首会出现(venv)。
  6. 一条命令安装库:pip install requests beautifulsoup4 lxml pandas openpyxl。安装要一到两分钟。

检查:在终端输入python -c "import requests, bs4, pandas; print('ok')"。如果屏幕上出现ok且没有错误,环境就绪。如果看到ModuleNotFoundError,说明环境没激活或安装中断了。重新激活venv再跑pip install。

备份

这个项目里最值钱的不是代码,而是积累了价格历史的数据库。它无法恢复:过去的价格不会再出现在别处。所以从第一天起就跟自己约定:数据库文件每周至少往云盘或外置硬盘复制一次。稍后我们会把自动复制加进脚本。

基础概念:开始前需要理解什么

下面要出现的术语先解释一下。如果你已经熟悉爬虫,可以跳过这一节,但法律部分请留意。

关键词通俗解释

  • 采集(爬取):程序自动获取网站页面并提取所需数据。跟你用眼睛做的一样,但由脚本完成,快上千倍。
  • HTML:网页所用的标记语言。ЦИАН上的公寓价格藏在某个带特定属性的HTML标签里,我们的任务就是找到这个标签。
  • 选择器:HTML中元素的地址。例如带data-mark属性等于MainPrice的span。采集器根据选择器知道从哪里取价格。
  • HTTP请求:向网站服务器发起的访问。你打开页面时浏览器就在做这件事。requests库从代码里做同样的事。
  • 请求头(headers):浏览器随请求一起发送的附加信息:浏览器类型、语言、数据格式。服务器靠它决定返回什么。
  • 代理:请求经过的中间服务器。移动代理使用移动运营商的IP地址,可以按命令更换地址。
  • 分页:列表被拆成多页。要采集全部房源,采集器必须从第一页翻到最后一页。
  • SQLite:单个文件里的轻量数据库。不需要装服务器,Python内置。非常适合存价格历史。

房产平台的列表页是怎么组织的

ЦИАН、Домклик、Яндекс Недвижимость等平台原理类似。有一个带筛选条件的搜索页:城市、交易类型、房间数、价格区间。每个筛选条件都会变成地址栏里的一个参数。例如deal_type参数值为sale表示出售,room1等于1就加入一居室。理解这些参数会给你强大的工具:不用在网站上点击,直接拼出需要的地址。

列表里每个房源是一张卡片:标题、价格、地址、几张照片、详情页链接。详情页包含完整特征,而且经常把数据重复放在网站用来渲染界面的隐藏JSON块里。这个块比HTML好采得多。

法律和道德边界

注意:只采集房源的公开信息:价格、面积、地址、房屋特征。不要采集和存储卖家、中介的电话、姓名及其他个人数据:这受个人信息保护法约束,违规要承担真实责任。开始前阅读平台的用户协议,把数据用于自己的分析,而不是转卖或复制整个网站。保持合理的请求频率:你的采集器不该造成影响服务运行的负担。

这样做不仅合法,而且实用。带暂停和地址轮换的温和采集器能跑几个月,而激进的采集器一小时就会被临时限制。

第1步:明确目标和数据结构

本阶段目标:清楚描述我们要采集什么、怎么存。没有这一步,你写出的采集器会什么都抓,然后在数据垃圾堆里翻一周。

  1. 用一句话说清业务问题。例如:圣彼得堡哪些一居室一个月内降价超过5%;某个区域新盘的每平米多少钱;低于某个金额的房源多快卖出去。
  2. 确定列表筛选条件。本指南以这个为例:出售、二手房、一居和两居、莫斯科、价格不超过1500万卢布。你换成自己的参数。
  3. 列出字段表。每个房源需要:房源唯一ID、链接、标题、价格、地址、总面积、楼层和总层数、房屋类型、建成年份、首次发现日期、最近检查日期。价格历史需要:房源ID、日期、价格。
  4. 打开代码编辑器,在项目文件夹里创建config.py。把最常改的参数写进去:
BASE_URL = 'https://www.cian.ru/cat.php'
SEARCH_PARAMS = {'deal_type': 'sale', 'engine_version': 2, 'offer_type': 'flat', 'region': 1, 'room1': 1, 'room2': 1, 'maxprice': 15000000}
MAX_PAGES = 5
PAUSE_MIN = 4
PAUSE_MAX = 9
DB_PATH = 'realty.db'

注意:示例代码里的换行用换行符表示,在编辑器里每个变量就写一行。参数region等于1对应莫斯科,2对应圣彼得堡。其他地区的代码可以在网站上应用筛选后看地址栏得到。

建议:先把MAX_PAGES设成2-3。每页列表大约28个房源,调试够用。等确认所有字段都能正确提取,再跑完整采集。

检查:你有一个config.py文件,本子或脑子里记着12个字段的清单和一个具体业务问题。如果问题是“我想收集全俄罗斯所有数据”,回去把范围缩小:全国采集是几十万个房源,完全是另一套基础设施。

可能的问题

搞不清哪个参数对应想要的筛选。解决办法:打开网站,手动设置筛选,复制地址栏的网址,按&符号拆开。每个“键=值”对就是一个参数。

第2步:研究列表页结构

本阶段目标:在HTML里找到取价格、标题、地址和链接的元素。这是最具探索性的一步,新手最容易在这里迷失,所以我们走得很慢。

  1. 打开浏览器,进入带你自己筛选条件的ЦИАН列表页。确认能看到公寓列表。
  2. 把鼠标悬停在任意公寓的价格上,右键选择检查(Edge里叫“检查”)。开发者工具面板会打开,价格元素会被高亮。
  3. 看高亮那行。写本指南时是带data-mark属性等于MainPrice的span标签。记下这个属性:它就是价格的选择器。
  4. 点击父级标签,在元素树里往上走,直到找到一个覆盖整张房源卡片的标签。通常是带data-name属性等于CardComponent的article。鼠标悬停在面板里它上面时,页面上整张带照片和价格的卡片会被高亮。
  5. 在卡片里找标题(带data-mark等于OfferTitle的span)、地址(几个data-name等于GeoLabel的a链接,拼起来就是地址)和房源链接(href指向cian.ru/sale/flat/编号的a标签)。把四个选择器都记下来。
  6. 找到页面底部的分页块。翻到列表底部,右键点第二页的页码,看它的地址长什么样。你会看到参数p等于2。也就是说,翻页只要改这个参数。

注意:data-mark和data-name这些属性名平台改版时会变。别照抄本文的选择器:一定要在开发者工具里跟真实页面对照。自己会找选择器,比任何现成清单都重要。

检查有没有隐藏JSON

很多平台把列表数据以现成形式存在script标签里。这比HTML方便:不用把地址从碎片拼起来。

  1. 在开发者工具里按Ctrl+F(macOS是Cmd+F),输入offers或initialState。
  2. 如果搜到一个大段文本、看起来像带花括号的字典的script标签,说明数据在JSON里。记住这个块开头那个变量名。
  3. 如果什么都没搜到,别担心:下一步的HTML方案在任何情况下都能用。

建议:在开发者工具里打开Network(网络)标签,刷新页面,按Fetch/XHR类型过滤请求。有时网站用单独的JSON请求加载列表。如果你看到带offers字段的这种请求,采它最简单:拿到的是没有HTML的纯数据。

检查:你记下了卡片、价格、标题、地址、链接的选择器,知道分页参数名。在面板里点每个选择器,能看到页面上对应元素被高亮。

可能的问题

开发者工具显示了HTML,但里面没有价格。原因:网站加载后用脚本渲染了部分数据。解决办法:在Network标签里看价格是不是单独请求来的,或者用进阶部分的浏览器自动化。

第3步:写第一个ЦИАН列表页采集器

本阶段目标:得到一个下载列表页、提取房源列表并打印到控制台的脚本。这一步之后你就有了可扩展的骨架。

  1. 在项目文件夹里创建parser.py。
  2. 在文件开头导入库和设置:
import time
import random
import requests
from bs4 import BeautifulSoup
from config import BASE_URL, SEARCH_PARAMS, MAX_PAGES, PAUSE_MIN, PAUSE_MAX
  1. 写请求头。服务器应该看到普通浏览器和俄语区域,否则可能拿到不是你想要的页面:
HEADERS = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36', 'Accept-Language': 'ru-RU,ru;q=0.9', 'Accept': 'text/html,application/xhtml+xml'}
  1. 写页面下载函数。它接收页码,加到参数里,返回HTML。必须检查响应码:200表示成功,其他都意味着该停下来查问题:
def fetch_page(page, session):
params = dict(SEARCH_PARAMS)
params['p'] = page
resp = session.get(BASE_URL, params=params, headers=HEADERS, timeout=30)
if resp.status_code != 200:
print('Статус', resp.status_code, 'на странице', page)
return None
return resp.text
  1. 写解析函数。它找到所有卡片,逐张取字段。注意if的写法:元素不存在时不报错,而是记None:
def parse_cards(html):
soup = BeautifulSoup(html, 'lxml')
cards = soup.select('article[data-name=CardComponent]')
result = []
for card in cards:
link_tag = card.select_one('a[href*=/sale/flat/]')
price_tag = card.select_one('span[data-mark=MainPrice]')
title_tag = card.select_one('span[data-mark=OfferTitle]')
geo_tags = card.select('a[data-name=GeoLabel]')
if not link_tag or not price_tag:
continue
url = link_tag.get('href')
offer_id = url.rstrip('/').split('/')[-1]
price_text = price_tag.get_text()
price = int(''.join(ch for ch in price_text if ch.isdigit()))
title = title_tag.get_text(strip=True) if title_tag else None
address = ', '.join(g.get_text(strip=True) for g in geo_tags)
result.append({'offer_id': offer_id, 'url': url, 'title': title, 'price': price, 'address': address})
return result
  1. 组装主循环。它翻页,在请求间随机暂停,把结果汇总到一个列表。随机暂停很重要:等间隔显得不自然,还会造成峰值负载:
def collect_listing():
session = requests.Session()
all_offers = []
for page in range(1, MAX_PAGES + 1):
html = fetch_page(page, session)
if html is None:
break
offers = parse_cards(html)
print('Страница', page, 'объектов:', len(offers))
if not offers:
break
all_offers.extend(offers)
time.sleep(random.uniform(PAUSE_MIN, PAUSE_MAX))
return all_offers
if __name__ == '__main__':
data = collect_listing()
for item in data[:5]:
print(item)
print('Всего собрано:', len(data))
  1. 保存文件,在终端用python parser.py运行。确认venv环境已激活。

讲解几个关键点。Session在请求之间保持cookies,所以网站看到的是同一个访客的连续行为,而不是十几个零散访问。select_one返回第一个匹配元素或None,所以我们总是在调get_text前检查结果。房源ID从链接里取:地址最后一段,像312456789这样的数字。它会成为价格历史的主键。

建议:调试阶段把第一页HTML存到文件里,用open('page1.html', 'w', encoding='utf-8').write(html)。这样可以在本地副本上调试解析函数,不给网站发多余请求。

检查:控制台里看到“Страница 1 объектов: 28”、“Страница 2 объектов: 28”等,底部有五个带真实价格和地址的字典。价格应该是没有空格和卢布符号的整数。如果全是空而不是数字,回到第2步检查选择器。

可能的问题

脚本在第一页打印“объектов: 0”。原因:卡片选择器变了,或者服务器返回了占位页。用浏览器打开保存的page1.html看看拿到了什么。如果是要求确认不是机器人的页面,加大暂停并转到带代理的第5步。如果是正常列表,对照选择器。

转换价格时ValueError。原因:价格文本里没有数字,比如写的是“价格面议”。解决办法:把转换包在try里,这种情况记None。

第4步:采集详情页

本阶段目标:让采集器进入每个房源页面,提取详细特征:面积、楼层、房屋类型、建成年份。这些字段用于计算每平米价格和比较相似公寓。

  1. 在浏览器里打开列表中任意一个房源页面。按Ctrl+U查看页面源代码。
  2. 按Ctrl+F输入totalArea。写本指南时,卡片数据在前端配置的script标签里,键名类似frontend-offer-card。你会看到totalArea、floorNumber、floorsCount、buildYear、materialType等字段。
  3. 如果搜totalArea没结果,就在HTML里找特征:通常是“名称-值”成对的块,比如“总面积”和“38,5 м²”。记下这个块的选择器。
  4. 在parser.py里加一个从卡片提取JSON的函数。我们找到需要的script,按花括号切出对象,用json模块解析:
import json
import re
def parse_offer_page(html):
soup = BeautifulSoup(html, 'lxml')
details = {}
for script in soup.find_all('script'):
text = script.string or ''
if 'totalArea' in text and 'offerData' in text:
m = re.search(r'totalArea[^0-9]*([0-9.,]+)', text)
if m:
details['area'] = float(m.group(1).replace(',', '.'))
m = re.search(r'floorNumber[^0-9]*([0-9]+)', text)
if m:
details['floor'] = int(m.group(1))
m = re.search(r'floorsCount[^0-9]*([0-9]+)', text)
if m:
details['floors_total'] = int(m.group(1))
m = re.search(r'buildYear[^0-9]*([0-9]{4})', text)
if m:
details['build_year'] = int(m.group(1))
break
return details
  1. 这里我们故意用正则表达式而不是完整解析JSON。原因很简单:页面上的script里不只有JSON,还有代码,切出纯对象有时不容易。正则查找键和它后面的第一个数字,对数值字段来说足够可靠。
  2. 加一个函数,接收列表房源并逐个用详情页数据丰富。这里的暂停更重要,因为请求量多了28倍:
def enrich_offers(offers, session):
for i, offer in enumerate(offers, 1):
try:
resp = session.get(offer['url'], headers=HEADERS, timeout=30)
if resp.status_code == 200:
offer.update(parse_offer_page(resp.text))
else:
print('Карточка', offer['offer_id'], 'статус', resp.status_code)
except requests.RequestException as e:
print('Ошибка сети на', offer['offer_id'], e)
if i % 10 == 0:
print('Обработано карточек:', i)
time.sleep(random.uniform(PAUSE_MIN, PAUSE_MAX))
return offers
  1. 在if __name__里,collect_listing后面加enrich_offers(data, requests.Session()),把MAX_PAGES设为1重新运行,免得等太久。

要花多久:28张卡片平均暂停6秒,大约3分钟。完整采集5页列表加详情大约15分钟。这很正常。房产采集器不该快,该稳。

建议:每次运行别重复采详情页。公寓特征不会变:面积和建成年份采一次就够。只有价格会变,而价格在列表里就有。第6步我们会让详情页只对新房源请求。这能把请求量减少几十倍。

检查:输出字典里出现了area、floor、floors_total、build_year等键,值合理:面积15到200,楼层不超过总层数,年份1900到2026。部分房源缺字段是正常的:不是所有卖家都填建成年份。

可能的问题

正则找到的是房间面积而不是总面积。原因:JSON里有livingArea或kitchenArea等相似键。解决办法:在键前面加引号或冒号,避免匹配到其他单词的一部分。

第5步:接入移动代理,让采集稳定

本阶段目标:把请求分散到带IP轮换的移动代理上,加重试和正确的响应处理。这一步之后,采集器就能长期规律运行,不会从一个地址造成过大负载。

为什么房产采集器需要移动代理

任何大型平台都会限制单个IP的请求频率。这是防过载保护,对任何自动化都会生效。家庭地址发几百个请求后就开始收到429或验证页面。移动代理另辟蹊径:你拿到移动运营商池里的IP,按命令或定时更换地址。请求分散到各地址,每个地址负载都很低,采集器就能平稳工作。对定期价格监控来说这是根本:你需要的不是一次性数据,而是几个月的每日快照。

配置

  1. 打开你的移动代理服务后台,找到购买的代理。复制四个值:主机、端口、用户名、密码。还要复制换IP链接:通常是一个带密钥的地址,访问它代理就会拿到新地址。
  2. 在config.py里加代理参数。永远不要把密码写进会公开的代码:放在单独文件或环境变量里:
PROXY_HOST = 'ваш_хост'
PROXY_PORT = 'ваш_порт'
PROXY_USER = 'ваш_логин'
PROXY_PASS = 'ваш_пароль'
ROTATE_URL = 'ссылка_для_смены_ip'
ROTATE_EVERY = 25
  1. 在parser.py里加创建带代理会话的函数。requests库接收一个带http和https地址的字典:
from config import PROXY_HOST, PROXY_PORT, PROXY_USER, PROXY_PASS, ROTATE_URL, ROTATE_EVERY
def make_session():
session = requests.Session()
proxy_url = 'http://' + PROXY_USER + ':' + PROXY_PASS + '@' + PROXY_HOST + ':' + PROXY_PORT
session.proxies = {'http': proxy_url, 'https': proxy_url}
return session
def rotate_ip():
try:
r = requests.get(ROTATE_URL, timeout=20)
print('Смена IP:', r.status_code)
time.sleep(5)
except requests.RequestException as e:
print('Не удалось сменить IP:', e)
  1. 检查代理是否可用。建一个临时文件check_proxy.py,通过会话请求IP查询服务并打印结果:
from parser import make_session
s = make_session()
print(s.get('https://api.ipify.org', timeout=20).text)
  1. 运行它。你应该看到一个不同于家里IP的地址。调用rotate_ip再检查一次:地址应该变了。
  2. 现在加带重试的请求函数。它处理三种情况:成功响应、429或403(需要等待并换地址)、网络错误(重试):
def safe_get(session, url, params=None, retries=3):
for attempt in range(1, retries + 1):
try:
resp = session.get(url, params=params, headers=HEADERS, timeout=30)
if resp.status_code == 200:
return resp
if resp.status_code in (429, 403):
print('Статус', resp.status_code, 'попытка', attempt, 'меняем IP и ждем')
rotate_ip()
time.sleep(30 * attempt)
continue
print('Неожиданный статус', resp.status_code)
return None
except requests.RequestException as e:
print('Сетевая ошибка', e, 'попытка', attempt)
time.sleep(10 * attempt)
return None
  1. 把fetch_page和enrich_offers里的session.get调用换成safe_get。在enrich_offers里加计数器:每ROTATE_EVERY个请求调用rotate_ip。这是计划性轮换,不让地址积累太多访问。

注意:如果你收到429或验证页面,别用高频重试硬闯。这只会让当前地址的情况更糟。正确反应:停下来,加大暂停,换IP,从容继续。尊重网站限制的采集器活得更久,采得更多。

建议:一个代理通道对应一个采集线程。用一个地址跑十个线程的诱惑很大,但这是通往限制的直路。要速度就买多个通道,把不同地区或筛选分散上去。

检查:check_proxy.py显示移动运营商地址,轮换后地址变了。采集器跑两页列表加详情,没有一个429。日志里每25张卡片能看到“Смена IP: 200”。

可能的问题

ProxyError或407。原因:用户名或密码不对,或者端口不对。解决办法:核对后台数据,确认用的是HTTP代理端口而不是SOCKS。如果是SOCKS5,装pysocks库,把proxy_url里的http前缀换成socks5h。

轮换后地址没变。原因:运营商发了同一个地址,或者轮换还没生效。解决办法:把rotate_ip后的暂停加到10秒,检查套餐里换IP频率是否受限。

第6步:保存数据,构建价格历史

本阶段目标:把采集器从打印到控制台改成写入SQLite数据库,让每次运行加一个价格历史点,而不是覆盖旧数据。这是整个项目的心脏。

设计表

我们需要两张表。第一张offers存房源:每套房一行,带特征和日期。第二张prices存某天的价格:每套房多行。分开是为了每次记价格时不重复面积和地址。

  1. 创建storage.py,写建表逻辑:
import sqlite3
from datetime import date
from config import DB_PATH
def get_conn():
conn = sqlite3.connect(DB_PATH)
conn.execute('CREATE TABLE IF NOT EXISTS offers (offer_id TEXT PRIMARY KEY, url TEXT, title TEXT, address TEXT, area REAL, floor INTEGER, floors_total INTEGER, build_year INTEGER, first_seen TEXT, last_seen TEXT, is_active INTEGER DEFAULT 1)')
conn.execute('CREATE TABLE IF NOT EXISTS prices (offer_id TEXT, checked_on TEXT, price INTEGER, PRIMARY KEY (offer_id, checked_on))')
conn.commit()
return conn
  1. 加一个函数,返回已知offer_id的集合。它用来只对新房源请求详情页:
def known_ids(conn):
rows = conn.execute('SELECT offer_id FROM offers').fetchall()
return set(r[0] for r in rows)
  1. 写保存函数。新对象往offers插一行。任何对象都更新last_seen并记录今天的价格。prices里的INSERT OR REPLACE意思是:今天已经记过价格就更新,否则新增:
def save_offers(conn, offers):
today = date.today().isoformat()
for o in offers:
conn.execute('INSERT OR IGNORE INTO offers (offer_id, url, title, address, area, floor, floors_total, build_year, first_seen, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', (o['offer_id'], o['url'], o.get('title'), o.get('address'), o.get('area'), o.get('floor'), o.get('floors_total'), o.get('build_year'), today, today))
conn.execute('UPDATE offers SET last_seen = ?, is_active = 1 WHERE offer_id = ?', (today, o['offer_id']))
if o.get('price'):
conn.execute('INSERT OR REPLACE INTO prices (offer_id, checked_on, price) VALUES (?, ?, ?)', (o['offer_id'], today, o['price']))
conn.commit()
  1. 加一个标记下架的函数。如果房源超过三天没出现在列表里,就视为不活跃。这给你销售速度的数据:
def mark_inactive(conn, days=3):
conn.execute('UPDATE offers SET is_active = 0 WHERE julianday(?) - julianday(last_seen) > ?', (date.today().isoformat(), days))
conn.commit()
  1. 重写parser.py主块,让它采列表、找出新对象、只丰富新对象、保存全部:
from storage import get_conn, known_ids, save_offers, mark_inactive
if __name__ == '__main__':
conn = get_conn()
session = make_session()
listing = collect_listing(session)
old = known_ids(conn)
new_offers = [o for o in listing if o['offer_id'] not in old]
print('Новых объектов:', len(new_offers), 'из', len(listing))
enrich_offers(new_offers, session)
save_offers(conn, listing)
mark_inactive(conn)
print('Сохранено. Всего в базе:', conn.execute('SELECT COUNT(*) FROM offers').fetchone()[0])
  1. 别忘了把collect_listing改成接收session参数,而不是自己创建。运行脚本。项目文件夹里会出现realty.db文件。

查看价格历史

创建report.py,把价格变动导出到Excel。下面的查询找出最后价格不同于首次价格的房源:

import sqlite3
import pandas as pd
from config import DB_PATH
conn = sqlite3.connect(DB_PATH)
query = 'SELECT o.offer_id, o.address, o.area, o.url, MIN(p.checked_on) AS first_date, MAX(p.checked_on) AS last_date, (SELECT price FROM prices WHERE offer_id = o.offer_id ORDER BY checked_on ASC LIMIT 1) AS first_price, (SELECT price FROM prices WHERE offer_id = o.offer_id ORDER BY checked_on DESC LIMIT 1) AS last_price FROM offers o JOIN prices p ON p.offer_id = o.offer_id GROUP BY o.offer_id'
df = pd.read_sql(query, conn)
df['change_pct'] = ((df['last_price'] - df['first_price']) / df['first_price'] * 100).round(1)
df['price_per_m2'] = (df['last_price'] / df['area']).round(0)
df = df.sort_values('change_pct')
df.to_excel('report.xlsx', index=False)
print(df.head(10))

第一次运行后change_pct列全是零:还没有历史。从第二天起会出现最初的变化。两周后你会看到全貌:多少房源降了价、平均降多少、哪些区域变动更快。

建议:在parser.py末尾加数据库复制:shutil.copy(DB_PATH, 'backup_' + date.today().isoformat() + '.db')。一行代码保护几周积累的数据。每月删一次旧副本,保留最近五个。

检查:realty.db文件存在,report.xlsx能在Excel里打开,有地址、面积、价格和每平米价格的列。隔几分钟再跑一次parser.py:“Новых объектов”应该显示0或很小的数,详情页也不该重复请求。这证明去重生效。

可能的问题

database is locked错误。原因:数据库被其他程序打开,比如SQLite查看器,或者两个脚本实例同时运行。解决办法:关掉多余程序,别让脚本并行跑自己。

report.xlsx里所有对象日期都一样。原因:脚本只运行了一天。这很正常,等后面几次运行。

第7步:自动化运行,扩展到其他平台

本阶段目标:让采集器每天早上自动运行,并让代码准备好接入其他房产平台。价格历史只有规律采集才有价值,所以自动化是必须的。

定时运行

  1. Windows在开始菜单搜索打开任务计划程序。点创建基本任务。输入名称,比如“房产采集器”。
  2. 触发器选“每天”,设置时间,比如07:30。清晨方便:网站负载最低,上班前你就有新数据。
  3. 操作选“启动程序”。程序字段填venv文件夹里python.exe的完整路径,比如C:\projects\cian_parser\venv\Scripts\python.exe。参数字段填parser.py。起始位置填项目文件夹。
  4. 保存任务,点右侧面板的“运行”测试。项目文件夹里的数据库应该更新。
  5. macOS和Linux用cron。在终端输入crontab -e,加一行:30 7 * * * cd /путь/к/cian_parser && ./venv/bin/python parser.py >> run.log 2>&1。所有运行的日志会累积到run.log。

为其他平台做准备

Домклик、Яндекс Недвижимость、Метр квадратный和地区门户原理相似,但选择器和筛选参数各不相同。为了不每个平台重写采集器,把不同的部分抽成单独模块。

  1. 在项目里创建sites文件夹。里面建cian.py,把fetch_page和parse_cards以及搜索参数移过去。保持同样的接口:parse_cards接收HTML并返回带相同键offer_id、url、title、price、address的字典列表。
  2. 新平台建一个文件,比如domclick.py,重复第2步的研究:打开列表,找卡片、价格、链接和分页参数。写自己的fetch_page和parse_cards版本。
  3. 在offer_id里加平台前缀,比如cian_312456789和domclick_98765。否则不同网站的ID可能撞车,把历史搅混。
  4. 在parser.py里从sites导入模块,循环采集每个平台。数据库表通用:一套schema管所有来源,source列告诉你房源来自哪。

关于Домклик和Яндекс Недвижимость的重要提醒:这些平台积极使用Network标签里可见的JSON内部API。它们的列表往往比HTML更好采,但响应结构变化更频繁。每月检查一次选择器和字段。

建议:同一个房源挂在多个平台时,价格可能不同。比较这些配对能带来有趣的分析,帮你找到在某个地方降了价却忘了在别处更新的卖家。匹配房源可以靠地址、面积和楼层。

检查:计划任务手动运行无错误,run.log或计划程序历史里有成功记录。文件夹结构里有带至少一个模块的sites,采集器从项目根目录能像以前一样运行。

可能的问题

计划程序说任务完成,但数据库没更新。原因:脚本从另一个工作目录运行,在别处创建了新的空数据库。解决办法:填任务的起始位置字段,或者在config.py里用数据库的绝对路径。

结果检查:成品采集器清单

逐条过一遍,每项打勾。全部完成,你就有了一个完整的、带价格历史的ЦИАН采集器。

  • parser.py在激活的环境中运行,没有导入错误。
  • 列表从多页采集,每页对象数和浏览器里看到的一致。
  • 价格保存为整数,地址可读,链接能打开。
  • 详情页只对新房源请求,日志里“Новых объектов”在重复运行时数字变小。
  • 请求经过移动代理,check_proxy.py显示运营商地址,轮换会改变它。
  • 遇到429时采集器暂停并换IP,而不是崩溃。
  • realty.db文件增长,prices表每天有新行。
  • report.xlsx能生成并打开,几天后出现非零的价格变动。
  • 自动运行已配置,日志记录每次运行。
  • 数据库备份自动创建。

如何整体测试

  1. 删除或重命名realty.db,从干净状态开始。
  2. 把MAX_PAGES设为2,运行parser.py。计时:算上详情页大约6-8分钟。
  3. 运行report.py,确认报告里大约56行。
  4. 用SQLite查看器打开realty.db,或者在Python里执行SELECT COUNT(*) FROM prices。数字应该和对象数一致。
  5. 再跑一次parser.py。时间应该缩短到一分钟,因为不请求详情页。prices行数不变,因为日期相同。
  6. 在数据库里手动把某个房源的价格改成另一个数,把记录日期改成昨天,再运行采集器。report.xlsx里这个对象应该显示价格变动。这样不用等真实变化就能验证历史逻辑。

成功指标

面积字段填充率高于90%。200状态响应占比高于97%。一次运行没有一个未处理异常。完整运行时间可预测,不会一次比一次长。如果指标更低,回到常见错误部分。

常见错误与解决办法

我们收集了几乎所有第一次写房产信息采集器的人都会遇到的问题。格式:问题、原因、解决办法。

采集器返回0个对象,虽然昨天还能跑

原因:平台更新了页面结构,改了data-mark或data-name属性。解决办法:在浏览器打开列表,重复第2步,更新选择器。养成把选择器放在模块开头一处的习惯,改起来一分钟。在采集器里加检查:如果第一页0个对象,给自己发消息通知。

价格保存时差了一千倍

原因:部分对象价格以千为单位或标注按月,或者文本里混进了每平米价格。解决办法:确认取的是MainPrice而不是旁边每平米价格的元素。加合理性检查:莫斯科公寓售价低于一百万卢布几乎肯定是解析错误,把这种情况记日志。

第三页就收到429

原因:暂停太短,或者还没接代理,所有请求都从家里一个IP发。解决办法:把PAUSE_MIN和PAUSE_MAX加到6和12,接入移动代理,每20-25个请求开启计划轮换。检查没有同时跑多个脚本实例。

Windows控制台打印时UnicodeEncodeError

原因:Windows标准控制台不总能正确输出西里尔字母。解决办法:运行前在终端执行chcp 65001,或者在脚本开头加sys.stdout.reconfigure(encoding='utf-8')。也可以把日志写到文件而不是控制台。

地址拼得不全或有重复

原因:卡片上的地址由多个GeoLabel链接组成,其中一些重复了城市和区。解决办法:去重但保留顺序,或者从详情页取地址,那里是完整字符串。为了按区域分析,加一个district字段,按已知区域列表从地址里切出来。

手动运行正常,计划程序里不行

原因:计划程序用了另一个没装库的Python解释器或另一个工作目录。解决办法:填venv里python.exe的绝对路径,填好工作目录。把输出重定向到日志文件,方便看错误。

一个月后数据库有几个GB

原因:你把完整HTML页面或JSON所有字段存进了数据库。解决办法:只存需要的字段。想保存原始页面以便重新解析,就压成文件放磁盘,别放SQLite。每季度执行VACUUM压缩数据库。

同一个房源以不同ID重复出现

原因:卖家下架后重新上架,拿到新编号。解决办法:加一个由地址、面积、楼层组成的辅助匹配键。键相同但offer_id不同的对象可以在单独表里关联,按关联算价格历史。这已经是进阶分析,但它能揭示被重新发布掩盖的真实降价。

进阶玩法

基础采集器完成了。想要更多,下面是回报最高的方向。

用Playwright做浏览器自动化

有些页面在加载后才用脚本拉数据,requests拿到的是空壳。这种情况用Playwright:它控制真实浏览器。用pip install playwright和playwright install chromium安装。代理在启动浏览器时通过proxy参数传字典server、username、password。用page.wait_for_selector等卡片出现,把page.content()传给已经写好的parse_cards。注意浏览器消耗的资源是十倍,所以只对问题页面点对点使用。

用多个代理通道并行采集

要采多个地区,就每个地区买一个移动代理,每个通道跑一个独立进程。不要在单个通道里用多线程:分流的意义就没了。简单做法:地区参数作为命令行参数传给脚本,计划程序启动几个带不同参数、时间稍错开的任务。

降价通知

在采集器末尾加比较:每个对象今天价格和上次价格。如果降幅超过设定阈值,比如3%,生成一条含地址、旧价、新价和链接的消息,通过机器人发到即时通讯。这把采集器从分析工具变成行动工具:变更后一小时内你就知道有划算房源。

分析与可视化

用pandas你可以按区域分组,算每平米中位价、降价房源占比、平均挂牌时间(非活跃对象first_seen和last_seen之差)。matplotlib三行代码就能画出一个月动态图。把报告上传到Google Sheets,不会编程的同事也能看到实时指标面板。

存到PostgreSQL

对象超过十万时,SQLite在分析查询上会变慢。迁到PostgreSQL很简单:表结构一样,只换连接字符串和库(用psycopg2代替sqlite3)。只在真有需要时做:一个城市用SQLite够好几年。

采集器健康监控

把每次运行的启动时间、结束时间、采集对象数、错误数和IP轮换次数记到单独的runs表。如果对象数突然变少或错误率超过5%,发通知。这种监控让你在改版当天就发现,而不是两周后看着空报告才发现。

FAQ:房产采集器常见问题

爬ЦИАН和其他平台合法吗?

为个人分析采集公开的房源信息总体上是允许的,但每个平台的条件写在其用户协议里,需要阅读。绝对不可以采集卖家个人数据、把复制的数据库当自己的发布、制造影响服务运行的负载。如果打算商业使用数据,请咨询律师。

为什么用移动代理而不是服务器代理?

移动运营商地址被成千上万真实用户共用,而且不断变化。平台对它们比对数据中心地址更宽容,因为真实买家几乎不从数据中心上线。加上按链接换IP的能力,让你不用买几百个地址就能实现可控轮换。

价格历史多久跑一次?

每天一次最合适。房价变化慢,一天跑多次没意义,负载还增加。想快速捕捉降价,就早晚各一次,但只针对窄筛选。

一个代理一天能采多少对象?

暂停4-9秒加计划轮换,每小时大约500-700个请求没问题,全天候跑一天8-12千个。监控一个城市通常一天2-3千个请求就够,因为详情页只对新对象请求。

选择器变了找不到怎么办?

回到第2步,从价格入手:右键点价格、检查、往树上走到卡片。找带data和有意义名称的属性:它们比随机字符的类名稳定。也看看Network标签:可能数据改由单独的JSON请求来了,那样采起来反而更简单。

小项目可以不用代理吗?

一次性采两三页可以。每天几百个请求的监控,家庭地址很快会被限制,数据就不完整。有缺口的价历史会失去价值,所以规律运行需要代理。

怎么采租赁而不是出售?

把deal_type改成rent,在搜索参数里加租赁类型:长租或短租。房源链接会含rent而不是sale,所以更新parse_cards里的链接选择器。其他逻辑包括价格历史照常工作。

需要保存房源照片吗?

价格分析不需要。照片占地方,对计算没用。如果做内部目录,只存图片链接,别存文件本身。

怎么知道房源是卖掉了还是仅下架?

平台不说明下架原因。间接标志:房源从列表消失,一个月内没再出现。几天后带着不同价格重新出现的大多是重新发布。按错误部分说的用地址和面积关联它们。

需要十个城市的数据怎么办?

在config.py里列地区,循环采集,每两三个城市配一个代理通道。把启动时间错开,别同时采。数据库共用,在offers表加地区字段。

结语

回顾一下你做了什么。你准备了Python和库的环境,搞懂了房产平台列表的结构,学会了独立找选择器。写了采集列表和详情页的ЦИАН采集器。接入了带轮换和重试的移动代理,让采集稳定可预测。设计了带价格历史的数据库,配置了报告和定时运行。这是一个完整的工作工具,不是教学示例。

接下来怎么做:让采集器原样跑两周。这段时间会积累历史,你会看到薄弱点:哪里填充率下降、哪些对象重复、报告哪里需要新列。之后再加功能。然后按第7步的模块结构接入第二个平台:你会惊讶第二次快多少。

发展方向:降价通知把工具变成交易来源。按区域和房屋类型的分析让你手握数字成为市场专家。关联重新发布的房源能揭示网站上看不到的真实折扣。而对平台的温和态度,暂停、通过移动代理轮换地址、只采集需要的数据,能让工具数月无故障运行。

房产采集拼的不是速度,而是规律和数据质量。你打好了正确的基础。祝你采集顺利,数据库每天早上都增长。