Nodejs使用puppeteer抓取iOS商店后台APP评论

需求

就是忽然有一天被拉进群,然后说要抓评论,还说后续要搞自动回复。因为苹果没有提供对应的api,后端搞不定登录态,所以决定搞前端。

主要问题

登录态 、 appId的双重验证

主要技术栈

nodejs、puppeteer

实现思路

用puppeteer模拟用户操作登录,获取登录态之后访问对应的获取评论接口,读取返回json后传给后端存储。

准备

设备:Linux + CentOS 7(必要,6不行) + 海外出口(非必要)
账号:ios后台账号及客服支持权限

开始操作

1、基础代码

用来测试安装成不成功

import puppeteer from "puppeteer";
let browser = await puppeteer.launch({
    // headless: false, // 本地调试可打开这行看到浏览器
    args: ["--no-sandbox", "--disable-setuid-sandbox"],
    defaultViewport: {
      width: 1200,
      height: 720,
    },
});
2、安装puppeteer

如果是海外出口的话,基本就是一个 npm install puppeteer --save 就完事了。如果是国内出口的话,就多搜搜帖子吧,幸运的女神会照料你的。

3、安装系统相关的包

当你运行puppeteer,出现类似下面的信息时,就说明需要安装/升级一些包。这里推荐一个网站,可以查找报错的资源属于哪个资源包,减少你的安装次数。
在这里插入图片描述
(1)复制绿框的部分,打开https://rpmfind.net/linux/rpm2html/search.php?query=&submit=Search+…&system=centos&arch=,复制进去搜索,找到对应的资源包名
Nodejs使用puppeteer抓取iOS商店后台APP评论_第1张图片
(2)yum安装

yum search libXtst
yum install 找到的对应的版本

上面提到,为什么CentOS 7是必须的呢?因为用到的浏览器的系统依赖里面有一个包是没有适用CentOS6的。

4、学习puppeteer语法

这里列举这次用到的主要方法,对puppeteer很熟悉的可以忽略。

打开浏览器
let browser = await puppeteer.launch({
    // headless: false, // 本地调试可打开这行看到浏览器
    args: ["--no-sandbox", "--disable-setuid-sandbox"],
    defaultViewport: {
      width: 1200,
      height: 720,
    },
  });
新建页签
this.page = await browser.newPage()
设置agent
await this.page.setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.0 Safari/537.36")
页面跳转
await this.page.goto("https://appstoreconnect.apple.com/")
输入字符
// 主iframe内输入
await this.page.type("#account_name", "[email protected]")
// 子iframe内输入
await this.page.frames()[1].type("#account_name", "[email protected]")
点击
// 主iframe内点击
await this.page.click("#sign-in")
// 子iframe内点击
await this.page.frames()[1].click("#sign-in")
获取页面链接
const pageUrl = await this.page.url();
监听页面请求/返回
this.page.on("response", callbackFn);
this.page.on("request", callbackFn);

例如:获取账号信息

export const getAccountList = function () {
  return new Promise((resolve, reject) => {
    const callbackFn = async function (response: any) {
      if (response.url() == "https://appstoreconnect.apple.com/olympus/v1/session" && response.request().method() == "GET") {
        page.removeListener("response", callbackFn);
        log("获取账号列表成功");
        let realJson = await response.json().catch((e: any) => {
          reject(e);
        });
        resolve(realJson);
      }
    };
    page.on("response", callbackFn);
  });
};
5、写抓取代码

写之前需要先要确认操作流程。正常用户的操作流程如下:
Nodejs使用puppeteer抓取iOS商店后台APP评论_第2张图片
但是作为脚本,其实我们可以通过拼接数据的方式,直接访问接口拿到评论。因此最终确定的流程如下:
Nodejs使用puppeteer抓取iOS商店后台APP评论_第3张图片
获取账号信息接口:https://appstoreconnect.apple.com/olympus/v1/session
Nodejs使用puppeteer抓取iOS商店后台APP评论_第4张图片

