利用Playwright伪装地理位置,破解饿了么商圈热销榜数据抓取难题

📅 2026/7/6 21:56:25
利用Playwright伪装地理位置,破解饿了么商圈热销榜数据抓取难题
1. 项目概述与核心价值最近在做一个本地生活数据分析的小项目需要获取不同城市、不同商圈的餐饮销售数据。直接找数据服务商价格不菲而且数据颗粒度未必能满足需求。于是我把目光投向了饿了么这类平台它们公开的“商圈热销榜”本身就是一份极有价值的市场风向标。但问题来了平台的风控机制会识别你的网络环境和地理位置如果你不在目标商圈要么看不到榜单要么看到的是基于你IP地址推荐的“本地榜单”数据就失去了参考意义。这就是“伪装地理位置”成为本项目核心挑战的原因。传统的爬虫手段比如用requests库加个代理IP在对付现代强交互、重度依赖JavaScript渲染的Web应用时常常力不从心。页面元素异步加载、接口参数加密、甚至直接检测浏览器指纹都可能让你无功而返。这时候Playwright的优势就凸显出来了。它不是一个简单的HTTP请求库而是一个能够驱动真实浏览器如Chromium, Firefox, WebKit进行自动化操作的框架。这意味着你的爬虫代码可以模拟一个真实用户的所有操作打开浏览器、输入网址、点击按钮、滑动页面甚至——关键就在这里——修改浏览器的地理位置信息。所以这个项目的核心思路是“降维打击”与其在HTTP请求层和平台的反爬机制斗智斗勇不如直接模拟一个“身处”目标商圈的真实用户用浏览器自动化工具去“看”页面然后提取我们需要的热销榜单数据。Playwright配合Python为我们提供了实现这一想法的完美工具链。接下来我会详细拆解从环境搭建、核心伪装技术到数据抓取与解析的完整流程并分享我在这个过程中踩过的坑和总结的经验。2. 环境准备与Playwright基础配置2.1 Python环境与Playwright安装首先确保你有一个可用的Python环境3.7及以上版本。我强烈建议使用虚拟环境来管理项目依赖避免包冲突。# 创建并激活虚拟环境以venv为例 python -m venv playwright-env # Windows playwright-env\Scripts\activate # macOS/Linux source playwright-env/bin/activate安装Playwright的Python库非常简单pip install playwright安装完Python库后Playwright还需要下载它要驱动的浏览器内核。这一步是必须的它会下载Chromium、Firefox和WebKit的特定版本。playwright install注意playwright install命令可能会因为网络问题下载缓慢或失败。你可以通过设置环境变量来使用国内镜像加速例如set PLAYWRIGHT_DOWNLOAD_HOSThttps://npmmirror.com/mirrors/playwright(Windows) 或export PLAYWRIGHT_DOWNLOAD_HOSThttps://npmmirror.com/mirrors/playwright(macOS/Linux)然后再执行安装命令。实测下来能极大提升成功率。2.2 初始化浏览器上下文与核心参数设定Playwright的核心对象是Browser,BrowserContext和Page。Browser对应一个浏览器实例Context是一个独立的会话上下文可以理解为隐身窗口Page则是具体的标签页。我们的地理位置伪装、User-Agent设置等操作主要在创建BrowserContext时进行。下面是一个基础的启动脚本框架import asyncio from playwright.async_api import async_playwright async def main(): async with async_playwright() as p: # 1. 启动浏览器headlessFalse表示显示浏览器界面便于调试 browser await p.chromium.launch(headlessFalse, slow_mo100) # slow_mo让操作变慢方便观察 # 2. 创建浏览器上下文并在此处注入我们的“伪装” context await browser.new_context( viewport{width: 1920, height: 1080}, user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, localezh-CN, timezone_idAsia/Shanghai, # 核心地理位置覆盖这里以上海陆家嘴为例 geolocation{longitude: 121.4997, latitude: 31.2397}, permissions[geolocation] # 授予地理位置权限 ) # 3. 创建页面 page await context.new_page() # 4. 后续的导航、操作逻辑将在这里编写 await page.goto(https://h5.ele.me/msite/) # ... 等待页面加载处理可能的弹窗等 # 5. 操作结束后关闭资源 await context.close() await browser.close() # 运行异步主函数 asyncio.run(main())关键参数解析viewport: 设置浏览器窗口大小。不同尺寸可能影响页面布局建议设置为常见的桌面分辨率。user_agent: 设置用户代理字符串。使用一个最新的、常见的桌面版Chrome UA能减少被识别为爬虫的风险。locale和timezone_id: 设置语言环境和时区使其与伪装的地理位置匹配中国上海。geolocation: 这是地理位置伪装的核心。其经纬度坐标决定了浏览器navigator.geolocationAPI返回的值。你需要通过地图服务如百度地图、高德地图的坐标拾取器获取目标商圈中心的精确坐标。permissions: 授予浏览器上下文特定的权限。这里我们显式授予geolocation权限模拟用户已同意网站获取位置。实操心得在开发调试阶段务必使用headlessFalse模式亲眼看到浏览器是如何工作的。这能帮你快速定位问题是出在导航、弹窗处理还是元素定位上。slow_mo参数也很有用它让每个Playwright操作都延迟指定的毫秒数相当于“慢动作”对于理解脚本执行顺序和调试交互问题至关重要。3. 核心伪装技术与饿了么页面适配3.1 地理位置伪装原理与验证仅仅在new_context时设置geolocation是否足够对于大多数依赖HTML5 Geolocation API的网站来说是的。当页面调用navigator.geolocation.getCurrentPosition()时浏览器将返回我们预设的坐标。但为了确保万无一失我们可以在页面加载后通过执行JavaScript来“加固”这个伪装并验证其是否生效。# 在 page.goto() 之后添加以下代码 # 方法一通过CDPChrome DevTools ProtocolSession覆盖地理位置 # 这种方法更底层优先级可能更高 client await context.new_cdp_session(page) await client.send(Emulation.setGeolocationOverride, { latitude: 31.2397, longitude: 121.4997, accuracy: 50 # 定位精度单位米 }) # 方法二在页面环境中执行JS模拟定位成功 # 有些网站会监听geolocation的watchPosition这个也能覆盖 geo_override_js Object.defineProperty(navigator.geolocation, getCurrentPosition, { value: (success) success({ coords: { latitude: 31.2397, longitude: 121.4997, accuracy: 50, altitude: null, altitudeAccuracy: null, heading: null, speed: null }, timestamp: Date.now() }), writable: false }); await page.add_init_script(geo_override_js) # 验证在控制台输出或通过JS获取当前定位 async def check_geolocation(): location await page.evaluate(() { return new Promise((resolve) { if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( pos resolve({lat: pos.coords.latitude, lng: pos.coords.longitude}), err resolve(定位错误: ${err.message}) ); } else { resolve(浏览器不支持定位); } }); }) print(f当前页面获取到的地理位置: {location}) await check_geolocation()运行后如果打印出的经纬度与你设置的一致恭喜你地理位置伪装成功。3.2 处理饿了么的定位弹窗与页面导航饿了么H5页面在首次访问或检测到位置变化时大概率会弹出“获取您的位置”或“选择收货地址”的弹窗。我们的脚本需要自动处理这些交互。await page.goto(https://h5.ele.me/msite/, wait_untilnetworkidle) # wait_until确保页面加载更充分 # 方案一等待并点击“允许”或“确定”按钮如果弹窗出现 try: # 使用更灵活的定位器适应可能的按钮文本变化 allow_button page.get_by_role(button, namere.compile(r(允许|确定|好|知道了)), exactFalse) # 或者使用CSS选择器结合文本 # allow_button page.locator(button:has-text(允许), button:has-text(确定)) await allow_button.wait_for(timeout5000) # 等待最多5秒 await allow_button.click() print(已处理定位权限弹窗) except Exception as e: print(f未出现预期弹窗或处理失败: {e}) # 方案二更通用的方法是直接通过URL参数或点击页面元素切换到目标城市/商圈 # 观察饿了么H5页面切换城市的入口通常是一个显示当前城市的元素 try: # 假设当前城市显示元素可以通过某个CSS选择器找到 city_switch page.locator(.city-switch, [class*city]) # 这是一个示例选择器实际需要分析页面 await city_switch.wait_for(timeout3000) await city_switch.click() # 随后可能出现城市列表需要搜索并选择目标城市例如“上海” await page.locator(input[placeholder*城市]).fill(上海) await page.locator(text上海市).first.click() # 点击搜索结果中的“上海市” print(已切换到目标城市) except Exception as e: print(f城市切换可能不需要或失败: {e}) # 关键步骤寻找并进入“商圈热销榜” # 通常榜单入口在首页的某个频道或图标里需要分析页面结构 # 这里以点击“排行榜”或“热销榜”文字为例 try: # 使用文本定位器更可靠 rank_entry page.get_by_text(排行榜, exactFalse).or_(page.get_by_text(热销榜, exactFalse)) await rank_entry.first.wait_for(timeout5000) await rank_entry.first.click() print(正在进入榜单页面...) await page.wait_for_load_state(networkidle) # 等待榜单页面加载 except Exception as e: print(f查找榜单入口失败: {e}) # 失败时可以截图便于分析页面结构 await page.screenshot(pathdebug_homepage.png)饿了么页面结构分析技巧手动操作在headlessFalse模式下先手动在打开的浏览器里操作一遍流程允许定位-可能切换城市-找到榜单入口点进去。观察URL的变化规律。开发者工具在Playwright打开的浏览器中按F12使用元素选择器CtrlShiftC点击页面元素找到其唯一或稳定的CSS选择器、类名、文本内容。网络监听在开发者工具的Network面板观察点击榜单后发了什么请求请求的URL和参数是什么。有时直接请求接口数据比解析页面更简单。但本项目的目标是“降维打击”应对复杂渲染所以我们优先考虑解析最终渲染出的页面。注意事项饿了么的页面结构可能会频繁变动你写的选择器今天有效明天可能就失效了。因此选择器要尽量健壮。优先使用page.get_by_role(),page.get_by_text(),page.get_by_label()这些基于可访问性属性的定位器它们比纯CSS选择器更稳定。其次使用or_()方法组合多个定位条件提高容错率。永远不要假设页面结构一成不变。4. 热销榜单数据抓取与解析策略成功进入目标商圈的“热销榜”页面后接下来的任务就是解析页面提取店铺信息。4.1 页面结构分析与数据定位首先我们需要确定榜单数据在页面中是如何呈现的。通常它是一个列表每个列表项li或div包含一家店铺的信息如店名、月售量、评分、人均价格、优惠信息等。打开开发者工具查看榜单列表的容器元素。假设我们通过分析发现店铺列表的容器是div classshop-list每个店铺项是div classshop-item。# 等待榜单列表加载出来 await page.wait_for_selector(.shop-list, .shop-item, statevisible, timeout10000) # 获取所有店铺项的元素句柄 shop_items await page.locator(.shop-item).all() # 使用.locator().all()获取元素列表 print(f共找到 {len(shop_items)} 家店铺) data_list [] for index, shop in enumerate(shop_items): # 针对每个shop元素提取其内部的详细信息 # 注意以下选择器路径是相对于每个shop元素的 try: # 店名 - 通常是一个h3或带特定class的div name await shop.locator(h3, .shop-name, [class*name]).first.text_content() name name.strip() if name else N/A # 月售量 - 通常包含“月售”文字 # 使用正则表达式提取数字 monthly_sales_text await shop.locator(text/月售[0-9]/).first.text_content() monthly_sales int(re.search(r月售(\d), monthly_sales_text).group(1)) if monthly_sales_text else 0 # 评分 rating_el shop.locator(.rating, [class*rate]).first rating float(await rating_el.text_content()) if await rating_el.count() 0 else 0.0 # 人均价格 price_el shop.locator(text/人均\d/).or_(shop.locator(text/\d/)).first price_text await price_el.text_content() if await price_el.count() 0 else price int(re.search(r(\d), price_text).group(1)) if price_text else 0 # 店铺链接如果需要深入抓取 link_el shop.locator(a).first shop_link await link_el.get_attribute(href) if await link_el.count() 0 else if shop_link and not shop_link.startswith(http): shop_link https://h5.ele.me shop_link # 补全相对链接 data_list.append({ 排名: index 1, 店名: name, 月售: monthly_sales, 评分: rating, 人均价格(元): price, 店铺链接: shop_link, 抓取商圈: 上海陆家嘴, # 根据你设置的坐标对应 抓取时间: datetime.now().strftime(%Y-%m-%d %H:%M:%S) }) except Exception as e: print(f解析第{index1}个店铺时出错: {e}) # 可以截图这个出错的元素以便调试 # await shop.screenshot(pathferror_shop_{index}.png) continue4.2 处理滚动加载与多页数据很多榜单页面是滚动加载无限滚动的而不是传统分页。我们需要模拟用户滚动行为让页面加载出更多数据。# 模拟滚动加载更多数据 previous_count len(await page.locator(.shop-item).all()) scroll_attempts 0 max_attempts 10 # 最多尝试滚动10次避免无限循环 while scroll_attempts max_attempts: # 滚动到页面底部 await page.evaluate(window.scrollTo(0, document.body.scrollHeight)) # 等待新内容加载可以等待一个特定的加载指示器消失或者简单等待一段时间 await page.wait_for_timeout(2000) # 等待2秒 # 再次获取店铺数量 current_count len(await page.locator(.shop-item).all()) if current_count previous_count: # 数量没有增加可能已加载完毕或者需要触发其他条件如点击“加载更多” print(f滚动后数量未增加 ({previous_count} - {current_count})可能已加载完所有数据。) # 检查是否有“加载更多”按钮 load_more_btn page.get_by_text(加载更多, exactFalse) if await load_more_btn.count() 0: await load_more_btn.click() await page.wait_for_timeout(3000) current_count len(await page.locator(.shop-item).all()) else: break # 没有更多数据了 else: print(f滚动加载成功店铺数量从 {previous_count} 增加到 {current_count}) previous_count current_count scroll_attempts 0 # 重置尝试次数因为成功加载了新内容 scroll_attempts 1 print(f最终获取到 {len(await page.locator(.shop-item).all())} 家店铺数据。)4.3 数据存储与结构化输出抓取到的数据最好保存下来方便后续分析。这里我们使用pandas库将数据保存为CSV文件。import pandas as pd if data_list: df pd.DataFrame(data_list) # 按“月售”降序排列更符合“热销”榜单的直观感受 df df.sort_values(by月售, ascendingFalse).reset_index(dropTrue) df[排名] df.index 1 # 重新计算排名 filename feleme_hot_sales_{datetime.now().strftime(%Y%m%d_%H%M%S)}.csv df.to_csv(filename, indexFalse, encodingutf-8-sig) # utf-8-sig支持Excel直接打开不乱码 print(f数据已保存至: {filename}) print(df.head(10)) # 打印前10名看看 else: print(未抓取到任何数据。)5. 反爬对抗策略与稳健性提升即使使用了Playwright模拟浏览器平台的反爬机制依然可能存在。我们需要让脚本行为更像真人并具备一定的容错和恢复能力。5.1 指纹伪装与行为模拟除了地理位置浏览器指纹还包括WebGL、Canvas、字体、插件列表等。Playwright启动的浏览器本身已经做了很多随机化处理但我们还可以进一步优化。# 在创建context时可以添加更多伪装参数 context await browser.new_context( # ... 之前的参数 # 禁用WebDriver属性避免被检测到是自动化工具 bypass_cspFalse, # 根据情况调整 # 随机化视口大小在合理范围内 viewport{width: 1920 random.randint(-100, 100), height: 1080 random.randint(-50, 50)}, # 设置一个随机的用户代理可以从预置列表中随机选 user_agentrandom.choice([ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, # 添加更多常见UA ]), # 设置额外的HTTP头 extra_http_headers{ Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, br, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Referer: https://www.ele.me/, DNT: 1, # Do Not Track } ) # 在页面操作中模拟人类行为随机延迟、不规则移动鼠标 async def human_like_click(page, selector): element await page.wait_for_selector(selector) box await element.bounding_box() # 随机点击点在元素范围内 x box[x] box[width] * random.uniform(0.2, 0.8) y box[y] box[height] * random.uniform(0.2, 0.8) # 模拟鼠标移动轨迹不一定直线 await page.mouse.move(x random.randint(-5, 5), y random.randint(-5, 5), stepsrandom.randint(5, 15)) await page.wait_for_timeout(random.randint(50, 300)) # 随机等待50-300ms await page.mouse.click(x, y) # 使用示例await human_like_click(page, .shop-item:first-child a)5.2 异常处理与重试机制网络不稳定、页面元素加载超时、反爬弹窗如验证码都可能中断脚本。健壮的爬虫必须有完善的异常处理和重试逻辑。import logging logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s) async def safe_goto_with_retry(page, url, max_retries3): for attempt in range(max_retries): try: response await page.goto(url, wait_untilnetworkidle, timeout30000) if response and response.ok: logging.info(f成功访问 {url}) return True else: logging.warning(f第{attempt1}次访问 {url} 响应异常: {response.status if response else No Response}) except Exception as e: logging.error(f第{attempt1}次访问 {url} 失败: {e}) if attempt max_retries - 1: wait_time (attempt 1) * 5 # 退避等待 logging.info(f等待{wait_time}秒后重试...) await asyncio.sleep(wait_time) # 可以尝试刷新页面或重新创建context # await page.reload() logging.error(f访问 {url} 失败已达最大重试次数 {max_retries}) return False # 在主要抓取逻辑外围包裹try-except async def crawl_hot_sales(city_coords, city_name): async with async_playwright() as p: browser None try: browser await p.chromium.launch(headlessTrue) # 生产环境用headless context await browser.new_context( geolocationcity_coords, permissions[geolocation], # ... 其他参数 ) page await context.new_page() if not await safe_goto_with_retry(page, https://h5.ele.me/msite/): raise Exception(无法访问首页) # ... 后续的定位处理、榜单进入、数据抓取逻辑 return data_list except Exception as e: logging.exception(f抓取{city_name}数据时发生严重错误: {e}) # 可以在这里保存错误截图 if page: await page.screenshot(pathferror_{city_name}_{datetime.now().strftime(%H%M%S)}.png) return [] finally: if browser: await browser.close()5.3 应对验证码与封禁策略如果触发了平台的风控可能会遇到验证码甚至IP被封。这时需要更复杂的策略。识别验证码在点击关键按钮或加载特定页面后检查页面是否出现了验证码元素如img[src*captcha],div.captcha-container等。如果出现可以记录并暂停记录下这个URL和上下文通知人工处理。使用打码平台商业项目考虑将截图发送到第三方打码API获取识别结果后填入。切换上下文/代理销毁当前BrowserContext更换IP如果有代理池新建一个Context再试。代理IP池集成对于大规模抓取使用住宅代理或高质量数据中心代理是必要的。Playwright在启动浏览器时可以通过proxy参数设置代理。proxy_settings { server: http://your-proxy-server:port, # 如果需要认证 username: your-username, password: your-password } browser await p.chromium.launch(proxyproxy_settings, headlessTrue)控制请求频率在抓取过程中加入随机延时避免请求过于密集。page.wait_for_timeout(random.uniform(1000, 5000))可以在操作间随机等待1-5秒。6. 项目扩展与数据分析应用基础抓取功能实现后这个项目可以朝多个方向扩展使其价值最大化。6.1 多商圈批量抓取与调度真正的商业分析需要对比多个商圈。我们可以将目标商圈列表名称和经纬度存入一个配置文件如JSON或CSV然后编写脚本循环抓取。import json import asyncio import aiohttp # 用于可能的异步HTTP请求如果后续用API with open(business_circles.json, r, encodingutf-8) as f: circles json.load(f) # circles 示例: [{name: 陆家嘴, lng: 121.4997, lat: 31.2397}, ...] async def crawl_circle(circle_info): data await crawl_hot_sales( {longitude: circle_info[lng], latitude: circle_info[lat]}, circle_info[name] ) # 保存时带上商圈名 if data: df pd.DataFrame(data) df[商圈] circle_info[name] return df return pd.DataFrame() # 使用asyncio.gather进行有限并发控制避免同时打开太多浏览器 async def main_batch(): semaphore asyncio.Semaphore(3) # 控制最大并发数为3 async def sem_task(circle): async with semaphore: return await crawl_circle(circle) tasks [sem_task(circle) for circle in circles] results await asyncio.gather(*tasks, return_exceptionsTrue) all_data pd.concat([r for r in results if isinstance(r, pd.DataFrame)], ignore_indexTrue) all_data.to_csv(all_circles_hot_sales.csv, indexFalse, encodingutf-8-sig) print(f批量抓取完成总计{len(all_data)}条数据。)6.2 数据清洗与可视化分析原始数据抓取后清洗和分析才能产生洞见。import pandas as pd import matplotlib.pyplot as plt import seaborn as sns df pd.read_csv(all_circles_hot_sales.csv) # 1. 数据清洗 # 检查缺失值 print(df.isnull().sum()) # 去除重复项如果有 df.drop_duplicates(subset[店名, 商圈], inplaceTrue) # 格式化列 df[人均价格(元)] pd.to_numeric(df[人均价格(元)], errorscoerce) df[评分] pd.to_numeric(df[评分], errorscoerce) # 2. 基础分析 # 各商圈店铺数量 shop_count_by_circle df.groupby(商圈)[店名].nunique().sort_values(ascendingFalse) print(shop_count_by_circle) # 各商圈平均月售和人均 avg_stats df.groupby(商圈).agg({月售: mean, 人均价格(元): mean}).round(2) print(avg_stats) # 3. 可视化 plt.figure(figsize(12, 6)) # 绘制各商圈Top10店铺月售分布箱线图 top_circles shop_count_by_circle.index[:5] # 取店铺数最多的5个商圈 df_top df[df[商圈].isin(top_circles)] sns.boxplot(datadf_top, x商圈, y月售) plt.title(核心商圈店铺月售额分布对比) plt.xticks(rotation45) plt.tight_layout() plt.savefig(monthly_sales_by_circle.png, dpi300) plt.show() # 评分与月售关系散点图 plt.figure(figsize(10, 6)) sns.scatterplot(datadf, x评分, y月售, hue商圈, alpha0.6, s100) plt.title(店铺评分与月售额关系) plt.tight_layout() plt.savefig(rating_vs_sales.png, dpi300) plt.show()6.3 架构优化将爬虫部署为可持续服务对于长期监控可以将脚本部署到服务器定期运行。定时任务使用cronLinux或Task SchedulerWindows定时执行Python脚本。或者使用Celery、APScheduler等库在Python内部实现定时。状态管理与去重使用数据库如SQLite, PostgreSQL存储历史数据。每次抓取前可以检查该店铺在该商圈的最新记录日期实现增量更新避免重复存储。异常通知集成邮件smtplib或即时通讯工具如企业微信、钉钉、Telegram的Bot API的报警功能。当抓取失败、触发验证码或数据异常时及时通知维护人员。容器化部署使用Docker将爬虫环境Python, Playwright浏览器打包成镜像便于在不同服务器上一致地部署和运行。# 示例 Dockerfile FROM mcr.microsoft.com/playwright/python:v1.40.0-noble WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD [python, main.py]这个从“伪装地理位置”切入的Playwright爬虫项目其核心思想在于用高维的、模拟真实用户行为的方式绕过低维的基于请求特征的反爬策略。它不仅仅适用于饿了么任何严重依赖地理位置、用户状态和客户端渲染的Web应用如美团、大众点评、地图服务、社交平台等的数据抓取场景都可以借鉴这套方法论。技术的关键在于对目标网站交互逻辑的细致观察以及对PlaywrightAPI的灵活运用。在实战中保持代码的健壮性和可维护性比追求极致的抓取速度更为重要。