发布于 2026-01-06 1 阅读
0

我如何在7小时内编写一个PS5猎杀机器人 引言 目标 研究 站点定义 自定义框架 主循环 发送电子邮件 部署 结论

我如何在7小时内编写一个PS5猎杀机器人

介绍

目标

研究

站点定义

自定义框架

主循环

发送电子邮件

部署

结论

介绍

我这辈子都没拥有过游戏主机(PSP不算)。随着PS5和Xbox Series X的发布,现在看来是时候入手一台了。我主要关注的是最新的PlayStation,因为它拥有许多独占游戏,例如:《蜘蛛侠》、《最后生还者》、《神秘海域》等等。

不过,我并没有预购,因为这完全是碰运气。一家店发货了,但另一家却说要等到一月份才能到货。我不想过一个没有圣诞礼物的圣诞节,所以我的计划是在开售第一天就抢购。可惜,我手速不够快 :(

有些网店提供订阅电子报的服务,希望能在补货时收到通知。然而,把邮箱地址给他们就等于收到大量的垃圾邮件,而且取消订阅也不一定意味着他们会删除我的邮箱。不久的将来,销售将完全转移到线上。

网店中的新闻简讯注册表单

另一种获得游戏机的方法是向已经购买过的人购买。但是价格……贵了一倍(商店里卖2200)。

Allegro PS5 价格列表

我真是气死了!那么多人买游戏机只是为了高价转手卖掉,而那么多人只是想好好玩游戏而已。这就是资本主义,对吧?

目标

幸运的是,我生气的时候动力十足。如果能把这种感觉和编程这项宝贵的技能结合起来实现目标,那就更好了:

圣诞节前买一台PS5

为了帮我解决这个问题,我写了一个机器人,它可以抓取几家波兰网店的PS5产品页面。一旦检测到库存发生变化,它就会通知我,这样我就可以手动去网店购买了。

它只是一个变更检测机器人,而不是自动购买程序。

以下是它的预览图:
PS5机器人演示GIF

研究

我的方法基本上是每隔 5 分钟获取一次页面,并检查是否存在指示内容发生变化的字符串。例如,在一种情况下,我检查是否存在“该产品暂时可用”这样的文本,而在另一种情况下,我检查是否存在特征类名。

我选定了7家波兰在线商店。经过一番研究(点击网站并检查网络请求)后,我发现了一些差异,在开始编写代码之前需要考虑这些差异。

  1. HTML 与 JSON - 有些网站使用 SSR(服务器端渲染),将所有内容直接嵌入到 HTML 文件中。而有些网站则使用 AJAX 以 JSON 格式获取数据。

  2. 产品页面不一致- 有些商店甚至还没有 PS5 产品页面,所以他们使用了一个漂亮的着陆页;有些商店有产品页面;还有一家商店两者都没有,所以它唯一的标志就是搜索列表为空。

    我们只能检查列表中Avans是否没有 PS5。
    清单中没有PS5的Avans商店

    我们MediaMarkt只能看到一个登录页面。
    带有 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

每个站点定义都有 2 种方法。

  1. getConfig()- 对于静态数据
  2. 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

自定义框架

如果您注意到,这里有两个基类HtmlSiteDefJsonSiteDef它们都会获取网站并生成 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;
}
Enter fullscreen mode Exit fullscreen mode

它们还有一个名为 `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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

主循环

我们内部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();
Enter fullscreen mode Exit fullscreen mode

发送电子邮件

我一开始想写个手机应用来发送自定义通知,但其实只需给我的Gmail邮箱发封邮件就能实现同样的功能,邮件里的内容就会在我的手机上显示通知。真棒!

为此,我主要使用Sendgrid,因为它有一个每天可发送 100 封邮件的免费套餐,这比我需要的数量多 100 倍。

集成过程非常简单。我只用了不到15分钟就成功发送了第一封邮件。

1. 自定义 DNS 条目

Sendgrid 需要通过添加一些 DNS 条目来验证自定义域名。幸运的是,我的域名托管在Cloudflare ,所以验证过程非常轻松。

以下是我收到的由 Sendgrid 提供的资料。
SendGrid DNS 条目

这里是我存放 Cloudflare 相关条目的地方
Cloudflare DNS 条目

2. 下载 Node 库

它们有一个专用库,可以通过以下命令安装:

npm install --save @sendgrid/mail
Enter fullscreen mode Exit fullscreen mode

然后,我在此基础上创建了一个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);
      });
  }
}
Enter fullscreen mode Exit fullscreen mode

它非常简单,只有两个方法,一个用于发送成功邮件,另一个用于发送错误邮件。错误信息还会包含异常的堆栈跟踪,以便我了解是哪部分代码出错了。以下是错误邮件的屏幕截图。

错误邮件屏幕

您还可以注意到,该机器人使用了敏感数据,例如: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"]
Enter fullscreen mode Exit fullscreen mode

然后,docker-compose.yml我可以快速地让它运行起来。

version: '3'
services:
  ps5-bot:
    build:
      context: .
    restart: always
    env_file:
      - .env
Enter fullscreen mode Exit fullscreen mode

我使用 Docker Compose CLI 来运行它:

docker-compose up -d
Enter fullscreen mode Exit fullscreen mode

最终结果如下:
PS5机器人演示GIF

存储库:

GitHub 标志 Humberd / ps5-bot

用于爬取热门波兰商店并检查PS5库存的机器人

结论

创建这个机器人花了我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