我如何在7小时内编写一个PS5猎杀机器人
介绍
目标
研究
站点定义
自定义框架
主循环
发送电子邮件
部署
结论
介绍
我这辈子都没拥有过游戏主机(PSP不算)。随着PS5和Xbox Series X的发布,现在看来是时候入手一台了。我主要关注的是最新的PlayStation,因为它拥有许多独占游戏,例如:《蜘蛛侠》、《最后生还者》、《神秘海域》等等。
不过,我并没有预购,因为这完全是碰运气。一家店发货了,但另一家却说要等到一月份才能到货。我不想过一个没有圣诞礼物的圣诞节,所以我的计划是在开售第一天就抢购。可惜,我手速不够快 :(
有些网店提供订阅电子报的服务,希望能在补货时收到通知。然而,把邮箱地址给他们就等于收到大量的垃圾邮件,而且取消订阅也不一定意味着他们会删除我的邮箱。不久的将来,销售将完全转移到线上。
另一种获得游戏机的方法是向已经购买过的人购买。但是价格……贵了一倍(商店里卖2200)。
我真是气死了!那么多人买游戏机只是为了高价转手卖掉,而那么多人只是想好好玩游戏而已。这就是资本主义,对吧?
目标
幸运的是,我生气的时候动力十足。如果能把这种感觉和编程这项宝贵的技能结合起来实现目标,那就更好了:
圣诞节前买一台PS5
为了帮我解决这个问题,我写了一个机器人,它可以抓取几家波兰网店的PS5产品页面。一旦检测到库存发生变化,它就会通知我,这样我就可以手动去网店购买了。
它只是一个变更检测机器人,而不是自动购买程序。
研究
我的方法基本上是每隔 5 分钟获取一次页面,并检查是否存在指示内容发生变化的字符串。例如,在一种情况下,我检查是否存在“该产品暂时可用”这样的文本,而在另一种情况下,我检查是否存在特征类名。
我选定了7家波兰在线商店。经过一番研究(点击网站并检查网络请求)后,我发现了一些差异,在开始编写代码之前需要考虑这些差异。
-
HTML 与 JSON - 有些网站使用 SSR(服务器端渲染),将所有内容直接嵌入到 HTML 文件中。而有些网站则使用 AJAX 以 JSON 格式获取数据。
-
产品页面不一致- 有些商店甚至还没有 PS5 产品页面,所以他们使用了一个漂亮的着陆页;有些商店有产品页面;还有一家商店两者都没有,所以它唯一的标志就是搜索列表为空。
站点定义
我用 Node.js 和 TypeScript 编写了这个机器人。项目结构如下:
每个店铺都有一个专属的类,这样就可以针对每个店铺进行一些特殊设置。每个店铺的定义如下所示:
// SITE WITH SSR
// Notice it extends from HTML
export class KomputronikDef extends HtmlSiteDef {
protected getConfig(): SiteConfig {
return {
name: 'Komputronik',
url: 'https://www.komputronik.pl/product/701046/sony-playstation-5.html',
};
}
// Notice it receives a Document as a parameter
protected hasUnexpectedChanges(document: Document): boolean {
const phrase = 'Produkt tymczasowo niedostępny.';
const xPathResult = document.evaluate(
`//*[normalize-space() = '${phrase}']`,
document,
null,
ORDERED_NODE_SNAPSHOT_TYPE,
null
);
return xPathResult.snapshotLength === 0;
}
}
每个站点定义都有 2 种方法。
getConfig()- 对于静态数据hasUnexpectedChanges(...)- 这是功能的核心。这里我们检查是否存在特定值,以判断产品是否仍然缺货。请注意,它接收一个Document参数,该参数是一个已解析的 DOM 树,就像在浏览器中一样,因此我们可以使用一些 CSS 选择器,或者像本例中一样,使用 XPath 来查找特定的字符串。
还有一种 JSON 类型的站点定义,看起来几乎完全相同,但它接收的不是Document参数,而是一个 JSON 对象。
// SITE WITH AJAX REQUEST
// Notice it extends from JSON
export class NeonetDef extends JsonSiteDef<NeonetResponse> {
protected getConfig(): SiteConfig {
return {
name: 'Neonet',
url:
'https://www.neonet.pl/graphql?query=query%20landingPageResolver($id:%20Int!)%20%7B%20landingPage:%20landingPageResolver(id:%20$id)%20%7B%20name%20custom_css%20teaser_alt%20teaser_file%20teaser_file_mobile%20show_teaser%20date_from%20clock_type%20modules%20%7B%20id%20position%20type%20parameters%20%7D%20is_outdated%20%7D%0A%7D%0A&variables=%7B%22id%22:1451%7D&v=2.54.0',
};
}
// Notice it receives an object specified
// in the base class JsonSiteDef<NeonetResponse>
protected hasUnexpectedChanges(json: NeonetResponse): boolean {
return !this.hasProperTitle(json) || !this.hasThankYouModule(json);
}
private hasProperTitle(json: NeonetResponse): boolean {
return json.data.landingPage.name === 'Premiera Konsoli Playstation 5';
}
private hasThankYouModule(json: NeonetResponse): boolean {
const module = json.data.landingPage.modules[4];
if (!module) {
return false;
}
/**
* Cannot check all the message, because from the backend we get them encoded
*/
const lastPartOfMessage = 'w celu uzyskania dalszych aktualizacji.';
return module.id === 7201 && module.parameters.includes(lastPartOfMessage);
}
}
自定义框架
如果您注意到,这里有两个基类HtmlSiteDef,JsonSiteDef它们都会获取网站并生成 JSON 对象的 DOM 树。以下是一个示例HtmlSiteDef。
// Notice it also extends from SiteDef
export abstract class HtmlSiteDef extends SiteDef {
protected async _internalTriggerChanges(): Promise<void> {
// we fetch a page
const body = await this.getBodyFor(
this.config.url,
this.config.cookie,
'html'
);
// we create a DOM tree
const dom = new JSDOM(body);
// we invoke an abstract method implemented by a child class
const somethingChanged = this.hasUnexpectedChanges(dom.window.document);
if (!somethingChanged) {
this.logger.info(`Nothing changed...`);
} else {
this.logger.warn(`-----------------------------------`);
this.logger.warn(`SOMETHING CHANGED!!!`);
this.logger.warn(`-----------------------------------`);
// we also send an email
this.sendSuccessMail();
}
}
// here we define a method to be implemented per site definition
protected abstract hasUnexpectedChanges(document: Document): boolean;
}
它们还有一个名为 `getPage` 的基类SiteDef。它主要负责获取页面并发送成功邮件,或者在出现某些异常情况(例如 IP 地址被屏蔽、响应统计信息无效等)时发送错误邮件。
export abstract class SiteDef {
// the config from the child class
protected config = this.getConfig();
protected logger = getLogger(this.config.name);
// more on sending a mail later
protected mailSender = new MailSender();
// flags for sending an email,
// we want to send email only once, so that it's not treated as spam
private alreadySentMail = false;
private alreadySentErrorMail = false;
// classes for children to implement
protected abstract getConfig(): SiteConfig;
protected abstract _internalTriggerChanges(): Promise<void>;
// main method invoked every 5 minutes
async triggerChanges(): Promise<void> {
try {
await this._internalTriggerChanges();
this.alreadySentErrorMail = false;
} catch (e) {
this.logger.error(e);
if (!this.alreadySentErrorMail) {
this.alreadySentErrorMail = true;
this.mailSender.sendError(this.config.name, e);
}
}
}
protected async getBodyFor(
url: string,
cookie: string,
type: 'json' | 'html'
): Promise<string> {
// we need to spoof the headers, so the request looks legitimate
const response = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0',
Accept: type === 'html' ? 'text/html' : 'application/json',
'Accept-Language': 'en-GB,en;q=0.5',
Referer: 'https://www.google.com/',
Pragma: 'no-cache',
'Cache-Control': 'no-cache',
'Accept-Encoding': 'gzip, deflate, br',
Cookie: cookie ?? null,
},
});
return await response.text();
}
protected sendSuccessMail(): void {
if (!this.alreadySentMail) {
this.alreadySentMail = true;
this.mailSender.send(this.config.name);
}
}
}
主循环
我们内部index.ts只是每 5 分钟循环一次站点列表。
// 5 minutes
const TIMEOUT = 5 * 60 * 1000;
// list of all the supported sites
const sites: SiteDef[] = [
new MediaMarktDef(),
new MediaExpertDef(),
new NeonetDef(),
new EuroDef(),
new EmpikDef(),
new AvansDef(),
new KomputronikDef(),
];
function sleep(timer: number): Promise<void> {
return new Promise<void>((resolve) => setTimeout(() => resolve(), timer));
}
// the main infinite loop
async function main() {
while (true) {
for (const site of sites) {
await site.triggerChanges();
}
console.log('------------- SLEEPING -------------');
await sleep(TIMEOUT);
}
}
main();
发送电子邮件
我一开始想写个手机应用来发送自定义通知,但其实只需给我的Gmail邮箱发封邮件就能实现同样的功能,邮件里的内容就会在我的手机上显示通知。真棒!
为此,我主要使用Sendgrid,因为它有一个每天可发送 100 封邮件的免费套餐,这比我需要的数量多 100 倍。
集成过程非常简单。我只用了不到15分钟就成功发送了第一封邮件。
1. 自定义 DNS 条目
Sendgrid 需要通过添加一些 DNS 条目来验证自定义域名。幸运的是,我的域名托管在Cloudflare ,所以验证过程非常轻松。
2. 下载 Node 库
它们有一个专用库,可以通过以下命令安装:
npm install --save @sendgrid/mail
然后,我在此基础上创建了一个MailSender包装类,你可能在SiteDef课堂上已经注意到了。
// we set api key created in the sendgrid app
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
export class MailSender {
send(siteName: string): void {
const mailData: MailDataRequired = {
to: process.env.TARGET_MAIL,
from: process.env.SENDGRID_MAIL,
subject: `[ps5-bot] ${siteName} has changed`,
text: `${siteName} has changed`,
};
sgMail
.send(mailData)
.then(() => {
logger.info('Mail sent');
})
.catch((error) => {
logger.warn(error);
});
}
sendError(siteName: string, error: Error): void {
const mailData: MailDataRequired = {
to: process.env.TARGET_MAIL,
from: process.env.SENDGRID_MAIL,
subject: `[ps5-bot] ERROR in ${siteName}`,
text: `${error.stack}`,
};
sgMail
.send(mailData)
.then(() => {
logger.info('Mail sent');
})
.catch((error) => {
logger.warn(error);
});
}
}
它非常简单,只有两个方法,一个用于发送成功邮件,另一个用于发送错误邮件。错误信息还会包含异常的堆栈跟踪,以便我了解是哪部分代码出错了。以下是错误邮件的屏幕截图。
您还可以注意到,该机器人使用了敏感数据,例如:SENDGRID_API_KEY,,通过环境变量实现。没有任何硬编码的内容。SENDGRID_MAILTARGET_MAIL
部署
我原本打算搭建一个流水线,构建一个 Docker 镜像,将其上传到 DockerHub,然后使用我树莓派上的 Terraform 将其部署到 Kubernetes 集群。不过,这样做有点过于复杂了。我希望这个机器人能在接下来的几周内完成它的工作,然后就可以不用管它了,所以流水线不需要太复杂。
这就是为什么我决定手动通过 SSH 连接到我的 Raspberry Pi,拉取代码仓库,然后运行 Docker 镜像。所有操作都手动完成。
首先,我创建了一个Dockerfile
FROM node:14.15-alpine as builder
WORKDIR /usr/app/ps5-bot
COPY ./package.json ./package-lock.json ./
RUN npm set progress=false
RUN npm ci
COPY . .
RUN npm run build
# -----------
FROM node:14.15-alpine
WORKDIR /usr/app/ps5-bot
COPY --from=builder /usr/app/ps5-bot/build build
COPY --from=builder /usr/app/ps5-bot/node_modules node_modules
ENTRYPOINT ["node", "./build/main/index.js"]
然后,docker-compose.yml我可以快速地让它运行起来。
version: '3'
services:
ps5-bot:
build:
context: .
restart: always
env_file:
- .env
我使用 Docker Compose CLI 来运行它:
docker-compose up -d
存储库:
结论
创建这个机器人花了我7个小时:
- 5 小时的研究和实施
- 1 小时配置和集成 Sendgrid
- 1 小时配置部署
我对目前的成果相当满意。这个机器人每 5 分钟抓取 7 个页面,查找更新,一旦发现更新就会给我发邮件。它目前部署在我的 Raspberry Pi 上,运行在 Docker 容器中。
现在我只需要耐心等待邮件了 :)
请务必关注我,以便及时了解该项目的进展情况。
再见。
文章来源:https://dev.to/humberd/how-i-wrote-a-ps5-hunter-bot-in-7-hours-6j4









