Target.com 是美国最大的零售目录网站之一,拥有数百万个涵盖电子产品、服装、家居用品和杂货的公开商品页面。每条商品详情页都包含驱动大多数零售研究的核心数据:标题、当前价格、TCIN 标识符、星级评分,以及清晰的有货或缺货状态。价格追踪器、竞争分析师和商品研究人员密切关注这些字段,因为它们是了解大型零售商在任意时刻定价与库存状况最清晰的信号之一。
本指南介绍如何用 Python 抓取 Target 商品数据。你将构建一个小型可运行的爬虫,通过 Crawling API 获取 Target 搜索或商品页面,为每个商品解析一条结构化记录,处理结果页的分页,并导出为 JSON 和 CSV。整个流程仅限于公开目录数据:任何人在 Target.com 上无需登录即可看到的标题、价格、评分和库存信息。
你将构建的内容
一个 Python 脚本,接受 Target 搜索 URL,通过 Crawling API 获取渲染后的页面,并为每个商品提取一条结构化记录。示例使用 "womens sweaters" 搜索作为贯穿全文的案例(与旧版教程使用的查询相同),并从每张商品卡片中提取以下字段:
- 标题:商品列表卡片上显示的商品名称。
- 价格:当前标价,可能是单一价格或价格区间。
- TCIN / SKU:Target 自有的商品标识符,从商品 URL 中读取。
- 评分:平均星级,由评分条宽度推算得出。
- 评论数:支撑该评分的评论数量。
- 库存状态:商品是否显示为有货或无货。
- 商品链接:指向该商品详情页的绝对链接。
为什么普通请求在 Target 上会失败
如果你用普通 HTTP 客户端请求 Target 搜索 URL,得到的结果几乎是空的。Target 的搜索网格在客户端渲染:服务器发送一个轻量级外壳,商品卡片只有在页面 JavaScript 运行后才会填充。解析普通 requests.get() 的响应,你会看到一个空列表,因为你所需的商品从未出现在第一个 HTML 载荷中。
此外,Target 会监测自动化流量,屏蔽那些看起来不像真实浏览器的请求模式。因此,一个能正常工作的 Target 爬虫需要在一次请求中同时满足两点:渲染页面的浏览器,以及 Target 认为是真实购物者的 IP。你可以自己搭建一套带有无头浏览器和轮换住宅代理池的方案,但维护这套架构才是工作量的主体。Crawling API 将两者融合为一次调用:你发送搜索 URL,它在受信任的住宅 IP 后渲染页面,处理轮换和 CAPTCHA 解决,并返回供你解析的完整 HTML。
前提条件
在编写代码之前,你需要准备好以下几项。每项配置都不需要太长时间。
Python 基础。你应该能够编写并运行 Python 脚本,并使用 pip 安装包。如果你是 Python 新手,请参阅我们的Python 爬虫入门指南,其内容正是本教程所假设的基础水平。
Python 3.8 或更高版本。使用 python --version(或 python3 --version)确认版本。如果没有安装,请从 python.org 安装,并确保 Python 已加入系统 PATH。
Crawlbase 账户和 token。注册免费账户,打开控制台,从账户文档页面复制你的 token。Crawlbase 提供两种 token:用于静态网站的普通 token,以及用于动态 JS 渲染页面的 JavaScript token。由于 Target 是 JS 渲染的,本教程使用 JavaScript token。免费版最多包含 20,000 次请求,无需信用卡,足以构建和测试本爬虫。请像对待密码一样保管 token,不要放入版本控制。
配置项目
创建虚拟环境以隔离项目依赖,然后安装爬虫所需的两个库。crawlbase 是 Crawling API 的官方客户端,beautifulsoup4 解析返回的 HTML,以便你通过 CSS 选择器从商品卡片中提取各字段。
python --version python -m venv target_env source target_env/bin/activate pip install crawlbase beautifulsoup4
在 Windows 上,用 target_env\Scripts\activate 代替 source 那行来激活环境。安装好两个库后,创建本指南后续部分将逐步构建的脚本文件:
touch target_scraper.py
了解 Target 搜索页面
Target 搜索的 URL 格式固定:https://www.target.com/s?searchTerm=womens+sweaters。页面以网格形式展示商品卡片,每张卡片包含相同的几个字段:标题、价格、星级评分条、评论数,以及指向商品详情页的链接。Target 为大多数字段设置了 data-test 属性,这比其生成的类名稳定得多。
在编写选择器之前,在浏览器中打开一个搜索页面,右键点击商品卡片并选择"检查"。每张卡片位于 div[data-test="product-grid"] 中,标题链接带有 data-test="product-title",当前价格使用 data-test="current-price",评论数使用 data-test="rating-count",而评分本身是一个带有 data-ref="rating-mask" 的遮罩条,其 CSS 宽度编码了评分值。Target 的内部商品 ID(TCIN)嵌入在商品 URL 中 /A- 标记之后,因此可直接从链接中读取。
第一步:获取渲染后的搜索页面
首先获取完整页面。导入 CrawlingAPI 类,用你的 JavaScript token 初始化它,设置搜索 URL,并使用 JS 渲染选项请求该 URL。在解析之前检查状态,可以让失败情况更加明显而非悄然发生。
from crawlbase import CrawlingAPI from urllib.parse import quote api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) def crawl(page_url): options = {"ajax_wait": "true", "page_wait": 5000} response = api.get(page_url, options) if response["headers"]["cb_status"] == "200": return response["body"].decode("utf-8") print(f"Request failed: {response['headers']['cb_status']}") return None if __name__ == "__main__": search_term = "womens sweaters" url = f"https://www.target.com/s?searchTerm={quote(search_term)}" html = crawl(url) print(html[:500] if html else "No HTML returned")
两个等待选项对于加载后才填充的网格非常重要。ajax_wait 告知 API 等待异步内容加载完毕,page_wait 在加载后等待 5000 毫秒,以便晚渲染的卡片在页面被捕获前出现。Crawling API 在 response["headers"]["cb_status"] 中报告自身的结果,因此应检查该值而非原始 HTTP 状态码。运行脚本后,你应该能看到真实的商品标记,而非空外壳。这确认了渲染工作正常,之后再编写选择器。
那个空网格正是 Crawling API 所解决的问题:Target 的商品列表只有在 JavaScript 运行后才存在,且需要 Target 信任的 IP。你发送 token 和搜索 URL,它在真实浏览器中运行页面,在服务端轮换住宅 IP,处理 CAPTCHA 解决,并返回完整的 HTML。你无需自己运行无头浏览器集群和代理池。先在最多 20,000 次请求的免费版中体验。
第二步:用 BeautifulSoup 解析商品卡片
拿到渲染后的 HTML,将其加载到 BeautifulSoup 中,找到所有商品卡片,并通过选择器提取各字段。评分需要一个小辅助函数:Target 将星级条绘制为填充遮罩,评分值以百分比形式存在于条的 CSS width 中,因此需要将该百分比转换为 5 分制的值。
from bs4 import BeautifulSoup BASE = "https://www.target.com" GRID = 'div[data-test="product-grid"] section[class^="styles__StyledRowWrapper"] div[class^="styles__StyledCardWrapper"]' def extract_rating(element): style = element.get("style") if element else None if not style: return None for prop in style.split(";"): prop = prop.strip() if prop.startswith("width:"): value = prop[len("width:"):].strip() if value.endswith("%"): percentage = float(value[:-1]) return round((percentage / 100) * 5, 2) return None def extract_tcin(href): if href and "/A-" in href: return href.split("/A-")[1].split("?")[0].split("#")[0] return None def scrape_target_listing(html): soup = BeautifulSoup(html, "html.parser") products = soup.select(GRID) results = [] for product in products: try: title_el = product.select_one('a[data-test="product-title"]') rating_el = product.select_one('div[data-ref="rating-mask"]') reviews_el = product.select_one('span[data-test="rating-count"]') price_el = product.select_one('span[data-test="current-price"]') sold_out_el = product.select_one('[data-test="soldOut"]') href = title_el["href"] if title_el else None availability = "Out of stock" if sold_out_el else "In stock" results.append({ "title": title_el.get_text(strip=True) if title_el else None, "price": price_el.get_text(strip=True) if price_el else None, "tcin": extract_tcin(href), "rating": extract_rating(rating_el), "review_count": reviews_el.get_text(strip=True) if reviews_el else None, "availability": availability, "product_url": BASE + href if href else None, }) except Exception as e: print(f"Skipped a card: {e}") return results
商品网格选择器由三段直接来自旧版脚本的部分组成:data-test="product-grid" 容器、行包装器和卡片包装器。在每张卡片内,标题和价格来自各自的 data-test 选择器,评论数来自 rating-count,评分则通过 extract_rating 从遮罩条读取。extract_tcin 辅助函数从 URL 中 /A- 之后读取 Target 的商品标识符,库存状态根据是否存在售罄标记来推断。每个 if el else None 守卫确保在字段缺失时提取不会崩溃,因为并非每张卡片都显示评分或评论数。
Target 生成的类名(如 styles__StyledCardWrapper 前缀)会在没有通知的情况下更改,而其 data-test 和 data-ref 属性则稳定得多。应优先使用属性选择器,并将类前缀链作为起始模板而非固定合约。当某个字段对每张卡片都返回 None 时,在浏览器开发者工具中重新检查实时搜索页面并更新选择器。定期维护选择器对任何生产级爬虫来说都是正常的。
第三步:处理结果页分页
一页结果只是演示;实际工作需要遍历整个结果集。Target 使用 URL 中的 Nao 参数来分页搜索结果,该参数设置每页的起始偏移量。结果以每批 24 条呈现,所以 Nao=1 是第一页,Nao=25 是第二页,依此类推。你递增偏移量,获取每一页,并在没有启用的"下一页"按钮时停止。
PER_PAGE = 24 def has_next_page(html): soup = BeautifulSoup(html, "html.parser") return bool(soup.select_one('button[data-test="next"]:not([disabled])')) def scrape_all_pages(base_url, max_pages=5): all_products = [] for page in range(max_pages): nao = page * PER_PAGE + 1 url = f"{base_url}&Nao={nao}" html = crawl(url) if not html: break found = scrape_target_listing(html) if not found: break all_products.extend(found) print(f"Page {page + 1}: {len(found)} products") if not has_next_page(html): break return all_products
Nao 偏移量由页面索引计算得出,第 0 页请求 Nao=1,第 1 页请求 Nao=25,循环持续直到 has_next_page 找不到启用的 button[data-test="next"]。max_pages 上限和结果为空时的中断,都会提前停止循环,因此只有两页结果的搜索不会发出五次请求。在测试阶段限制页数也有助于控制免费版额度的消耗。
第四步:组装脚本并导出 JSON 和 CSV
现在将获取、解析和分页串联成一个可运行的脚本,然后将记录写入 JSON 和 CSV,以便加载到笔记本或电子表格中。
import csv import json from urllib.parse import quote from crawlbase import CrawlingAPI from bs4 import BeautifulSoup api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) BASE = "https://www.target.com" GRID = 'div[data-test="product-grid"] section[class^="styles__StyledRowWrapper"] div[class^="styles__StyledCardWrapper"]' PER_PAGE = 24 FIELDS = ["title", "price", "tcin", "rating", "review_count", "availability", "product_url"] def crawl(page_url): options = {"ajax_wait": "true", "page_wait": 5000} response = api.get(page_url, options) if response["headers"]["cb_status"] == "200": return response["body"].decode("utf-8") print(f"Request failed: {response['headers']['cb_status']}") return None def extract_rating(element): style = element.get("style") if element else None if not style: return None for prop in style.split(";"): prop = prop.strip() if prop.startswith("width:"): value = prop[len("width:"):].strip() if value.endswith("%"): percentage = float(value[:-1]) return round((percentage / 100) * 5, 2) return None def extract_tcin(href): if href and "/A-" in href: return href.split("/A-")[1].split("?")[0].split("#")[0] return None def scrape_target_listing(html): soup = BeautifulSoup(html, "html.parser") products = soup.select(GRID) results = [] for product in products: try: title_el = product.select_one('a[data-test="product-title"]') rating_el = product.select_one('div[data-ref="rating-mask"]') reviews_el = product.select_one('span[data-test="rating-count"]') price_el = product.select_one('span[data-test="current-price"]') sold_out_el = product.select_one('[data-test="soldOut"]') href = title_el["href"] if title_el else None availability = "Out of stock" if sold_out_el else "In stock" results.append({ "title": title_el.get_text(strip=True) if title_el else None, "price": price_el.get_text(strip=True) if price_el else None, "tcin": extract_tcin(href), "rating": extract_rating(rating_el), "review_count": reviews_el.get_text(strip=True) if reviews_el else None, "availability": availability, "product_url": BASE + href if href else None, }) except Exception as e: print(f"Skipped a card: {e}") return results def has_next_page(html): soup = BeautifulSoup(html, "html.parser") return bool(soup.select_one('button[data-test="next"]:not([disabled])')) def scrape_all_pages(base_url, max_pages=5): all_products = [] for page in range(max_pages): url = f"{base_url}&Nao={page * PER_PAGE + 1}" html = crawl(url) if not html: break found = scrape_target_listing(html) if not found: break all_products.extend(found) print(f"Page {page + 1}: {len(found)} products") if not has_next_page(html): break return all_products def export(rows, name="target_products"): with open(f"{name}.json", "w", encoding="utf-8") as f: json.dump(rows, f, indent=2, ensure_ascii=False) with open(f"{name}.csv", "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=FIELDS) writer.writeheader() writer.writerows(rows) print(f"Saved {len(rows)} products to {name}.json and {name}.csv") def main(): search_term = "womens sweaters" base_url = f"https://www.target.com/s?searchTerm={quote(search_term)}" rows = scrape_all_pages(base_url, max_pages=3) export(rows) if __name__ == "__main__": main()
用 python target_scraper.py 运行完整脚本。它最多遍历三页搜索结果,每个商品解析一行,并同时写入 target_products.json 和 target_products.csv。共享的 FIELDS 列表使 CSV 列顺序与字典键保持一致,确保两种导出格式不会出现偏差。如需抓取不同类别,只需将 search_term 替换为 Target 支持的任何查询,或将 base_url 指向类别页面而非搜索结果。
输出结果是什么样的
你将获得一个按页面顺序排列的商品记录列表,可写入 JSON、CSV 或数据库。
[ { "title": "Women's Fine Gauge Crewneck Sweater - A New Day", "price": "$20.00", "tcin": "88228365", "rating": 3.9, "review_count": "587", "availability": "In stock", "product_url": "https://www.target.com/p/women-s-fine-gauge-crewneck-sweater-a-new-day/-/A-88228365" }, { "title": "Women's Crew Neck Cashmere-Like Pullover Sweater - Universal Thread", "price": "$20.00 - $25.00", "tcin": "88062926", "rating": 4.2, "review_count": "746", "availability": "In stock", "product_url": "https://www.target.com/p/women-s-crew-neck-cashmere-like-pullover-sweater-universal-thread/-/A-88062926" } ]
注意,对于尚无评论的商品,rating 和 review_count 会返回 null,这是正常情况:这些卡片上根本不会渲染评分遮罩条。价格以显示字符串的形式返回,包括 "$20.00 - $25.00" 这样的区间价格,如果你需要比较或绘图,可在下游将其规范化为数字。这正是价格情报工作流程所需的字段集。
跨查询扩展与保持不被屏蔽
单次搜索只是演示;真实的研究工作需要跨多个查询或类别运行。维护一个待抓取搜索词的映射,循环遍历,并控制请求节奏。同样的字段集也适用于单个商品详情页,标题、价格、TCIN、评分和明确的有货或缺货标识都在一个 URL 上。即使渲染问题已解决,Target 仍会监测爬虫流量,因此养成一些好习惯能让长时间运行保持健康,这些习惯适用于任何有挑战性的商业目标。
- 控制请求节奏。在页面和查询之间添加延迟,而不是全速抓取所有内容。将较重的任务安排在非高峰时段,以减轻 Target 服务器的负担。
- 依赖轮换机制。住宅 IP 池将请求分散到众多真实用户地址,使任何单一地址都不会触发限速。Crawling API 为你处理这一切;如果你自己搭建方案,这是最重要的部分。
- 只保留你需要的数据。存储项目所用的商品字段,丢弃其余部分。定期检查选择器,使爬虫能跟上标记变化的步伐。
关于避免屏蔽的更广泛策略,请参阅如何在不被屏蔽的情况下抓取网站,关于渲染为何重要,请参阅如何抓取 JavaScript 网站。同样的模式也适用于其他大型零售商,相关指南包括抓取 Best Buy 商品数据和用 Python 抓取 Walmart 搜索。
抓取 Target 是否合法?
抓取 Target 是否被允许,取决于 Target 的服务条款、你所在的司法管辖区以及你对数据的使用方式。Target 的条款限制了自动访问,因此无论你的工具多么谨慎,抓取行为都可能违反这些条款。本文中的任何代码都不会改变这一点;它只是让技术部分得以实现。请阅读 Target 的服务条款及其 robots.txt,并将两者视为采集边界。
有几条值得坚守的原则。只采集公开数据:任何人在 Target 搜索或商品页面上无需账户即可看到的标题、价格、TCIN、评分和商品链接。保持足够低的请求量,不要给 Target 的服务器造成压力,并避免采集个人数据,包括任何与可识别购物者、评论者或店员相关的信息(超出公开列出的范围)。不要重新发布 Target 的受版权保护的媒体(如商品摄影),如果你计划将数据用于商业目的,请获得许可或正式协议,而不是假设沉默即为同意。
本指南特意将范围限定在公开搜索和商品页面,因为这是使工作具有可辩护性的边界。它不涵盖登录墙后的任何内容、账户或订单数据、支付详情,或任何绕过你无权通过的身份验证或 CAPTCHA 的尝试。Target 运营着附属和合作伙伴计划,并为经批准的合作伙伴提供官方数据源,当你需要大量数据、有保障的结构或商业权利时,应通过官方渠道而非更复杂的爬虫来实现。如果你的项目需要的不只是公开目录数据,正确的途径是官方协议。
核心要点
- Target 的数据是公开的,但需要 JavaScript 渲染。商品网格只有在页面脚本运行后才存在,因此普通请求返回空列表。
-
需要同时进行渲染和受信任的 IP。Crawling API 通过一次调用在住宅 IP 后渲染页面,使用 JavaScript token 加上
ajax_wait和page_wait。 -
依赖 data-test 属性。从 Target 的
data-test和data-ref标记中提取标题、价格、TCIN、评分、评论数和库存状态,并预期生成的类名会发生变化。 -
使用 Nao 偏移量分页。Target 通过
Nao参数以每批 24 条的形式分页展示结果;当没有启用的下一页按钮时停止,并从共享字段列表导出为 JSON 和 CSV。 - 坚守公开数据。遵守 Target 的条款和 robots.txt,对于授权或批量数据优先使用官方合作伙伴源,不触碰账户、订单或个人信息。
常见问题
为什么普通请求从 Target 获取不到商品?
Target 在客户端渲染其搜索网格:第一个 HTML 载荷是一个外壳,商品卡片只有在页面 JavaScript 运行后才会出现。原始的 requests.get() 解析的是那个外壳,什么也找不到,这正是旧版手动尝试返回空列表的原因。通过 Crawling API 在受信任 IP 后渲染页面,同时解决了 JavaScript 和屏蔽两个问题,这就是为什么本文的爬虫将请求路由到它。
什么是 TCIN,如何获取它?
TCIN 是 Target 的内部商品标识符,相当于网站上的 SKU。它出现在每个商品 URL 中 /A- 标记之后,例如 /A-88228365 表示 TCIN 88228365。extract_tcin 辅助函数直接从商品链接中读取它,无需额外请求即可为每件商品获得稳定的键,便于跨时间去重或关联记录。
如何抓取特定的 Target 类别而非搜索结果?
将爬虫指向类别 URL 而非搜索 URL。商品网格、data-test 选择器和 Nao 分页参数在类别页面上的行为与搜索页面完全相同,因此相同的解析器和分页循环可以复用。只需将 main 中的 base_url 替换为你想要的类别,其余脚本保持不变。
如何读取商品库存状态?
在搜索和类别卡片上,缺货商品会显示售罄标记,爬虫通过 [data-test="soldOut"] 检查来检测,并记录为"Out of stock",否则默认为"In stock"。如需精确的按门店库存读取,请抓取单个商品页面,Target 在那里显示清晰的有货或缺货标识,以及与位置相关的配送或自提选项。
为什么输出中某些评分为 null?
null 评分或评论数意味着该商品尚无评论,因此 Target 不会在其卡片上渲染评分遮罩条。extract_rating 辅助函数从条的 CSS 宽度中读取评分,当条不存在时返回 None。这是预期行为,不是选择器失败;新品或低流量商品简单地没有可显示的内容。
如何在抓取 Target 时避免被屏蔽?
保持较低的每 IP 请求速率,在页面和查询之间添加延迟,并通过轮换住宅 IP 路由,使任何单一地址都不会触发限速。Crawling API 为你管理轮换、受信任的 IP 池和 CAPTCHA 处理;如果你自己搭建方案,这是需要投入的部分。观察 API 返回的 cb_status(legacy pc_status) 代码,当开始出现失败时适当退让。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
