去年有个下午,我花了好几个小时跟一个 Headless Chrome 实例较劲,想用它来扒 Google Play 商店的评论数据。验证码、代理费、一周之后 DOM 结构一换,整个脚本返回空数组——我直接把那段代码扔了,心里只有两个字:心累。
等真正冷静下来才发现,自己其实根本不需要浏览器。无论是 Google Play 还是苹果的 App Store,它们对外提供的评论内容,本质上都是以纯 JSON 格式返回的。不需要登录、不需要代理池、更不需要 Playwright 这种重型武器,只要一个干净的 HTTP 请求就能把结构化数据拉下来。这篇文章,我会把这两个平台的请求形态、分页上限这些官方文档里找不到的东西都摊开来讲,以及我实际踩过的一个谷歌埋的坑。
所有示例都用 Node.js 实现,核心依赖就一个:got-scraping。它是对 got 库的直接替换,会完整复制真实浏览器在 TLS 握手阶段的指纹和请求头结构。这份指纹在实际请求中非常值回票价:拿标准的 axios 或者 fetch 发完全相同的 HTTP 请求,有时会被 Google 返回 403;但换成 got-scraping 却能一路畅通。原因很简单——Play 商店在做反爬检测时,看的是 TLS 握手阶段的协商参数,而不是你写在 User-Agent 里的那行字符串。
换个更直白的说法:你的 User-Agent 怎么改,在 Play 面前作用有限;它关注的是你的 TLS 客户端在建立加密连接时所暴露的密码套件、椭圆曲线、扩展顺序和签名算法这些低层指纹。只有把整套指纹模拟到一个真实浏览器的水平,才能绕过那道无声的拦截。这就是 got-scraping 在这里的真正价值——它不是绕过封锁的花招,而是让请求行为变得“不像机器人”。
下面,我把两个平台的做法拆成两个部分来讲,先从苹果开始,因为苹果做得实在太直接了。
苹果 App Store:一个干净的 JSON RSS 端点
苹果把用户评论发布成了一个标准的 RSS Feed,但数据封装格式不是 XML,而是 JSON。所有内容由一个统一的端点对外暴露:
https://itunes.apple.com/{country}/rss/customerreviews/page={1-10}/id={appId}/sortby={mostrecent|mosthelpful}/json这里面有三个需要动态填充的占位符,分别对应不同维度的筛选条件。第一个是 country,它接收的是一个两字母的店面代码,比如 us、gb、de 等等。需要注意,每个店面维护的是属于自己的评论集合,美国和英国用户留下的评价是分开存放的,这对后面我们绕过单店面 500 条上限至关重要。
第二个是 appId,它是一个纯数值的 ID,可以直接从 App Store 的网页 URL 中提取。例如 WhatsApp 的商店地址是:
apps.apple.com/us/app/whatsapp-messenger/id310633997那么 310633997 就是我们要的 appId。很多人在刚开始做数据抓取时,会下意识去解析 HTML 或者模拟点击来获取这个数字,但其实它已经白纸黑字地写在了链接里,连一次额外请求都不需要。
第三个参数是 page,取值范围为 1 到 10。10 就是硬上限,没有任何 hidden token 或 continuation key 能让你翻到第 11 页——我在尝试各种变体之后非常确定这一点。每一页返回 50 条评价,所以一个店面最多只能拿到 500 条最新的评论。
下面是我用来拉取苹果评论的一个封装函数,整个逻辑非常直白:
import { gotScraping } from 'got-scraping';async function fetchAppleReviews(appId, { country = 'us', maxReviews = 200 } = {}) { const out = []; const pages = Math.min(10, Math.ceil(maxReviews / 50)); for (let page = 1; page <= pages; page++) { const url = `https://itunes.apple.com/${country}/rss/customerreviews/page=${page}/id=${appId}/sortby=mostrecent/json`; const res = await gotScraping({ url, responseType: 'json' }); const entries = res.body?.feed?.entry ?? []; for (const e of entries) { // 有些 entry 是应用本身的元数据,不是用户评价,检查 im:rating 字段来过滤 if (!e['im:rating']) continue; out.push({ id: e.id.label, author: e.author.name.label, rating: Number(e['im:rating'].label), title: e.title.label, text: e
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.