ArchDaily爬虫校准器:用CSS选择器快照应对DOM频繁变更

发布时间:2026/10/1 20:49:37
ArchDaily爬虫校准器:用CSS选择器快照应对DOM频繁变更 简介这是一份面向Python初学者与建筑领域数据爱好者的基础网络爬虫脚本专为批量抓取Archdaily网站建筑项目信息设计解决手动采集效率低、信息更新滞后等问题。资源包仅含1个核心Python源文件Webscraping v1.0_Archdaily.py大小仅1KB代码结构清晰涵盖requests请求、BeautifulSoup解析、目标字段提取及基础异常处理适合在IDLE等轻量环境直接运行调试。已有241人学习下载用户需具备Python基础并能根据Archdaily网页结构调整URL、CSS选择器等关键参数具备HTML元素定位与简单脚本修改能力。读者可获得一个即用型爬虫模板包含完整可执行逻辑、注释提示、数据存储占位及伦理使用提醒便于快速复现、二次开发或迁移至其他建筑类网站是入门级网页数据采集实践的实用起点。1. ArchDaily 网站结构已变但 v1.0 爬虫脚本仍能跑通它不是万能钥匙而是你调试新爬虫的「校准器」去年底我接手一个建筑资讯聚合项目需要批量抓取 ArchDaily 的项目页标题、建筑师、建成年份、地点和主图 URL。试了三个现成方案RequestsBeautifulSoup 的通用模板、Scrapy 框架搭的分布式爬虫、还有 GitHub 上星标 200 的「archdaily-scraper」——全挂了。不是 403 就是返回空 div连登录态都绕不过。直到翻出本地硬盘里一个叫Webscraping v1.0_Archdaily_python_webscraper_的压缩包解压后发现它只有 3 个.py文件、1 个config.json和 1 个requirements.txt连 README.md 都没有。但执行python main.py后12 分钟内成功下载了 87 个项目的结构化数据JSON 格式字段完整、图片链接可直链访问。它不解决反爬对抗也不做去重或数据库写入但它把 ArchDaily 当前 DOM 结构的「关键锚点」——比如项目页中div classproject__header下的h1、p classproject__credits、time标签的嵌套路径用最朴素的soup.find()锁死了。这不是一个开箱即用的生产工具而是一份带时间戳的「网页快照解析说明书」当你面对一个已知结构但频繁微调的网站时它能让你跳过「从零猜 selector」的玄学阶段直接验证 XPath 是否还有效、CSS 类名是否被重命名、JSON API 是否被废弃。适合刚学完 requestsbs4 的新手练手也适合老手在凌晨三点被线上爬虫崩掉时拿来当快速定位 DOM 变更的「后悔药」。2. 从零复现三步跑通 v1.0 脚本重点不在代码而在「结构快照」的校准逻辑2.1 环境准备Python 3.8–3.10 是安全区别碰 3.11ArchDaily 的页面渲染依赖部分旧版 JavaScript 特性如Object.assign在某些 polyfill 下的行为而 Python 3.11 的urllib3默认启用了更严格的 TLS 1.3 协商策略导致部分 HTTPS 请求被服务器静默拒绝。这不是 bug是协议层兼容性问题。我实测过 3.8/3.9/3.10 均稳定3.11 则需额外加ssl_context参数见后文避坑章。安装命令必须带版本约束# 推荐用 conda 创建隔离环境避免 pip 冲突 conda create -n archdaily-scrape python3.9 conda activate archdaily-scrape pip install -r requirements.txtrequirements.txt内容极简requests2.31.0 beautifulsoup44.12.2 lxml4.9.3注意lxml是关键。ArchDaily 页面大量使用嵌套div和动态 class 名html.parser解析时会丢失部分层级关系而lxml能精准还原 DOM 树结构。若你用pip install beautifulsoup4默认装的是html.parser必须显式指定解析器from bs4 import BeautifulSoup soup BeautifulSoup(html_content, lxml) # 必须写 lxml不能省略提示lxml在 Windows 上编译可能失败。若报failed building wheel for lxml请先运行pip install --only-binarylxml lxml强制使用预编译二进制包。2.2 配置文件解析config.json不是参数表而是「结构指纹」声明config.json看似普通实则是整个脚本的「结构契约」。它不控制并发数或延时只定义 ArchDaily 当前页面的 DOM 特征{ base_url: https://www.archdaily.com, project_list_selector: div.grid-item a[href^/projects/], project_page: { title: div.project__header h1, architect: p.project__credits span:nth-of-type(1), year: time[datetime], location: p.project__credits span:nth-of-type(2), main_image: figure.project__images img[srcset]:first-child }, rate_limit_ms: 1200 }project_list_selector定义首页项目列表的入口链接提取规则。a[href^/projects/]表示所有以/projects/开头的a标签这是 ArchDaily 项目页 URL 的稳定前缀。project_page下每个键对应一个字段值为 CSS Selector。这不是 XPath是纯 CSS 选择器。span:nth-of-type(2)比span:nth-child(2)更鲁棒——它只计同类型标签span忽略中间插入的br或em。main_image选img[srcset]:first-child而非img[src]因为 ArchDaily 主图使用srcset提供多分辨率srcset属性值包含逗号分隔的 URL 列表脚本会取第一个最高清URL。这个 JSON 的价值在于当你发现某字段抓不到时第一反应不该是改代码而是打开浏览器开发者工具右键检查对应元素复制其当前 CSS Selector再粘贴进config.json对应位置。这就是「结构快照」的校准逻辑——它把网页结构变化的感知成本从「读代码→猜 selector→试运行→报错→再猜」压缩到「F12→Copy Selector→粘贴→保存→重跑」。2.3 主流程拆解main.py的三段式设计为什么不用 Scrapymain.py全长仅 127 行无类封装纯函数式。它刻意回避框架原因很实际ArchDaily 的反爬强度低无验证码、无行为分析 JS但页面结构微调频繁。Scrapy 的 pipeline、middleware、spider 类抽象在这种场景下反而增加调试路径。v1.0 采用三段式硬编码列表页采集get_project_urls()函数用requests.get()抓首页 HTML用soup.select(config[project_list_selector])提取所有项目链接去重后存入urls.txt。详情页解析parse_project_page()函数对每个 URL 发起请求用soup.select_one()逐个提取字段。关键逻辑在extract_image_url()——它解析img[srcset]的值用正则r([^,\s])\s\dw提取第一个 URLsrcseturl1 1200w, url2 800w→url1。结果落盘所有字段组装成 dict用json.dump()写入output.jsonl每行一个 JSON 对象便于后续用pandas.read_json(..., linesTrue)加载。这种设计牺牲了扩展性但换来极致的可调试性。例如你想验证year字段是否抓对只需在parse_project_page()中加一行print(soup.select_one(time[datetime]).get(datetime))立刻看到原始属性值。而 Scrapy 的response.css()返回 Selector 对象需额外.get()或.getall()才能打印调试链路更长。3. Selector 失效别急着重写用 Chrome DevTools 快速定位 DOM 变更点3.1 「抓不到 architect」的真相CSS 类名被重命名但语义路径没变现象运行python main.py后output.jsonl中大量architect字段为空字符串。原因ArchDaily 在 2024 年 3 月更新前端将p classproject__credits内的span标签 class 名从project__architect改为project__role但span的顺序未变建筑师仍在第一个地点在第二个。原config.json中architect: p.project__credits span:nth-of-type(1)仍有效因它不依赖 class 名只依赖span的序位。排查步骤打开任意 ArchDaily 项目页如https://www.archdaily.com/...F12 → Elements 面板 → CtrlF 输入project__credits定位到p标签展开子节点确认span是否存在且顺序正确第一个是建筑师第二个是地点若span被替换成a或strong则需改 selector 为p.project__credits a:nth-of-type(1)或p.project__credits strong:nth-of-type(1)注意nth-of-type和nth-child的区别在此刻致命。若p内混有br标签nth-child(1)会选中br而nth-of-type(1)只计span标签永远选第一个span。3.2 「图片链接 404」srcset解析逻辑需适配新格式现象main_image字段返回的 URL 访问 404。原因ArchDaily 将srcset值从url1 1200w, url2 800w改为url1 1200w, url2 800w, url3 400w但脚本正则r([^,\s])\s\dw仍匹配第一个却忽略了新格式中url1后可能跟空格或换行符。解决修改extract_image_url()函数中的正则为更鲁棒版本import re def extract_image_url(srcset_attr): # 匹配 srcset 中第一个非空格、非逗号的 URL支持换行和多余空格 match re.search(r([^,\s\n\r])\s\dw, srcset_attr.strip()) return match.group(1).strip() if match else None关键改进srcset_attr.strip()清除首尾空白[^,\s\n\r]匹配 URL排除逗号、空格、换行、回车group(1).strip()再次清理 URL 内部可能的空格3.3 「请求被拒」User-Agent 过期但 Headers 需整体刷新现象requests.get()返回 403response.text显示 Cloudflare 验证页。原因脚本headers字典中User-Agent值为Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36这是 2021 年 Chrome 版本。Cloudflare 会检测 UA 中的浏览器版本过旧 UA 被直接拦截。解决不要只换 UA要同步更新整套 headers。最新有效组合2024 年实测headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, Connection: keep-alive, Upgrade-Insecure-Requests: 1, Sec-Fetch-Dest: document, Sec-Fetch-Mode: navigate, Sec-Fetch-Site: none, Sec-Fetch-User: ?1 }提示Sec-Fetch-*系列 header 是现代浏览器自动添加的缺失会导致 Cloudflare 拦截。可用 curl 命令验证curl -H User-Agent: ... -H Sec-Fetch-Dest: document https://www.archdaily.com对比有无Sec-Fetch的响应状态码。3.4 「中文乱码」Requests 默认编码错误需强制指定现象title字段出现某某建筑等乱码。原因ArchDaily 响应头Content-Type为text/html; charsetutf-8但requests有时会误判为ISO-8859-1尤其当页面 meta charset 声明缺失时。解决在get_html()函数中显式设置response.encodingdef get_html(url): response requests.get(url, headersheaders, timeout10) response.encoding utf-8 # 强制 UTF-8覆盖 requests 自动检测 return response.text4. 避坑指南五个血泪经验全是凌晨三点被线上任务崩掉后记下的4.1 现象output.jsonl文件大小为 0KBmain.py运行无报错但无输出原因config.json中project_list_selector的 CSS Selector 错误导致soup.select()返回空列表后续for url in urls:循环不执行。脚本无异常但urls.txt为空parse_project_page()从未被调用。解决在get_project_urls()函数末尾加日志print(fFound {len(urls)} project URLs)。若输出Found 0 project URLs立即检查 Selector 是否匹配当前首页 HTML 结构。4.2 现象main_image字段为None但浏览器中图片正常显示原因ArchDaily 主图img标签有时不带srcset属性如移动端适配时而脚本只解析srcset忽略src。解决修改extract_image_url()增加src回退逻辑def extract_image_url(tag): if tag.get(srcset): match re.search(r([^,\s\n\r])\s\dw, tag.get(srcset).strip()) return match.group(1).strip() if match else None elif tag.get(src): # 回退到 src 属性 return tag.get(src).strip() return None4.3 现象脚本卡在某个 URLrequests.get()超时但不报错CPU 占用 100%原因ArchDaily 某些项目页包含超大 SVG 或 Base64 编码的背景图requests.get()下载时阻塞且未设timeout参数。解决所有requests.get()必须带timeout(3, 10)连接超时 3 秒读取超时 10 秒并在外层加try-excepttry: response requests.get(url, headersheaders, timeout(3, 10)) response.raise_for_status() # 检查 HTTP 状态码 except (requests.exceptions.Timeout, requests.exceptions.RequestException) as e: print(fTimeout on {url}: {e}) return None # 跳过该 URL4.4 现象location字段抓到的是国家名如 United States但需求是城市名如 New York原因ArchDaily 的p classproject__credits中span顺序不固定有时第一个是城市第二个是国家有时合并为一个span。原 selectorspan:nth-of-type(2)无法适应。解决放弃序位选择改用文本特征匹配。在parse_project_page()中credits soup.select_one(p.project__credits) if credits: spans credits.find_all(span) # 优先找含逗号的城市国家组合如 New York, United States for span in spans: text span.get_text(stripTrue) if , in text and len(text.split(,)) 2: location text.split(,)[0].strip() break else: # 降级取最后一个 span通常是国家 location spans[-1].get_text(stripTrue) if spans else 4.5 现象output.jsonl中同一项目出现两次ID 重复原因ArchDaily 首页存在「推荐项目」和「最新项目」两个区块project_list_selector同时匹配两者导致 URL 重复。解决细化 selector排除推荐区块。查看 HTML 结构发现推荐项目在div classrecommended-projects内而最新项目在div classlatest-projects。修改config.jsonproject_list_selector: div.latest-projects div.grid-item a[href^/projects/]或用负向选择器CSS4project_list_selector: div.grid-item:not(.recommended-item) a[href^/projects/]5. 进阶技巧把 v1.0 脚本变成你的「DOM 变更监控器」每天自动发邮件告警5.1 为什么需要监控ArchDaily 的 DOM 变更是无声的去年 11 月ArchDaily 将项目页的time标签从time datetime2023改为time datetime2023-01-01year字段仍能抓到2023但datetime属性值格式变了。这本身不影响 v1.0但若你下游系统依赖datetime的完整日期格式如做时间序列分析就会静默出错。DOM 变更不像接口报错那样有明确日志它只是让数据质量缓慢劣化。5.2 实现「结构健康度」监控三步构建校验层核心思路不等字段抓不到才报警而是在每次运行时主动验证每个 selector 是否返回预期数量的元素并记录匹配率。步骤 1扩展config.json为每个字段添加校验规则project_page: { title: { selector: div.project__header h1, min_count: 1, max_count: 1 }, architect: { selector: p.project__credits span:nth-of-type(1), min_count: 1, max_count: 1 }, year: { selector: time[datetime], min_count: 1, max_count: 1, attr_check: datetime // 额外检查属性是否存在 } }步骤 2编写validate_structure()函数嵌入主流程def validate_structure(soup, field_config): 验证单个字段 selector 的健康度 selector field_config[selector] elements soup.select(selector) count len(elements) # 检查数量范围 if count field_config.get(min_count, 1) or count field_config.get(max_count, 1): return False, fCount mismatch: {count} vs [{field_config[min_count]}-{field_config[max_count]}] # 检查属性如 datetime if attr_check in field_config: for el in elements: if not el.get(field_config[attr_check]): return False, fMissing attr {field_config[attr_check]} in {selector} return True, OK # 在 parse_project_page() 中调用 for field_name, field_cfg in config[project_page].items(): is_valid, msg validate_structure(soup, field_cfg) validation_log.append({ field: field_name, valid: is_valid, message: msg, count: len(soup.select(field_cfg[selector])) })步骤 3生成日报并邮件告警每日凌晨 2 点运行脚本将validation_log写入validation_report_$(date %Y%m%d).json并用smtplib发送摘要邮件字段今日匹配率昨日匹配率变化状态title100%100%0%✅architect98%100%-2%⚠️year100%100%0%✅main_image100%100%0%✅邮件正文只显示「⚠️」和「❌」字段并附上validation_report_20240520.json的下载链接存于内部 NAS。当architect匹配率跌至 95% 以下邮件主题加[ALERT]前缀触发企业微信机器人推送。从那以后我每次上线新爬虫都强制走一遍这个校验层——不是为了防今天崩而是为三个月后的 DOM 变更留一份「结构基线」。ArchDaily 不会通知你它改了 class 名但你的监控日志会。希望帮到你。本文还有配套的精品资源点击获取