获取APP列表信息接口:https://appstoreconnect.apple.com/iris/v1/apps
Nodejs使用puppeteer抓取iOS商店后台APP评论_第5张图片

获取评论信息接口(最新100条):https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/ra/apps/[appID]/platforms/ios/reviews?sort=REVIEW_SORT_ORDER_MOST_RECENT

// 假设已经登录完成
var res = [];

await Promise.all(["跳转到https://appstoreconnect.apple.com/","获取账号列表"])

for(账号列表){
	执行对应的点击切换账号操作
	await Promise.all(["等待切换","获取账号信息"])
	if(判断是否有app){
		await Promise.all(["等待跳转到/apps","获取app列表信息"])
		await 新开页签page1
		page1.on("response",function(){
			res.push(结果)
		}) 
		for(app列表){
			await 请求获取评论信息接口
		}
	}
}

console.log(res);

关于操作流程解析 & 总结:

(1)为什么依然要保留点击选择账号这个操作呢?

苹果那边应该是对账号有做一定的访问权限管理:在当前账号下,是无法读取其他账号下的app相关信息的,因此需要保留操作以获得权限。

(2)如何获取app列表信息?

某一账号下的app列表信息接口是在https://appstoreconnect.apple.com/apps中请求的,如果不是自行发起请求的话需要进入到这个界面(点击【我的APP】就可以进入)。但是如果你的账号里没有app,是没有【我的APP】的入口的,进入这个页面也会被重定向到https://appstoreconnect.apple.com/

解决这个问题可以通过【获取账号信息接口】中的【modules[0].key】是否apps来判断能否跳转。这个值其实就是这个按钮的跳转地址(这里要感谢苹果大佬把整个页面都弄成接口配置,也就是所有信息内容都是读的接口)
Nodejs使用puppeteer抓取iOS商店后台APP评论_第6张图片虽然说这个跳转的问题不是很大,但是它会影响你切换的操作,因为/和/apps下切换账号的按钮的结构是不一样的……
/的Nodejs使用puppeteer抓取iOS商店后台APP评论_第7张图片
/apps的
Nodejs使用puppeteer抓取iOS商店后台APP评论_第8张图片

6、登录态的解决

登录这块流程就和正常操作一样,点击、输入、点击、输入。需要解决的特殊问题如下:

(1)如何判断可以进行下一步操作?

这个问题其实在整个过程中都会遇到,因为很多操作它会有请求的发生,但却没有页面跳转的发生。解决的方案就是写个方法来判断一下页面的请求是否在一定时间内(如3s)没有更新状态。

// 使用
requestFn.listen();
await this.page.frames()[1].click("#sign-in").catch(fetchErr);
await requestFn.testEnd();
// requestFn.js 节选

// 每次更新请求/状态,就会重新计时
Object.defineProperty(options, "requestCount", {
  set(newValue) {
    clearTimeout(timer);
    _startCountZeroTime();
    requestCount = newValue;
  },
  get() {
    return requestCount;
  },
});

// 计时器,如果3秒之后没有新的更新了,就执行resolve回调
const _startCountZeroTime = function () {
  if (timer) {
    timer = null;
  }
  timer = setTimeout(async () => {
    listenState = true;
    if (listenResolve) {
      _resetListenFn();
      listenResolve();
    }
  }, 3 * 1000);
};

// 新请求+1
const _requestCallFn = function (request: any) {
  if (_filterRequestType(request.resourceType())) {
    options.requestCount++;
  }
};

// 完成请求-1
const _requestfinishedCallFn = function (request: any) {
  if (_filterRequestType(request.resourceType())) {
    options.requestCount--;
  }
};

// 新地址=1
const _framenavigatedCallFn = function (frame: any) {
  if (!frame.parentFrame()) {
    options.requestCount = 1;
  }
};

