
1. 项目概述从数据源头理解营养计算最近在做一个个人健康管理的小工具核心需求是根据每日饮食自动估算热量摄入。市面上虽然有现成的App但要么数据封闭要么不符合我的饮食习惯。于是我把目光投向了“薄荷健康网”它拥有一个相对全面且国人常用的食物营养数据库。我的目标很明确把这个数据库“搬”到本地建立一个属于自己的、可自由查询和计算的食物热量库。这个项目本质上是一个定向的网络数据采集也就是常说的“爬虫”。但它的价值远不止于技术练习。对于营养师、健身爱好者、健康类应用开发者或者任何想量化自己饮食的人来说拥有一份结构清晰、字段完整的本地食物数据意味着可以脱离网络依赖进行离线计算可以基于此开发个性化应用甚至进行更深度的数据分析比如分析某类食物的平均热量分布。整个过程涉及对目标网站结构的分析、请求策略的制定、数据清洗与存储是学习Python爬虫和数据处理的绝佳实战案例。2. 目标网站分析与爬虫策略设计动手之前必须先摸清“敌情”。盲目请求只会导致IP被封或拿到一堆无法解析的乱码。我的策略是像用户一样浏览网站同时用开发者工具观察背后的网络活动。2.1 网站结构与数据入口探查首先访问薄荷健康网的食物库页面。通过观察我发现其食物数据主要通过两种方式呈现分类浏览页网站将食物分为“菜肴”、“蔬菜”、“水果”等大类每个大类下又有细分。例如“蔬菜”下分“叶菜类”、“瓜茄类”等。这些分类页面是获取食物清单的关键入口。搜索与详情页通过搜索或点击分类中的具体食物会进入该食物的详情页这里包含了热量、蛋白质、脂肪、碳水化合物等核心营养数据。关键问题在于数据是直接渲染在HTML中还是通过异步接口Ajax加载的打开浏览器的开发者工具F12切换到“网络Network”选项卡然后浏览几个分类并点击几个食物。我立刻发现分类列表和食物详情的数据都是通过访问特定的API接口返回的JSON格式数据而非传统的服务端渲染。这是一个非常重要的发现它决定了我们的爬虫策略——我们不需要去解析复杂的HTML而是直接模拟浏览器对这些JSON接口的请求。2.2 请求分析与参数破解找到一个获取食物分类的请求其URL形如https://www.boohee.com/food/search?keywordpage1。观察其请求头Headers有几个关键点User-Agent标识客户端身份。必须设置成一个常见的浏览器UA否则服务器可能拒绝响应。Referer表示请求来源页面。对于API请求通常需要正确设置模拟从网站页面发起的请求。Cookie可能包含会话信息。虽然初始抓取分类和列表可能不需要但在高频访问或需要维持状态时很重要。翻看响应体Response是结构清晰的JSON。例如分类数据可能包含category_id,category_name食物列表数据包含food_id,food_name,food_calory热量以及指向详情页的food_url。我们的任务就是解析这些JSON提取出目标字段。注意不同网站的接口设计差异很大。薄荷健康网的接口可能带有时间戳、签名等防爬参数。你需要仔细对比多个请求查看Query String Parameters或Form Data找出规律。有时关键的page参数或category_id参数就藏在里面。2.3 反爬机制考量与应对策略商业网站通常有基础的反爬措施频率限制如果请求太快会收到429状态码或被临时封禁。解决方案是在请求间加入随机延时例如time.sleep(random.uniform(1, 3))。User-Agent检测使用单一的UA容易被识别。可以准备一个UA列表每次请求随机选取。IP封禁这是最严厉的措施。对于个人小规模爬取使用随机延时通常足够。如果数据量极大需要考虑使用代理IP池但这超出了本基础项目的范围。我们的策略是友好、低频、模拟真人。明确我们只采集公开数据用于个人学习不进行恶意攻击或商业滥用。3. 核心工具选型与环境搭建工欲善其事必先利其器。选择一套高效、稳定的工具链能让开发过程事半功倍。3.1 Python库的选择与职责requests这是HTTP请求库的绝对首选。它简单易用功能强大足以应对本项目99%的请求场景。我们将用它来发送GET/POST请求获取API返回的JSON数据。pip install requestsBeautifulSoup4虽然我们的主要数据来自JSON接口但网站的入口如分类导航可能仍然在初始HTML中。BeautifulSoup可以帮助我们解析HTML提取出分类的链接或ID作为爬取的起点。它是一个灵活的HTML/XML解析库。pip install beautifulsoup4pandas数据清洗、处理和存储的瑞士军刀。我们将把爬取到的数据列表、字典形式转化为DataFrame这样可以方便地进行去重、缺失值处理并最终导出为CSV或Excel文件。pip install pandassqlite3(Python内置)如果希望建立一个小型数据库进行持久化存储和查询SQLite是轻量级首选。它无需安装额外服务单个文件即数据库非常适合本地管理。time random(Python内置)用于实现请求间隔的随机延时模拟人类操作行为规避反爬。3.2 开发环境配置要点我推荐使用VSCode或PyCharm作为IDE。关键是要配置好Python解释器环境。建议为这个项目创建一个独立的虚拟环境venv避免库版本冲突。# 在项目目录下 python -m venv venv # 激活虚拟环境 # Windows: venv\Scripts\activate # macOS/Linux: source venv/bin/activate然后在激活的虚拟环境中使用pip安装上述库。4. 分步爬取实现与代码详解接下来我们将把策略转化为代码。整个过程分为三个主要阶段获取所有食物分类、遍历分类获取食物列表、抓取食物详情数据。4.1 第一步获取全站食物分类结构首先我们需要知道要爬哪些类别的食物。观察网站分类信息可能通过一个独立的API接口返回也可能需要从首页或食物库首页的HTML中解析。 假设我们分析发现分类数据可以通过APIhttps://www.boohee.com/food/group获取。import requests import pandas as pd from bs4 import BeautifulSoup import time import random # 配置请求头模拟浏览器 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, Referer: https://www.boohee.com/food/ } def get_food_categories(): 获取食物分类列表 category_url https://www.boohee.com/food/group # 假设的接口需根据实际情况替换 try: resp requests.get(category_url, headersheaders, timeout10) resp.raise_for_status() # 如果状态码不是200抛出异常 # 假设返回的是JSON data resp.json() # 解析JSON提取分类ID和名称。这里结构是假设的需要根据实际响应调整 categories [] for group in data.get(data, []): cat_id group.get(id) cat_name group.get(name) if cat_id and cat_name: categories.append({category_id: cat_id, category_name: cat_name}) print(f成功获取 {len(categories)} 个食物分类) return categories except requests.exceptions.RequestException as e: print(f获取分类失败: {e}) return [] except ValueError as e: print(f解析JSON失败: {e}) # 可能是HTML页面尝试用BeautifulSoup解析 # soup BeautifulSoup(resp.text, html.parser) # ... 解析HTML逻辑 ... return [] # 执行获取分类 all_categories get_food_categories()实操心得resp.raise_for_status()是一个好习惯它能立即捕获404、500等错误避免程序在错误数据上继续运行。实际接口地址和JSON结构需要你通过开发者工具具体分析这里的代码是一个逻辑模板。4.2 第二步遍历分类抓取食物列表拿到分类ID后我们需要遍历每个分类分页获取该分类下的所有食物。通常列表接口会包含食物名称、基础热量和详情页链接或食物ID。def get_food_list_by_category(category_id, category_name, max_pages10): 根据分类ID获取食物列表分页 foods [] base_list_url https://www.boohee.com/food/search for page in range(1, max_pages 1): # 构造请求参数通常包含分类ID、页码、每页大小 params { kind: group, # 参数名需根据实际情况调整 value: category_id, page: page, per: 50 # 每页条数 } try: print(f正在抓取分类【{category_name}】第 {page} 页...) resp requests.get(base_list_url, headersheaders, paramsparams, timeout10) resp.raise_for_status() list_data resp.json() # 解析食物列表数据 items list_data.get(data, {}).get(foods, []) if not items: # 如果当前页没有数据说明已抓取完 print(f分类【{category_name}】第 {page} 页无数据停止抓取。) break for item in items: food_info { category_id: category_id, category_name: category_name, food_id: item.get(id), food_name: item.get(name, ).strip(), food_calory: item.get(calory), # 注意字段名可能是calory或heat food_url: fhttps://www.boohee.com{item.get(url)} if item.get(url) else # 拼接完整URL } # 只添加必要字段存在的记录 if food_info[food_id] and food_info[food_name]: foods.append(food_info) # 随机延时避免请求过快 time.sleep(random.uniform(1.5, 3.5)) # 检查是否还有下一页根据实际接口返回判断 # 例如if page list_data.get(total_pages, 1): break except Exception as e: print(f抓取分类【{category_name}】第 {page} 页时出错: {e}) break print(f分类【{category_name}】抓取完成共 {len(foods)} 条食物记录。) return foods # 遍历所有分类进行抓取 all_foods [] for cat in all_categories: food_list get_food_list_by_category(cat[category_id], cat[category_name]) all_foods.extend(food_list) # 分类间也增加延时 time.sleep(random.uniform(2, 4)) print(f列表抓取阶段结束总计获取 {len(all_foods)} 条食物列表记录。)4.3 第三步获取食物详情补充精确热量数据列表中的热量数据有时是粗略值如每100克可食部而详情页可能有更精确的数据如每份热量。此外详情页还包含蛋白质、脂肪等更多营养信息。我们可以根据food_url或food_id去抓取详情。def get_food_detail(food_url): 根据食物详情页URL抓取更详细的营养信息 if not food_url: return {} try: resp requests.get(food_url, headersheaders, timeout10) resp.raise_for_status() # 详情页数据可能也是通过内嵌的JS或API加载需要具体分析。 # 这里假设我们需要从HTML中解析一些额外信息。 soup BeautifulSoup(resp.text, html.parser) detail_info {} # 示例尝试从页面中寻找更详细的热量数据 # 假设热量在一个class为‘calory’的span标签里 calory_elem soup.find(span, class_calory) if calory_elem: detail_info[detail_calory] calory_elem.text.strip() # 可以继续解析其他营养元素如蛋白质、脂肪等 # protein_elem soup.find(li, text蛋白质) # ... time.sleep(random.uniform(2, 5)) # 详情页请求间隔更长一些 return detail_info except Exception as e: print(f抓取详情页 {food_url} 失败: {e}) return {} # 遍历食物列表补充详情这里可以抽样或全量抓取全量需谨慎 detailed_foods [] for idx, food in enumerate(all_foods[:50]): # 示例只处理前50条进行测试 print(f正在抓取详情 ({idx1}/50): {food[food_name]}) detail get_food_detail(food[food_url]) food.update(detail) # 将详情信息合并到原食物字典中 detailed_foods.append(food) print(详情抓取阶段结束。)4.4 第四步数据清洗、去重与存储爬取到的原始数据往往存在重复、格式不一致、缺失值等问题必须经过清洗才能使用。def clean_and_save_data(food_list, filenameboohee_foods.csv): 清洗数据并保存到CSV文件 df pd.DataFrame(food_list) if df.empty: print(数据为空无法保存。) return # 1. 去重基于食物ID或名称去重 df.drop_duplicates(subset[food_id], keepfirst, inplaceTrue) print(f去重后剩余 {len(df)} 条记录。) # 2. 处理缺失值对于关键字段如热量可以填充或标记 # 例如将热量为空的记录标记出来 df[calory_missing] df[food_calory].isna() # 或者用列表中的平均值填充需谨慎可能不准 # avg_calory df[food_calory].mean() # df[food_calory].fillna(avg_calory, inplaceTrue) # 3. 格式化确保热量是数值类型 df[food_calory] pd.to_numeric(df[food_calory], errorscoerce) # 无法转换的变为NaN # 4. 列排序便于阅读 columns_order [category_id, category_name, food_id, food_name, food_calory, food_url, detail_calory] # 只保留DataFrame中实际存在的列 existing_cols [col for col in columns_order if col in df.columns] df df[existing_cols] # 保存到CSV df.to_csv(filename, indexFalse, encodingutf-8-sig) # utf-8-sig支持Excel直接打开不乱码 print(f数据已清洗并保存至 {filename}) # 可选保存到SQLite数据库 # import sqlite3 # conn sqlite3.connect(food_data.db) # df.to_sql(foods, conn, if_existsreplace, indexFalse) # conn.close() # print(数据已保存至SQLite数据库。) return df # 执行清洗与保存 cleaned_df clean_and_save_data(detailed_foods)5. 关键问题排查与实战经验在实际操作中你几乎一定会遇到下面这些问题。这里记录了我的排查思路和解决方法。5.1 请求被拒绝或返回异常数据现象收到403 Forbidden、429 Too Many Requests或者返回的HTML内容是反爬提示如“请验证”。排查检查请求头确保User-Agent是有效的浏览器标识。可以尝试从浏览器开发者工具中直接复制一个。检查Cookie和Session有些页面需要维持登录状态或初始会话。你可以先用浏览器正常访问一次网站然后从开发者工具中复制Cookie字符串添加到headers中。对于更复杂的情况可以使用requests.Session()对象来保持会话。降低请求频率这是最有效的方法。大幅增加time.sleep()的间隔时间并在循环中加入随机性如random.uniform(3, 8)。验证Referer确保Referer字段的值是合理的上一个页面URL。5.2 解析不到数据或JSON解析错误现象resp.json()抛出JSONDecodeError或者解析后找不到预期的字段。排查先打印响应文本在尝试解析JSON之前先print(resp.text[:500])看看服务器到底返回了什么。可能是错误信息、验证页面或者是HTML。检查接口是否变更网站更新可能导致API地址或参数变化。重新用开发者工具分析最新的请求。仔细比对字段路径JSON的结构可能很嵌套。使用print(json.dumps(data, indent2, ensure_asciiFalse))来美化打印整个JSON仔细找到目标数据所在的准确路径。5.3 数据缺失或不完整现象爬取到的食物数量远少于网站显示的数量或者某些字段大量为空。排查分页逻辑错误检查“停止抓取”的条件。网站可能用不同的字段表示总页数或无更多数据如has_next: false。确保你的循环能抓取所有页。分类覆盖不全确认第一步获取的分类列表是完整的。有些分类可能隐藏在更深的层级或通过不同的接口获取。网络波动导致丢包在请求循环中加入重试机制。可以使用tenacity库或自己写一个简单的重试循环。import requests from tenacity import retry, stop_after_attempt, wait_random retry(stopstop_after_attempt(3), waitwait_random(min1, max3)) def safe_request(url, **kwargs): resp requests.get(url, **kwargs) resp.raise_for_status() return resp5.4 数据存储与性能优化现象爬取几万条数据时程序变慢甚至内存不足。建议增量存储不要等所有数据爬完再一次性存入DataFrame和CSV。可以每抓取100条或一个分类就追加写入到CSV文件中。import csv # 第一次写入时写入表头 with open(foods.csv, a, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames) if file_is_empty: writer.writeheader() writer.writerow(food_dict)分批处理详情页详情页请求是主要的时间消耗点。可以先快速爬完所有列表数据含基础热量后续再根据需要分批、分时爬取详情数据。使用数据库对于大规模数据使用SQLite甚至MySQL/PostgreSQL进行实时插入比操作大型DataFrame更高效。6. 伦理、法律与最佳实践最后也是最重要的一部分是关于爬虫的边界。尊重robots.txt在网站根目录下如https://www.boohee.com/robots.txt查看其爬虫协议。如果明确禁止爬取/food/路径那么你就应该停止。虽然技术上可行但遵守规则是基本的网络礼仪。明确数据用途本项目定位为个人学习、研究使用。切勿将爬取的数据用于商业用途、公开大量分发或对原网站服务器造成显著负担如发起DDOS攻击式的高频请求。识别公开与非公开数据我们爬取的是网站向所有用户公开展示的食物营养信息。绝不尝试爬取需要登录才能查看的个人数据、付费内容或通过破解API密钥获取的非公开接口数据。设置友好爬取间隔这是体现“友好”的关键。你的爬虫不应该影响网站的正常服务。间隔时间宁可设长不要设短。对于列表页间隔2-5秒对于详情页间隔3-8秒是比较稳妥的范围。处理数据的态度在本地分析使用爬取的数据时如果公开发布任何分析结果应考虑注明数据来源。这既是对原数据整理者的尊重也能让你的成果更可信。整个项目走下来收获的不仅仅是一份食物热量数据表更是一套完整的数据获取、处理和分析的实战经验。从分析目标、设计策略、编写代码到处理异常和优化流程每一步都是对解决问题能力的锻炼。最重要的是通过这个过程建立起了对网络数据生态的敬畏之心——在技术的便利与法律的边界、个人的需求与他人的权益之间找到那个平衡点。