就是忽然有一天被拉进群,然后说要抓评论,还说后续要搞自动回复。因为苹果没有提供对应的api,后端搞不定登录态,所以决定搞前端。
登录态 、 appId的双重验证
nodejs、puppeteer
用puppeteer模拟用户操作登录,获取登录态之后访问对应的获取评论接口,读取返回json后传给后端存储。
设备:Linux + CentOS 7(必要,6不行) + 海外出口(非必要)
账号:ios后台账号及客服支持权限
用来测试安装成不成功
import puppeteer from "puppeteer";
let browser = await puppeteer.launch({
// headless: false, // 本地调试可打开这行看到浏览器
args: ["--no-sandbox", "--disable-setuid-sandbox"],
defaultViewport: {
width: 1200,
height: 720,
},
});
如果是海外出口的话,基本就是一个 npm install puppeteer --save 就完事了。如果是国内出口的话,就多搜搜帖子吧,幸运的女神会照料你的。
当你运行puppeteer,出现类似下面的信息时,就说明需要安装/升级一些包。这里推荐一个网站,可以查找报错的资源属于哪个资源包,减少你的安装次数。
(1)复制绿框的部分,打开https://rpmfind.net/linux/rpm2html/search.php?query=&submit=Search+…&system=centos&arch=,复制进去搜索,找到对应的资源包名
(2)yum安装
yum search libXtst
yum install 找到的对应的版本
上面提到,为什么CentOS 7是必须的呢?因为用到的浏览器的系统依赖里面有一个包是没有适用CentOS6的。
这里列举这次用到的主要方法,对puppeteer很熟悉的可以忽略。
let browser = await puppeteer.launch({
// headless: false, // 本地调试可打开这行看到浏览器
args: ["--no-sandbox", "--disable-setuid-sandbox"],
defaultViewport: {
width: 1200,
height: 720,
},
});
this.page = await browser.newPage()
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);
});
};
写之前需要先要确认操作流程。正常用户的操作流程如下:
但是作为脚本,其实我们可以通过拼接数据的方式,直接访问接口拿到评论。因此最终确定的流程如下:
获取账号信息接口:https://appstoreconnect.apple.com/olympus/v1/session
获取APP列表信息接口:https://appstoreconnect.apple.com/iris/v1/apps
获取评论信息接口(最新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);
关于操作流程解析 & 总结:
苹果那边应该是对账号有做一定的访问权限管理:在当前账号下,是无法读取其他账号下的app相关信息的,因此需要保留操作以获得权限。
某一账号下的app列表信息接口是在https://appstoreconnect.apple.com/apps中请求的,如果不是自行发起请求的话需要进入到这个界面(点击【我的APP】就可以进入)。但是如果你的账号里没有app,是没有【我的APP】的入口的,进入这个页面也会被重定向到https://appstoreconnect.apple.com/
解决这个问题可以通过【获取账号信息接口】中的【modules[0].key】是否apps来判断能否跳转。这个值其实就是这个按钮的跳转地址(这里要感谢苹果大佬把整个页面都弄成接口配置,也就是所有信息内容都是读的接口)
虽然说这个跳转的问题不是很大,但是它会影响你切换的操作,因为/和/apps下切换账号的按钮的结构是不一样的……
/的
/apps的
登录这块流程就和正常操作一样,点击、输入、点击、输入。需要解决的特殊问题如下:
这个问题其实在整个过程中都会遇到,因为很多操作它会有请求的发生,但却没有页面跳转的发生。解决的方案就是写个方法来判断一下页面的请求是否在一定时间内(如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();
}
});
};
思路: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)
}
思路:将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);
}
});
});
};
对不起臣妾办不到,但是臣妾能尽量少被验证。苹果后台的登录态经实践有效期大约在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
如有错误的地方欢迎大家指出,有更好的方案或者实践欢迎分享一起讨论!