const _resetListenFn = function () {
  listenState = false;
  page.removeListener("request", _requestCallFn);
  page.removeListener("requestfinished", _requestfinishedCallFn);
  page.removeListener("framenavigated", _framenavigatedCallFn);
  log("请求结束");
};

export const listen = async function () {
  if (!page) return;
  options.requestCount = 0;
  listenResolve = null;
  listenState = false;
  clearTimeout(timer);
  // 添加监听方法
  page.on("request", _requestCallFn);
  page.on("requestfinished", _requestfinishedCallFn);
  // 这个是重定向监听,如果重定向,则需要重置请求计数状态
  page.on("framenavigated", _framenavigatedCallFn);
};

export const testEnd = async function () {
  await new Promise((resolve) => {
    listenResolve = resolve;
    // 如果在调用的时候已经完成,就resolve,如果还没有,就等完成后由其他地方执行
    if (listenState) { 
      _resetListenFn();
      resolve();
    }
  });
};
(2)需要双重验证的时候怎么办?

思路:promise等待1分钟验证码,然后通过手动调用接口的方式获取验证码

const codePromise = function () {
	return new Promise((coderesolve, codereject) => {
		this.codePromiseFn = coderesolve;
		this.codePromiseTimer = setTimeout(() => {
			codereject("没有验证码");
		}, 1000 * 60);
	}).catch((e) => {throw e;});
};

let code: any = await codePromise().catch(fetchErr);

curl http://localhost/code?code=123456
code(nums){
	clearTimeout(this.codePromiseTimer);
	this.codePromiseFn(nums)
}
(3)如何保持登录态

思路:将cookies信息保存成文件,下次跳转页面前设置cookies。

// 判断是否有cookies文件,有的话就设置
if (await cookiesFn.checkCookieFile()) {
	log("has cookies");
	await cookiesFn.loadCookies(this.page);
}

this.page.goto("https://appstoreconnect.apple.com/")

// 登录成功后获取cookies并保存
log("登录成功");
let cookies = await this.page.cookies().catch(fetchErr);
cookiesFn.saveCookies(cookies);
// cookies.js
const cookiesUrl = "../cookies/app.json";

export const loadCookies = async function (page: any) {
  const cookies = JSON.parse(fs.readFileSync(cookiesUrl).toString());
  for (let index = 0; index < cookies.length; index++) {
    const cookie = cookies[index];
    await page.setCookie(cookie);
  }
};

export const saveCookies = function (cookies: any) {
  fs.writeFileSync(cookiesUrl, JSON.stringify(cookies, null, 2));
  log(`更新cookies时间:${new Date()}`);
};

export const checkCookieFile = function () {
  return new Promise((resolve) => {
    fs.stat(cookiesUrl, function (err, stat) {
      log("检测完毕");
      if (stat && stat.isFile()) {
        resolve(true);
      } else {
        resolve(false);
      }
    });
  });
};

(4)如何绕过appId的双重验证

对不起臣妾办不到,但是臣妾能尽量少被验证。苹果后台的登录态经实践有效期大约在1天左右。绕不过双重验证的原因是因为每次打开浏览器,都会被认为是新的设备,原因暂时不明,因此无法从技术上解决这个问题。

不过换个思路,既然你认为都是新的,那我以空间换次数,我不关浏览器了,不就一直是同一个了么。事实证明这个方法是可行的,不过一直开着浏览器不知道会不会有什么问题,这个还有待验证,等我验证完了再回来更新。

写在最后

其实如果要抓取评论,可以不需要登录态,苹果商店的接口就可以获取,只要你知道上线地区和对应的id就可以(id好像还是得从后台找)。

https://amp-api.apps.apple.com/v1/catalog/【地区】/apps/【appid】/reviews?l=zh-Hant-TW&offset=10&platform=web&additionalPlatforms=appletv%2Cipad%2Ciphone%2Cmac

写在最后的后面

如有错误的地方欢迎大家指出,有更好的方案或者实践欢迎分享一起讨论!

你可能感兴趣的:(小实践,javascript,node.js)