上周一个朋友问我说他常跑的城市比较固定能不能把未来一周的天气整理成一张表出门前扫一眼就够。我说这需求听着就该交给爬虫来干。于是就有了这个项目用 Python 爬虫抓中国天气网的七日预报页面解析出全国城市的天气现象、温度、风力、日期最后统一写进一份 CSV。整个项目没有花哨框架requests 加 BeautifulSoup 就能跑代码量不大但对想入门爬虫的朋友来说这是一条非常完整的链路分析 URL 规律、构造请求、解析页面、清洗数据、落盘存储每一环都能学到东西。1. 先搞清楚数据从哪来页面结构与URL规律1.1 为什么选中中国天气网而不是一堆第三方天气API国内能拿到七日天气数据的渠道其实不少但第三方天气 API 大多需要注册账号、申请 key、签名鉴权有些还限制每天的调用次数。对于练手项目来说这层麻烦完全没必要。中国天气网是官方站点覆盖全国两千多个市县页面公开数据字段也足够完整——气温、天气现象、风向风力、日期都齐全。最重要的一点是它的七日预报数据是直接渲染在 HTML 页面里的不需要走复杂的 JavaScript 加密也不依赖登录态这意味着用 requests 就能把原始页面拿下来再用 BeautifulSoup 解析出结构化数据。不是说所有数据都要靠“硬爬”。如果目标站点提供 JSON 接口那当然优先用接口数据干净、路径短。中国天气网也有数据接口但它的 HTML 页面结构规整反而更适合做教学案例。你把一个真实页面的源码打开能直观看到“原来日期在 h1 里、温度在 p.tem 里”这种感知比直接调接口深刻得多。1.2 URL与城市ID的编码关系七日预报页面的 URL 长这样https://www.weather.com.cn/weather/101010100.shtml变化的核心是末尾那串九位数字也就是城市ID。这个 ID 不是乱编的它内部有区段逻辑101010100拆开看101是中国天气网产品的固定前缀01是省级编码01是市级编码00是区县级编码。也就是说101010100对应北京101020100对应上海101040100对应重庆规律很明确。记住这个规则对调试很有帮助。比如你抓下来一个城市ID是101280101如果只从数字本身看不出是哪你完全可以自己拼出 URL 去访问页面看看title里写的是什么城市。我在最早写这个项目时就是靠这种“反查法”验证了一大批城市ID。城市城市ID七日预报URL北京101010100https://www.weather.com.cn/weather/101010100.shtml上海101020100https://www.weather.com.cn/weather/101020100.shtml广州101280101https://www.weather.com.cn/weather/101280101.shtml深圳101280601https://www.weather.com.cn/weather/101280601.shtml成都101270101https://www.weather.com.cn/weather/101270101.shtml当你想扩展到全国城市时只需要把完整的“城市ID到城市名”映射表备好套用同一个 URL 模板就能对每个城市发起请求。1.3 先用浏览器开发者工具确认数据结构写爬虫最忌讳一上来就写代码。我习惯先按 F12 打开浏览器开发者工具切到 Network 面板刷新目标页面找到101010100.shtml这个文档请求看看响应内容。这一步能确认三件事数据是不是直接写在 HTML 里、页面编码是什么、7天数据到底藏在哪个 DOM 节点下。我最初打开响应时发现七日天气都在ul classt clearfix下面的li节点里每个 li 包含日期、天气现象、温度、风向结构非常干净。这意味着完全不需要 Selenium不需要模拟点击requests 就能搞定。如果你打开页面发现数据不在 HTML 里而是由一段 JavaScript 动态加载的那就得换思路了可能要去抓它背后的 JSON 接口。先看开发者工具能帮你省掉后面大量的排查时间。2. 城市ID表整个项目的基石2.1 先准备一份常用城市字典全国有两千多个市县没必要一开始就把所有城市ID都找齐。我建议先用一个字典把常用城市放进去跑通流程之后再逐步扩充。这样做的好处是你可以更快验证后面的解析逻辑有没有问题而不是被“找ID”这一步卡住。CITY_IDS { 北京: 101010100, 上海: 101020100, 广州: 101280101, 深圳: 101280601, 杭州: 101210101, 成都: 101270101, 武汉: 101200101, 西安: 101110101, 南京: 101190101, 重庆: 101040100, }实际项目中我建议把这个字典单独放到city_ids.py里不要和主逻辑混在一起。因为城市列表会持续更新单独维护起来更清晰。2.2 从城市导航页批量抓取ID固定字典终究不够。如果你想把这个脚本扩展成“全国七日天气预报”就得有一个能从页面自动提取城市ID的办法。思路不复杂找一页包含大量城市链接的导航页比如中国天气网的城市导航页面页面里会有不少形如hrefhttps://www.weather.com.cn/weather/101010100.shtml的链接。你用 requests 抓下这个页面再用正则把所有九位数字提取出来去重之后就是一份城市ID列表。import re import requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9, } def fetch_city_ids(nav_url: str) - list[str]: resp requests.get(nav_url, headersHEADERS, timeout10) resp.encoding utf-8 ids re.findall(r/weather/(\d{9}), resp.text) return list(dict.fromkeys(ids))这里的dict.fromkeys是个小技巧既能去重又能保持第一次出现的顺序。别小看这个细节如果你直接set()城市ID的顺序会被打乱后面输出 CSV 时城市排列就会很随机不利于人工核对。2.3 用页面标题反推城市名光有ID还不够CSV 里必须有一个读者认识的城市名。怎么拿到“ID到城市名”的映射我试过几种方案最省事的是请求该城市的天气页面直接解析title标签。中国天气网每个城市页面的标题格式都是“城市名 7天预报 站点名”比如“北京7天预报 - 中国天气网”。def get_city_name(city_id: str) - str: url fhttps://www.weather.com.cn/weather/{city_id}.shtml resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 title_match re.search(rtitle(.?)/title, resp.text) if not title_match: return city_id name_match re.search(r(.?)7天预报, title_match.group(1)) return name_match.group(1).strip() if name_match else city_id为什么不用页面正文里的城市名因为正文里到处可能是“周边景点”“同城天气”之类的文字容易抓错。title里的信息最干净几乎不需要二次清洗。3. 请求七日天气页头信息、超时与重试3.1 请求头伪装别让服务器一眼认出你是爬虫如果你直接requests.get(url)大概率会遇到两种结果一种是返回 403另一种是返回一个安全验证页。原因很简单很多站点会检查请求头里的 User-Agent 字段Python requests 的默认 UA 形如python-requests/2.31.0服务器一看就知道不是浏览器。所以第一步伪造一个浏览器的 User-Agent。我不建议用什么随机 UA 池对天气网这种站点来说一个稳定的 Chrome UA 就够了HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9, }Accept和Accept-Language是在模拟浏览器“我想要什么样的内容”。裸请求通常只带 UA不带这两个字段很多反爬策略会针对这一点做校验。把这三个头都带上基本就不会在请求头这一步被拦。3.2 编码处理与内容校验一个都不能少中国天气网页面是 UTF-8 编码但 requests 有时候会自动探测编码并判断错误导致拿回来的字符串变成乱码。最稳的做法是拿到响应后直接指定编码resp.encoding utf-8另外只判断status_code 200不够。有些反爬页面同样返回 200但内容是安全验证页不是一个正常的预报页面。我习惯再加一道内容校验检查页面里是否包含“7天预报”这个关键字if resp.status_code 200 and 7天预报 in resp.text: return resp.text这一步看似多余实际上能帮你规避很多“看似成功、实则失败”的情况。我踩过一次某个城市页面被改成了临时维护页状态码 200内容里全是维护公告结果解析出来的数据全部为空排查了半天才反应过来是内容校验没做好。3.3 重试与限速爬得快不如爬得稳网络请求总有意外超时、连接重置、临时 5xx 都可能出现。我写了一个带退避重试的请求函数第一次失败等 2 秒第二次失败等 4 秒最多试三次。这样既不浪费太多时间又能给目标站点一个“恢复正常”的缓冲。def fetch_html(city_id: str, retries: int 3) - str | None: url fhttps://www.weather.com.cn/weather/{city_id}.shtml for attempt in range(1, retries 1): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200 and 7天预报 in resp.text: resp.encoding utf-8 return resp.text except requests.RequestException: pass time.sleep(2 * attempt) return None同时在每个城市请求之间加上time.sleep(1.5)。一分钟也就抓 40 个城市全程跑完可能一分钟左右对目标站点完全构不成压力。爬虫不是越猛越好尤其这种公开数据源控制频率既是礼貌也是保护自己 IP 不被封的手段。4. 解析DOM把网页变成结构化数据4.1 定位七日天气节点中国天气网的七日预报页面上有一个主容器ul classt clearfix里面每个li代表一天。节点结构大致如下ul classt clearfix li h110日今天/h1 p classwea晴/p p classtemspan27/spani/ 17℃/i/p p classwinspan东北风/spani3级/i/p /li ... /ul用 BeautifulSoup 把它定位出来from bs4 import BeautifulSoup soup BeautifulSoup(html, html.parser) ul soup.find(ul, class_t clearfix) if not ul: return []要注意我取了前 7 个 li而不是全部。有些页面会在“7天”之外附带“8-15天趋势”之类的数据如果不加限制解析结果可能超过 7 条。干脆就写死for idx, li in enumerate(ul.find_all(li)[:7]): ...4.2 逐项提取日期、天气现象、温度、风力每个 li 里的信息提取逻辑很直接日期li.find(h1).get_text(stripTrue)得到“10日今天”天气现象li.find(p, class_wea).get_text(stripTrue)得到“晴”或“晴转多云”温度li.find(p, class_tem).get_text(stripTrue)得到类似“27℃ / 17℃”风li.find(p, class_win).get_text(stripTrue)得到“东北风3级”温度字段虽然能直接看到但它是字符串不方便日后做统计。我习惯用正则把数字抽出来拆分成高温和低温import re temp_text re.sub(r\s, , tem_text) nums re.findall(r-?\d, temp_text) high_temp nums[0] if nums else N/A low_temp nums[1] if len(nums) 1 else N/A为什么要处理负数北方冬天零下温度在页面上是“-5℃”正则会带上负号所以用了-?\d。4.3 用标准日期替代“今天”“明天”页面上的“10日今天”“11日明天”用于人读很方便但写进 CSV 之后很难按日期排序和筛选。我的做法是同时保留两个字段date_text存页面原始文本date存标准日期YYYY-MM-DD。标准日期可以用当天日期加偏移量推算from datetime import date, timedelta today date.today() for i in range(7): std_date (today timedelta(daysi)).isoformat()这样 CSV 里既有“10日今天”这种直观文本也有机器可计算的2025-06-10这种标准格式。实际做报表的时候你会感谢当初多写了这一行。4.4 解析容错不要因为一个坏节点全盘崩溃网页解析最怕“结构变了”或者“某个字段缺失”。我写解析函数时一定会做异常兜底一个 li 解析失败不能影响后面 6 个 li。try: day_text li.find(h1).get_text(stripTrue) wea li.find(p, class_wea).get_text(stripTrue) tem li.find(p, class_tem).get_text(stripTrue) win li.find(p, class_win).get_text(stripTrue) except AttributeError: continue如果某个 li 里少了p.temfind会返回None再调用.get_text就会抛AttributeError。这里捕获后跳过这个 li 即可。更极端的做法是把这条记录写成残缺数据“N/A”存进去但那样反而会让 CSV 里的脏数据变多我选择直接跳过并在日志里打印一下异常节点的 HTML 片段。5. 写进CSV字段设计、编码与更新策略5.1 数据字段怎么设计CSV 的第一行是表头字段名就是你对数据的“定义”。这次项目我用了这几个字段字段名示例说明city北京城市名date2025-06-10标准日期便于排序date_text10日今天页面原始日期文本weather晴天气现象high_temp27最高温度low_temp17最低温度wind东北风3级风向风力注意温度字段我存的是纯数字没有带℃单位。为什么不带因为带单位会造成 Excel 里无法直接做均值、极值计算。需要在展示层加单位在读数据时做格式化而不是在存储层混入单位。5.2 utf-8-sig编码与csv写入写 CSV 有两个常见的坑一个是 Excel 打开 UTF-8 文件乱码另一个是 Windows 下写入 CSV 会多出空行。第一个坑用utf-8-sig编码解决它会在文件开头写入一个 BOM 标记Excel 就能正确识别第二个坑用newline解决。import csv FIELD_NAMES [city, date, date_text, weather, high_temp, low_temp, wind] def save_csv(rows: list[dict], filename: str weather_7days.csv) - None: with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesFIELD_NAMES) writer.writeheader() writer.writerows(rows)DictWriter的好处是它会自动按照fieldnames的顺序写入不需要你手动处理逗号转义。如果某个字段缺失它会写一个空字符串不会导致整行错位。5.3 更新策略覆盖还是追加如果每天跑一次脚本你的目标就是“拿到未来 7 天”直接覆盖旧文件就好每次得到一份全量数据。如果你想把数据积累起来做趋势分析那就需要历史数据了。两种常见做法按日期命名文件weather_20250610.csv保留每天的快照追加模式打开文件用a模式但要注意只在首次写入时写表头避免每次追加表头我的建议是练手阶段用覆盖模式最简单逻辑清楚。等你想做历史气温统计了再切换成按日期命名文件的方式。别一上来就把复杂度拉满爬虫项目的乐趣在于一步步看到数据变多而不是一开始就想设计一个完美系统。6. 完整代码与运行效果6.1 完整代码把前面所有模块拼起来就是一个可以直接运行的脚本。我习惯把函数职责拆开请求、解析、存储各自独立这样以后想换存储格式或者改解析逻辑不用动其他部分。import csv import re import time from datetime import date, timedelta import requests from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9, } CITY_IDS { 北京: 101010100, 上海: 101020100, 广州: 101280101, 深圳: 101280601, 杭州: 101210101, 成都: 101270101, 武汉: 101200101, 西安: 101110101, 南京: 101190101, 重庆: 101040100, } FIELD_NAMES [city, date, date_text, weather, high_temp, low_temp, wind] def fetch_html(city_id: str, retries: int 3) - str | None: url fhttps://www.weather.com.cn/weather/{city_id}.shtml for attempt in range(1, retries 1): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200 and 7天预报 in resp.text: resp.encoding utf-8 return resp.text except requests.RequestException: pass time.sleep(2 * attempt) return None def parse_weather(html: str, city: str) - list[dict]: soup BeautifulSoup(html, html.parser) ul soup.find(ul, class_t clearfix) if not ul: return [] rows [] today date.today() for idx, li in enumerate(ul.find_all(li)[:7]): try: day_text li.find(h1).get_text(stripTrue) wea li.find(p, class_wea).get_text(stripTrue) tem li.find(p, class_tem).get_text(stripTrue) win li.find(p, class_win).get_text(stripTrue) except AttributeError: continue tem_clean re.sub(r\s, , tem) nums re.findall(r-?\d, tem_clean) high nums[0] if nums else N/A low nums[1] if len(nums) 1 else N/A rows.append({ city: city, date: (today timedelta(daysidx)).isoformat(), date_text: day_text, weather: wea, high_temp: high, low_temp: low, wind: win, }) return rows def save_csv(rows: list[dict], filename: str weather_7days.csv) - None: with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesFIELD_NAMES) writer.writeheader() writer.writerows(rows) def main() - None: all_rows [] for city, city_id in CITY_IDS.items(): html fetch_html(city_id) if html is None: print(f{city}: 获取失败跳过) continue rows parse_weather(html, city) all_rows.extend(rows) print(f{city}: 已获取 {len(rows)} 天预报) time.sleep(1.5) save_csv(all_rows) print(f保存完成共 {len(all_rows)} 条记录) if __name__ __main__: main()这个脚本没有做很花哨的事情但足够完整。拿到代码后把它复制到一个weather_spider.py文件先pip install requests beautifulsoup4再python weather_spider.py就能跑出结果。6.2 运行效果控制台输出北京: 已获取 7 天预报 上海: 已获取 7 天预报 广州: 已获取 7 天预报 ... 保存完成共 70 条记录生成的weather_7days.csv内容大致如下citydatedate_textweatherhigh_templow_tempwind北京2025-06-1010日今天晴2717东北风3级北京2025-06-1111日明天多云2918东南风2级北京2025-06-1212日后天小雨2415东风3级上海2025-06-1010日今天阴2520西北风2级上海2025-06-1111日明天小雨2319东北风3级这份 CSV 已经是干净、结构化、可用的数据了。你可以直接丢进 Excel 里看也可以写一个小脚本按城市或日期筛选。7. 实战中踩过的坑与扩展方向7.1 坑1中文乱码根源在两处我第一次跑完打开 CSV 发现中文全是“锟斤拷”这种东西气得差点把电脑摔了。后来排查发现是两个独立问题叠加一是 requests 请求时没有手动指定编码页面返回的 UTF-8 内容被 requests 猜成了其他编码二是写 CSV 时用了默认的utf-8Excel 在 Windows 上默认按 GBK 读取。解决起来就两行代码请求时resp.encoding utf-8写文件时encodingutf-8-sig。但如果你不知道这两个坑可能来回折腾一晚上。这也是为什么我在前面章节把编码问题单独拎出来讲。7.2 坑2403与限流别把爬虫写成攻击有段时间我把请求间隔从 1.5 秒改成 0.2 秒想着快点跑完结果跑到第 30 个城市时服务器开始返回 403页面也变成了安全验证页。说白了限速不只是礼貌问题更直接的后果是你自己的 IP 被封。被 403 之后只能等几分钟再继续反而浪费更多时间。现在的做法是每城市固定 sleep 1.5 秒请求失败时指数退避重试。不要开多线程并发去抓同一个站点尤其你只是抓天气数据完全没必要。批量爬取任何站点前也记得看一眼目标站的robots.txt遵守里面的约束这是爬虫的基本素质。7.3 坑3页面结构调整class名突然失效有一段时间中国天气网改版ul.t.clearfix改成了别的 class 名我的解析函数直接返回空列表。这不是代码逻辑问题而是目标页面的 DOM 结构变了。遇到这种情况最有效的排查方式是把当前页面 HTML 保存到本地搜索“晴”或“多云”这些关键词看最新结构长什么样然后针对性修改选择器。为了降低未来改版带来的影响我的建议是解析时尽量用“稳定特征”比如日期文本的h1、温度文本中的数字模式而不是把所有希望押在某一层class上。同时把ul查找失败的情况完整打印出来这样就算改版你也能第一时间知道而不是看着空结果干瞪眼。7.4 扩展方向从爬虫到天气服务这个项目跑通之后可扩展的方向很多。如果单纯想用可以把脚本挂到服务器上配一个 cron 定时任务每天早上八点跑一次或者用 Windows 任务计划设置每天定时执行生成的 CSV 自动同步到网盘或飞书文档周末出门前打开手机看表格就够了。如果想让数据更好用可以把 CSV 换成 SQLite支持按城市、按日期范围查询也可以给脚本加一个命令行参数比如python weather.py 北京,上海,广州只查指定城市。再进阶一点用 Flask 包一个轻量 HTTP 接口前端用 ECharts 画气温折线图这就从“爬虫”升级成了“数据产品”。另外中国天气网除了七日预报还有生活指数、空气质量、预警信息等页面。同一个城市ID可以直接复用只需要换一下 URL 和解析规则字段就能从“温度风力”扩展到“穿衣指数”“洗车指数”“紫外线指数”。爬虫项目最迷人的地方就在这里方法论一旦建立换数据源只是换选择器的事。我自己现在还在用这个项目生成周末出行表。每次想加一个新城市只需要在CITY_IDS字典里加一行再跑一次脚本新城市的数据就自动出现在 CSV 里了。偶尔遇到页面结构小调整就打开开发者工具看几眼改个选择器顺手又是一版。这套“分析URL、发送请求、解析页面、落盘存储”的流程后来我移植到商品价格监控和行业资讯聚合上基本就是改改 URL 和字段名的事。对想练手爬虫的人来说天气项目是一个非常好的起点——足够简单但又覆盖了爬虫最核心的每一步。