Python爬虫实战:构建本地食物热量数据库的技术解析
1. 项目概述为什么我们需要一个食物热量数据库做健康管理或者健身的朋友对“薄荷网”这个名字应该不陌生。它是一个老牌的食物热量查询平台积累了海量的食物营养数据。无论是想控制体重、计算每日摄入还是进行专业的营养配餐一个准确、全面的食物热量数据库都是刚需。然而手动在网页上一个个查询效率太低而官方API要么不开放要么有严格的调用限制。这时候通过Python爬虫技术自动化地获取并构建一个本地的食物热量数据库就成了一件既有学习价值又有实用价值的事情。这个项目我们将聚焦于如何稳定、高效地从薄荷网爬取食物热量数据。它不仅仅是一个简单的“请求-解析”练习更涉及到面对动态加载、反爬策略、数据结构化存储等实际工程问题。对于数据爱好者、健身从业者或者任何想学习如何处理一个中型、结构稍复杂的网站爬虫的朋友来说都是一个绝佳的实战案例。接下来我会带你从零开始拆解整个爬取流程并分享我在实际操作中踩过的坑和总结的技巧。2. 核心思路与网站结构分析在动手写代码之前花时间分析目标网站的结构和行为模式是避免后续反复折腾的关键。盲目开干往往会在中途遇到各种意想不到的障碍。2.1 目标数据定位与反爬策略预判薄荷网的核心数据是食物及其对应的热量卡路里、蛋白质、脂肪、碳水化合物等营养信息。我们的目标是批量获取这些数据。首先我们打开薄荷网的食物搜索页面。通过浏览器的开发者工具F12的“网络”Network选项卡进行观察这是爬虫工程师的“眼睛”。你会发现当你在搜索框输入食物名称如“苹果”并点击搜索后页面展示结果的过程可能有两种情况静态加载搜索结果直接嵌入在返回的HTML页面中。这种情况最简单直接解析HTML即可。动态加载AJAX页面先返回一个框架然后通过额外的JavaScript请求通常是XHR/Fetch类型去获取真实的数据再动态渲染到页面上。这种情况下你需要找到这个真正的数据接口。根据我的实测薄荷网的食物列表和详情页采用了混合模式。列表页可能是动态加载而详情页的信息则基本存在于静态HTML中。这意味着我们的爬虫策略需要灵活对于列表可能需要分析接口对于详情可以直接解析HTML。此外必须预判反爬措施。常见的包括请求头校验检查User-Agent、Referer等缺少或异常会被拒绝。频率限制短时间内过多请求会导致IP被暂时封禁。Cookie/Session某些页面需要维持会话状态。JavaScript渲染核心数据由JS生成简单的requests库无法直接获取。这时可能需要用到Selenium或Playwright等浏览器自动化工具。初步观察薄荷网的反爬不算极端严厉但对请求频率比较敏感。因此我们的策略核心是模拟正常浏览器的请求并在请求间添加合理的延时。2.2 技术栈选型与工具准备基于以上分析我们选择以下技术栈它们是在平衡了易用性、效率和功能性的常见选择请求库requests简单易用是处理HTTP请求的事实标准。用于获取网页HTML或API接口数据。解析库lxmlcssselect/parsellxml解析速度快配合cssselect语法或parsel库它内置了CSS和XPath选择器写起来非常直观。比纯BeautifulSoup在某些场景下更高效。浏览器自动化备选Selenium如果发现关键数据确实由JavaScript动态渲染且找不到隐藏的接口我们将启用Selenium来模拟真实浏览器操作。这是一个重武器速度慢但兼容性最好。数据存储SQLite/CSVpandas对于初学者或数据量不是特别大的情况SQLite数据库轻便易用无需安装额外服务。用pandas做数据处理和导出到CSV/Excel也非常方便。如果数据量极大或需要复杂查询可考虑MySQL或PostgreSQL。调度与延时time.sleep() 随机数避免固定频率请求模拟人类操作的不确定性。代理IP备用如果单个IP被限制可能需要使用代理IP池。对于薄荷网在合理延时下通常不需要但作为高级方案需要了解。注意在开始任何爬取行为前请务必阅读目标网站的robots.txt文件通常在网站根目录如https://www.boohee.com/robots.txt并尊重其中关于爬虫访问权限的规定。本教程仅用于技术学习交流所爬取的数据切勿用于商业用途或对目标网站服务器造成压力。3. 实战爬取从搜索列表到详情数据假设我们从“常见食物”这个分类开始爬取。我们需要设计一个爬虫它能够遍历食物列表然后逐个进入详情页抓取营养数据。3.1 第一步获取食物列表链接首先我们需要找到食物列表的入口。薄荷网有“食物库”板块里面按类别如谷薯豆、蔬菜、水果等排列。我们可以从这些分类页入手。操作步骤打开一个分类页例如“水果类”页面。打开开发者工具刷新页面查看是静态加载还是动态加载。如果静态加载直接使用requests获取页面HTML然后用选择器解析出每个食物项的链接。通常链接会在a标签的href属性里。如果动态加载在“网络”选项卡中过滤XHR/Fetch请求搜索包含“list”、“category”、“food”等关键词的请求查看其预览Preview和响应Response找到包含食物列表数据的真实接口。然后用requests模拟这个接口的请求复制其请求URL、方法、头部和参数。示例代码片段假设为静态列表import requests from lxml import html 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/ } def get_food_list_from_category(category_url): 从分类页面获取食物列表链接 try: resp requests.get(category_url, headersheaders, timeout10) resp.raise_for_status() # 检查请求是否成功 resp.encoding utf-8 # 设置编码防止乱码 tree html.fromstring(resp.text) # 使用CSS选择器定位食物链接。这里的选择器需要根据实际页面结构调整。 # 例如假设每个食物项在一个class为‘food-item’的div里链接在里面的a标签 food_link_elements tree.cssselect(div.food-item a[href*/food/view/]) food_links [] for elem in food_link_elements: href elem.get(href) if href and href.startswith(/food/view/): # 补全为完整URL full_url fhttps://www.boohee.com{href} food_links.append(full_url) # 随机延时模拟人类浏览 time.sleep(random.uniform(1, 3)) return food_links except requests.exceptions.RequestException as e: print(f请求分类页失败: {category_url}, 错误: {e}) return [] # 示例获取水果分类的第一页食物链接 fruit_category_url https://www.boohee.com/food/group/1 # 假设的URL需替换为真实地址 food_list get_food_list_from_category(fruit_category_url) print(f获取到 {len(food_list)} 个食物链接)关键点解析headers我们模拟了一个常见浏览器的User-Agent并添加了Referer让请求看起来更“自然”。resp.raise_for_status()这是一个好习惯如果HTTP状态码不是200会抛出异常便于我们及时处理错误。选择器的编写这是爬虫的核心技能之一。你需要使用浏览器的“检查元素”功能仔细分析目标元素的HTML结构找到唯一且稳定的特征来定位它。cssselect语法非常强大a[href*/food/view/]表示选择所有href属性包含/food/view/字符串的a标签。延时time.sleep(random.uniform(1, 3))添加了1到3秒的随机等待这是最基本的反反爬措施。3.2 第二步解析食物详情页营养数据获取到食物详情页的链接后下一步就是进入每个详情页提取我们关心的数据食物名称、热量千卡、蛋白质、脂肪、碳水化合物等。操作步骤访问一个食物详情页。分析营养数据在HTML中的位置。通常这些数据会放在一个表格table或者一系列带有特定class的div或span中。编写解析函数使用选择器精准定位并提取这些文本信息。对提取的文本进行清洗例如去除多余的空格、单位如“千卡”、“克”并转换为数值类型如float。示例代码片段def parse_food_detail(detail_url): 解析食物详情页提取营养信息 food_info { name: , calories: None, # 热量千卡 protein: None, # 蛋白质克 fat: None, # 脂肪克 carbohydrate: None, # 碳水化合物克 url: detail_url } try: resp requests.get(detail_url, headersheaders, timeout10) resp.raise_for_status() resp.encoding utf-8 tree html.fromstring(resp.text) # 1. 解析食物名称 (假设在h1标签里) name_elem tree.cssselect(h1.food-name) if name_elem: food_info[name] name_elem[0].text.strip() # 2. 解析营养数据 (假设在一个class为‘nutrient-table’的表格中) # 我们需要找到“热量”、“蛋白质”、“脂肪”、“碳水化合物”对应的行 # 这里演示一种通用的查找方法遍历所有包含营养名称的单元格 nutrient_rows tree.cssselect(table.nutrient-table tr) for row in nutrient_rows: cols row.cssselect(td) if len(cols) 2: # 至少有两列营养名称和含量 nutrient_name cols[0].text_content().strip() nutrient_value cols[1].text_content().strip() # 根据名称匹配并提取数值 if 热量 in nutrient_name: # 提取数字部分例如“53千卡” - 53.0 food_info[calories] extract_number(nutrient_value) elif 蛋白质 in nutrient_name: food_info[protein] extract_number(nutrient_value) elif 脂肪 in nutrient_name: food_info[fat] extract_number(nutrient_value) elif 碳水化合物 in nutrient_name: food_info[carbohydrate] extract_number(nutrient_value) # 再次随机延时处理每个详情页后都等待一下 time.sleep(random.uniform(2, 4)) return food_info except requests.exceptions.RequestException as e: print(f请求详情页失败: {detail_url}, 错误: {e}) return None def extract_number(text): 从字符串中提取数字浮点数 import re # 匹配数字包括小数例如 “53.2千卡” - 53.2 match re.search(r(\d\.?\d*), text) if match: return float(match.group(1)) return None # 示例解析一个食物详情 if food_list: sample_food parse_food_detail(food_list[0]) print(sample_food)关键点解析数据提取的健壮性网页结构可能会变。我们的解析逻辑不能写得太死。示例中使用了if len(cols) 2和if ‘热量’ in nutrient_name这样的条件判断即使表格结构微调也有一定容错能力。更好的做法是使用更精确但也更复杂的XPath或多种选择器组合来定位。文本清洗extract_number函数使用正则表达式r(\d\.?\d*)来提取字符串中的数字部分这是一个非常实用的技巧。错误处理使用try...except包裹网络请求和解析过程确保一个页面的解析失败不会导致整个程序崩溃。将失败的信息记录下来便于后续排查和重试。3.3 第三步数据存储与流程串联现在我们已经有了获取列表和解析详情的函数。接下来需要创建一个主循环将流程串联起来并把爬取到的数据保存起来。操作步骤初始化一个数据存储结构如列表。遍历所有分类页如果需要多分类爬取。对于每个分类页获取食物链接列表。遍历每个食物链接调用parse_food_detail函数解析数据。将解析成功的数据存入列表并实时保存到文件或数据库防止程序意外中断导致数据丢失。添加丰富的日志输出监控爬取进度和状态。示例代码片段使用SQLite存储import sqlite3 from contextlib import closing def init_database(db_pathfood_calories.db): 初始化SQLite数据库和表 with closing(sqlite3.connect(db_path)) as conn: cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS foods ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, calories REAL, protein REAL, fat REAL, carbohydrate REAL, source_url TEXT UNIQUE, -- 唯一约束避免重复爬取 created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ) conn.commit() print(数据库初始化完成。) def save_to_database(food_info, db_pathfood_calories.db): 将食物信息保存到数据库 if not food_info or not food_info.get(name): return False try: with closing(sqlite3.connect(db_path)) as conn: cursor conn.cursor() # 使用INSERT OR IGNORE避免重复基于source_url唯一约束 cursor.execute( INSERT OR IGNORE INTO foods (name, calories, protein, fat, carbohydrate, source_url) VALUES (?, ?, ?, ?, ?, ?) , (food_info[name], food_info[calories], food_info[protein], food_info[fat], food_info[carbohydrate], food_info[url])) conn.commit() if cursor.rowcount 0: print(f已保存: {food_info[name]}) return True else: print(f已存在跳过: {food_info[name]}) return False except sqlite3.Error as e: print(f数据库保存失败 {food_info[name]}: {e}) return False def main_crawler(): 主爬虫函数 init_database() # 假设我们有多个分类的URL列表 category_urls [ https://www.boohee.com/food/group/1, # 水果 https://www.boohee.com/food/group/2, # 蔬菜 # ... 添加更多分类 ] all_food_data [] for cat_url in category_urls: print(f\n开始处理分类: {cat_url}) food_links get_food_list_from_category(cat_url) print(f 找到 {len(food_links)} 个食物。) for idx, link in enumerate(food_links, 1): print(f 正在处理 [{idx}/{len(food_links)}]: {link}) food_data parse_food_detail(link) if food_data: save_to_database(food_data) all_food_data.append(food_data) # 每处理N个食物后可以额外休息一下降低请求密度 if idx % 10 0: wait_time random.uniform(5, 10) print(f 已处理10个休息{wait_time:.1f}秒...) time.sleep(wait_time) print(f\n爬取结束共获取 {len(all_food_data)} 条有效数据。) # 也可以选择将数据导出为CSV import pandas as pd df pd.DataFrame(all_food_data) df.to_csv(food_calories.csv, indexFalse, encodingutf-8-sig) print(数据已另存为 food_calories.csv) if __name__ __main__: main_crawler()关键点解析数据库去重在数据库表中为source_url详情页链接设置了UNIQUE约束并使用INSERT OR IGNORE语句。这样当程序多次运行时或者同一个食物出现在不同分类下时可以自动避免插入重复数据。进度控制与延时策略在主循环中不仅每个详情页后有随机延时还设置了每处理10个食物进行一次更长的休息5-10秒。这是一种阶梯式的延时策略能更好地模拟人类的不规律访问显著降低被封IP的风险。数据持久化爬虫程序可能运行很长时间网络也可能不稳定。将数据实时存入数据库并定期如每处理完一个分类备份到CSV文件可以最大限度地保证数据安全。上下文管理器使用with closing(...)来管理数据库连接确保即使在发生异常时连接也能被正确关闭避免资源泄漏。4. 高级技巧与反反爬应对策略基本的爬虫框架搭建好了但在实际运行中你几乎肯定会遇到问题。下面分享一些进阶的处理技巧。4.1 处理动态加载与JavaScript渲染如果按照上述方法获取到的food_links列表是空的或者详情页解析不到数据很可能页面核心内容是通过JavaScript动态加载的。这时有几种应对方案方案A寻找隐藏的API接口推荐这是最高效的方法。在开发者工具的“网络”选项卡中仔细筛选XHR或Fetch请求寻找包含json、api、data等关键词的请求。查看其“负载”Payload和“响应”Response尝试用requests直接模拟这个请求。这通常需要你分析请求的参数是如何构成的可能包含时间戳、加密签名等。方案B使用Selenium/Playwright如果找不到接口或者接口参数过于复杂就只能动用浏览器自动化工具了。from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def get_dynamic_links_with_selenium(url): 使用Selenium获取动态加载的链接 # 初始化浏览器驱动以Chrome为例需下载对应版本的chromedriver options webdriver.ChromeOptions() options.add_argument(--headless) # 无头模式不显示浏览器窗口 options.add_argument(--disable-gpu) options.add_argument(user-agentMozilla/5.0 ...) # 同样可以设置UA driver webdriver.Chrome(optionsoptions) links [] try: driver.get(url) # 显式等待直到某个包含链接的元素加载出来 wait WebDriverWait(driver, 10) # 假设动态加载的内容在一个id为‘food-list’的容器里 food_container wait.until(EC.presence_of_element_located((By.ID, food-list))) # 在容器内查找链接 link_elements food_container.find_elements(By.CSS_SELECTOR, a[href*/food/view/]) for elem in link_elements: links.append(elem.get_attribute(href)) finally: driver.quit() # 一定要退出释放资源 return links使用Selenium的注意事项速度慢启动浏览器和加载页面远比直接HTTP请求慢。资源消耗大会占用较多内存和CPU。需要对应驱动如chromedriver且版本需与浏览器匹配。反爬可能升级一些网站能检测到Selenium的自动化特征。可能需要额外的插件或参数来隐藏特征。4.2 应对IP封锁与请求限制即使加了延时如果爬取速度过快或目标网站风控严格IP仍可能被暂时封锁。症状通常是连续收到403 Forbidden、429 Too Many Requests状态码或者返回的都是验证页面。应对策略进一步降低频率增加time.sleep的随机等待时间范围例如random.uniform(3, 8)。使用代理IP这是解决IP封锁最直接的方法。你可以购买付费代理服务或者寻找免费的代理IP但免费代理通常不稳定、速度慢。在requests中使用代理很简单proxies { http: http://your-proxy-ip:port, https: https://your-proxy-ip:port, } resp requests.get(url, headersheaders, proxiesproxies, timeout10)你需要构建一个代理IP池并在请求失败时自动切换IP。处理Cookie和Session有些网站需要维持登录状态或会话。使用requests.Session()对象可以自动管理Cookie。session requests.Session() session.headers.update(headers) # 所有使用session发起的请求都会共享Cookie resp session.get(list_url) resp2 session.get(detail_url) # 会携带上一个请求的Cookie设置请求超时和重试机制网络不稳定是常事。可以使用try...except捕获超时异常并加入重试逻辑但重试间隔要长避免雪上加霜。import requests.adapters from requests.packages.urllib3.util.retry import Retry session requests.Session() retries Retry(total3, backoff_factor1, status_forcelist[500, 502, 503, 504]) session.mount(http://, requests.adapters.HTTPAdapter(max_retriesretries)) session.mount(https://, requests.adapters.HTTPAdapter(max_retriesretries))4.3 数据清洗与校验爬取下来的原始数据往往是脏的需要清洗。单位统一确保所有数值字段的单位一致如热量统一为“千卡”质量统一为“克”。处理缺失值有些食物可能缺少某些营养信息。在数据库设计时应允许NULL值。在后续分析时再决定是填充默认值如0、平均值还是直接剔除。去重除了通过URL去重有时同一食物可能有不同名称或别名。可以通过模糊匹配如计算字符串相似度或建立手动映射表来进行更智能的合并。异常值检测检查是否有明显不合理的数据例如热量为负数或者蛋白质含量高得离谱比如超过100克。这可能是解析错误需要手动核查或设置过滤规则。5. 常见问题排查与实战心得在爬虫开发过程中你会遇到各种各样的问题。这里记录一些典型问题的排查思路和我总结的经验。5.1 问题排查速查表问题现象可能原因排查步骤与解决方案返回状态码403/429IP被限制或请求头被识别为爬虫。1. 检查并完善请求头特别是User-Agent,Referer。2. 大幅降低请求频率增加随机延时。3. 考虑使用代理IP。获取的HTML中没有目标数据数据是JavaScript动态加载的。1. 打开开发者工具“网络”选项卡刷新页面查找XHR/Fetch请求。2. 尝试直接请求找到的API接口。3. 若找不到接口或接口复杂使用Selenium。解析数据为None或乱码1. 网页编码问题。2. 选择器写错了定位不到元素。3. 页面结构已更新。1. 检查resp.encoding尝试resp.apparent_encoding或手动指定gbk/utf-8。2. 使用浏览器检查元素确认选择器路径是否正确。3. 打印一小段resp.text确认是否拿到了正确内容。程序运行一段时间后崩溃1. 网络连接超时或中断。2. 内存泄漏如Selenium驱动未关闭。3. 数据库连接未正常关闭。1. 为所有网络请求添加try...except和超时参数timeout。2. 使用with语句或try...finally确保资源如浏览器驱动、数据库连接被释放。3. 将爬虫分批次运行并定期保存进度。爬取速度非常慢1. 延时设置过长。2. 使用了Selenium。3. 网络或代理速度慢。1. 在遵守网站规则的前提下优化延时逻辑如首次访问后短延时被限制后长延时。2. 优先使用requests接口的方式。3. 检查代理IP质量。5.2 实操心得与避坑指南“慢就是快”对于爬虫新手最大的误区就是追求速度。一开始就把延时设得很短很快IP就会被封导致前功尽弃。我的经验是从保守的延时开始例如每次请求后等待3-10秒稳定运行一段时间后再尝试逐步缩短间隔观察网站的容忍度。一个稳定运行一整晚的慢速爬虫远比一个运行十分钟就被封的快速爬虫有效。保存中间状态一定要设计程序能够断点续爬。可以将已成功爬取的URL列表保存到一个文件或数据库表中。每次启动时先加载这个列表跳过已经处理过的URL。这样即使程序中途因各种原因停止重启后也能从上次中断的地方继续而不是从头开始。日志是生命线不要只用print。使用Python内置的logging模块将信息分级DEBUG, INFO, WARNING, ERROR输出到文件和控制台。当爬虫在后台运行数小时出错时详细的日志文件是你排查问题的唯一依据。记录下每个请求的URL、状态码、耗时以及解析成功或失败的原因。尊重robots.txt与法律边界这是职业道德和法律红线。robots.txt文件指明了网站允许和禁止爬虫访问的路径。即使技术上可行也应遵守。同时爬取的数据仅限于个人学习或研究使用切勿用于商业目的、公开大量传播或对网站进行攻击这可能会侵犯权益并带来法律风险。网页结构是会变的今天能用的爬虫明天可能就失效了因为网站改版了。因此选择器要尽量写得健壮不要依赖于过于具体、容易变化的ID或Class。多使用相对位置和标签语义。更重要的是核心的解析函数最好与主流程分离这样当网站结构变化时你只需要修改解析函数而不必动整个爬虫框架。考虑使用现成的框架如果项目变得复杂需要调度、监控、分布式等可以考虑使用Scrapy这样的专业爬虫框架。它提供了项目结构、异步处理、中间件、管道等一套完整机制能极大地提升开发效率和程序的健壮性。但对于像薄荷网这样结构相对清晰的中型网站自己从requests开始搭建是理解爬虫原理的最佳途径。通过这个“一日一技”项目你不仅获得了一个可用的食物热量数据库更重要的是掌握了一套完整的、可复用的网站数据爬取方法论。从环境分析、工具选型、代码实现到反爬应对、错误处理和效率优化这些经验可以迁移到绝大多数类似的爬虫任务中。记住爬虫是技术和策略的结合耐心和细心往往比高深的技巧更重要。