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

使用 Puppeteer 生成实时 GitHub 贡献图表,并在您的 Twitter 横幅中实时更新。使用 Puppeteer 生成实时 GitHub 贡献图表,并在您的 Twitter 横幅中实时更新。DEV 全球展示挑战赛,由 Mux 呈现:展示您的项目!

使用 Puppeteer 生成实时 GitHub 贡献图表,并在您的 Twitter 横幅中实时更新。

使用 Puppeteer 生成实时 GitHub 贡献图表,并在您的 Twitter 横幅中实时更新。

由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!

使用 Puppeteer 生成实时 GitHub 贡献图表,并在您的 Twitter 横幅中实时更新。

借助 Node JS 和puppeteer ,构建如此惊人的动态实时更新图像。

https://i.imgur.com/9yPBY9Q.png

介绍

我们通常更喜欢动态生成的内容,它功能更多,感觉也更酷。

下面这张图片就是此类图片的一个例子,它是直接由云函数生成的。

PS:请注意,生成可能需要一些时间,这取决于多种因素。

https://relaxed-joliot-41cdfa.netlify.app/.netlify/functions/unmeta

我们将学习如何使用 Puppeteer,如何自定义内容等等。

让我们直接进入正题。

先决条件

  • 基础 NodeJS
  • TypeScript
  • Twitter开发者账号(如果您需要实时横幅广告自动化功能)
  • 占用您15分钟时间 :)

我们要建造什么?

我们将编写一个脚本来生成这类图像。

你可以在我的推特横幅图片中看到我的实时Github贡献图表。

Twitter:gillarohith

https://i.imgur.com/9yPBY9Q.png

如果我们观察这张图片,会发现它是两张图片的混合体,上面还有一些自定义文字。

发展

本节内容分为多个小节,以便于读者理解。

您可以使用npmoryarnpnpm作为您的包管理器,只需相应地替换命令即可。

接下来的步骤我将使用yarn我的包管理器。

安装应用程序

让我们创建一个文件夹,初始化一个空的节点应用程序。

mkdir github-live-banner
cd github-live-banner
yarn init -y
Enter fullscreen mode Exit fullscreen mode

我们需要puppeteerdotenv因为"dependencies"

dependencies嘘!文章末尾我们还会添加几个,敬请期待。

typescript由于我们将使用 TypeScript ,ts-node因此我们需要nodemon……devDependencies

yarn add puppeteer dotenv

yarn add -D typescript ts-node @types/node nodemon
Enter fullscreen mode Exit fullscreen mode

安装完成后,我们就可以配置脚本了。

"scripts": {
    "start": "node dist/index.js",
    "watch": "tsc -w",
    "dev": "nodemon dist/index.js",
    "build": "tsc",
    "postinstall": "npm run build"
},
Enter fullscreen mode Exit fullscreen mode

watch脚本ts-node以监视模式运行,也就是说,它会监听 typescript 文件中的更改,并.js在我们保存后立即将其编译成文件。在开发期间,您可以让它在后台运行。

dev脚本用于在文件更改后立即nodemon运行该文件。dist/index.js

postinstall并且在部署期间和部署后buildstart需要。

由于我们使用 TypeScript,因此我们需要tsconfig.json一个文件。

您可以使用命令行实用程序函数生成一个。

npx tsconfig.json

如果上述命令不起作用,您可以在下方找到配置文件。

{
  "compilerOptions": {
    "target": "es2017",
    "module": "commonjs",
    "lib": ["dom", "es6", "es2017", "esnext.asynciterable"],
    "skipLibCheck": true,
    "sourceMap": true,
    "outDir": "./dist",
    "moduleResolution": "node",
    "removeComments": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitThis": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "resolveJsonModule": true,
    "baseUrl": "."
  },
  "exclude": ["node_modules"],
  "include": ["./src/**/*.ts"]
}
Enter fullscreen mode Exit fullscreen mode

有了这些,我们就可以开始开发之旅了。

环境文件

如果您想动态更新横幅广告,我们将需要您的 Twitter 账号信息。

您需要按照完全相同的步骤生成所需的凭据,本文中您可以查看Twitter Developer Account相关部分以获取带有图片的详细说明。

开发并部署一个无服务器 Python 应用程序,用于实时更新 Twitter 横幅。

完成以上步骤后,您将得到以下数值。

  • 消费者密钥
  • 消费者秘密
  • 访问令牌
  • 访问令牌密钥

请将.env文件中的详细信息更新如下。

CONSUMER_KEY="your key"
CONSUMER_SECRET="your key"
ACCESS_TOKEN="your key"
ACCESS_TOKEN_SECRET="your key"
Enter fullscreen mode Exit fullscreen mode

使用 Puppeteer 进行屏幕截图

首先,我们需要在截屏之前初始化一个无头 Chrome 实例,为此,以下命令将启动该实例。

const browser = await puppeteer.launch({
        // the flags are useful when we deploy
    args: ["--no-sandbox", "--disable-setuid-sandbox"], 
});
Enter fullscreen mode Exit fullscreen mode

打开浏览器后,我们需要打开一个页面,这可以通过以下命令完成。

const page = await browser.newPage();

我们可以viewport根据清晰度和其他目的来设定尺寸。

await page.setViewport({ width: 1000, height: 800, deviceScaleFactor: 1 });

TL;DR(太长不看)deviceScaleFactor

清晰度越高,deviceScaleFactor清晰度就越高。

页面打开后,我们需要访问所需的页面。

在本教程中,由于我们要制作 GitHub 贡献图作为横幅,让我们进入 GitHub 个人资料页面。

await page.goto(`https://github.com/${GITHUB_USERNAME}`, {
      waitUntil: "networkidle2",
});
Enter fullscreen mode Exit fullscreen mode

现在我们需要等待 GitHub 贡献图表填充完毕,这可以通过选择器来实现。

获取所需的 CSS 选择器

  • 前往开发者控制台
  • 选择要选择的元素
  • 右键单击元素 → 复制 → 复制选择器

https://i.imgur.com/82g9pSq.png

选择器将是

const GITHUB_CONTRIBUTION_SELECTOR =
  "#js-pjax-container > div.container-xl.px-3.px-md-4.px-lg-5 > div > div.flex-shrink-0.col-12.col-md-9.mb-4.mb-md-0 > div:nth-child(2) > div > div.mt-4.position-relative > div > div.col-12.col-lg-10 > div.js-yearly-contributions > div:nth-child(1)";
Enter fullscreen mode Exit fullscreen mode

现在我们让 puppeteer 等待直到选择器加载完成。

await page.waitForSelector(GITHUB_CONTRIBUTION_SELECTOR);

生成完成后,我们选择选择器,然后截屏。

const element = await page.$(GITHUB_CONTRIBUTION_SELECTOR);
  if (element) {
    await element.screenshot({ path: "contributions.png" });
  }
Enter fullscreen mode Exit fullscreen mode

搞定!现在你可以contributions.png在本地文件系统中看到了。

综合起来


import puppeteer from "puppeteer";

const GITHUB_USERNAME = "Rohithgilla12";

const GITHUB_CONTRIBUTION_SELECTOR =
  "#js-pjax-container > div.container-xl.px-3.px-md-4.px-lg-5 > div > div.flex-shrink-0.col-12.col-md-9.mb-4.mb-md-0 > div:nth-child(2) > div > div.mt-4.position-relative > div > div.col-12.col-lg-10 > div.js-yearly-contributions > div:nth-child(1)";

const main = async () => {
    const browser = await puppeteer.launch({
      args: ["--no-sandbox", "--disable-setuid-sandbox"],
    });
    const page = await browser.newPage();
    await page.setViewport({ width: 1000, height: 800, deviceScaleFactor: 1 });

    await page.goto(`https://github.com/${GITHUB_USERNAME}`, {
      waitUntil: "networkidle2",
    });
        await page.waitForSelector(GITHUB_CONTRIBUTION_SELECTOR);
        const element = await page.$(GITHUB_CONTRIBUTION_SELECTOR);
    if (element) {
      await element.screenshot({ path: "contributions.png" });
    }

    await browser.close();

    console.log("Done creating the screenshot");
}

main();
Enter fullscreen mode Exit fullscreen mode

木偶师定制

但现在如果我们仔细观察,会发现截图中有一些地方需要修改。

深色模式

对于深色模式,我们需要模拟深色模式,为此,运行以下命令即可模拟深色模式。

我们需要在访问网站后运行该命令。

await page.emulateMediaFeatures([
    {
      name: "prefers-color-scheme",
      value: "dark",
    },
]);
Enter fullscreen mode Exit fullscreen mode

隐藏不需要的线条

我们采用与第一步中类似的方法来获取该行的 CSS 选择器。

为了帮您省事,我已经为您准备好了 CSS 选择器。

const REMOVE_SELECTOR =
  "#js-pjax-container > div.container-xl.px-3.px-md-4.px-lg-5 > div > div.flex-shrink-0.col-12.col-md-9.mb-4.mb-md-0 > div:nth-child(2) > div > div.mt-4.position-relative > div > div.col-12.col-lg-10 > div.js-yearly-contributions > div:nth-child(1) > div > div > div > div.float-left";
Enter fullscreen mode Exit fullscreen mode

选定元素后,我们自定义 CSS 样式并进行display调整。none

// puppeteer hide the selected element
await page.evaluate((selector) => {
  const element = document.querySelector(selector);
  element.style.display = "none";
}, REMOVE_SELECTOR);
Enter fullscreen mode Exit fullscreen mode

添加边距和内边距

我们需要在贡献选择器周围添加边距和内边距。

const CONTRIBUTION_SELECTOR =
  "#js-pjax-container > div.container-xl.px-3.px-md-4.px-lg-5 > div > div.flex-shrink-0.col-12.col-md-9.mb-4.mb-md-0 > div:nth-child(2) > div > div.mt-4.position-relative > div > div.col-12.col-lg-10 > div.js-yearly-contributions > div:nth-child(1) > h2";

await page.evaluate((selector) => {
  const element = document.querySelector(selector);
  element.style.margin = "8px";
  element.style.paddingTop = "16px";
}, CONTRIBUTION_SELECTOR);
Enter fullscreen mode Exit fullscreen mode

现在,定制选项可以无穷无尽,例如定制颜色、尺寸等等。

把所有东西整合在一起。


import puppeteer from "puppeteer";

const GITHUB_USERNAME = "Rohithgilla12";

const GITHUB_CONTRIBUTION_SELECTOR =
  "#js-pjax-container > div.container-xl.px-3.px-md-4.px-lg-5 > div > div.flex-shrink-0.col-12.col-md-9.mb-4.mb-md-0 > div:nth-child(2) > div > div.mt-4.position-relative > div > div.col-12.col-lg-10 > div.js-yearly-contributions > div:nth-child(1)";

const REMOVE_SELECTOR =
  "#js-pjax-container > div.container-xl.px-3.px-md-4.px-lg-5 > div > div.flex-shrink-0.col-12.col-md-9.mb-4.mb-md-0 > div:nth-child(2) > div > div.mt-4.position-relative > div > div.col-12.col-lg-10 > div.js-yearly-contributions > div:nth-child(1) > div > div > div > div.float-left";

const CONTRIBUTION_SELECTOR =
  "#js-pjax-container > div.container-xl.px-3.px-md-4.px-lg-5 > div > div.flex-shrink-0.col-12.col-md-9.mb-4.mb-md-0 > div:nth-child(2) > div > div.mt-4.position-relative > div > div.col-12.col-lg-10 > div.js-yearly-contributions > div:nth-child(1) > h2";

const main = async () => {
        const browser = await puppeteer.launch({
      args: ["--no-sandbox", "--disable-setuid-sandbox"],
    });
    const page = await browser.newPage();
    await page.setViewport({ width: 1000, height: 800, deviceScaleFactor: 1 });

    await page.goto(`https://github.com/${GITHUB_USERNAME}`, {
      waitUntil: "networkidle2",
    });

    // Dark Mode
    await page.emulateMediaFeatures([
      {
        name: "prefers-color-scheme",
        value: "dark",
      },
    ]);
    await page.waitForSelector(GITHUB_CONTRIBUTION_SELECTOR);

    // puppeteer hide the selected element
    await page.evaluate((selector) => {
      const element = document.querySelector(selector);
      element.style.display = "none";
    }, REMOVE_SELECTOR);

    await page.evaluate((selector) => {
      const element = document.querySelector(selector);
      element.style.margin = "8px";
      element.style.paddingTop = "16px";
    }, CONTRIBUTION_SELECTOR);

    const element = await page.$(GITHUB_CONTRIBUTION_SELECTOR);
    if (element) {
      await element.screenshot({ path: "contributions.png" });
    }

    await browser.close();

    console.log("Done creating the screenshot");
}

main();
Enter fullscreen mode Exit fullscreen mode

修改完成后,截图看起来已经很漂亮了。

节点画布和夏普

现在是时候进行一些变革,融合微调了。

本部分我们需要canvas一些sharp软件包。

yarn add canvas sharp

yarn add -D @types/sharp
Enter fullscreen mode Exit fullscreen mode

现在,如果我们看一下引言部分生成的图像,它包含了以下两幅图像的合并。

https://i.imgur.com/UjrHEEP.png

您可以从https://www.headers.me/获取如此精美的背景图片。

https://i.imgur.com/FkihLle.png

首先,我们需要将图表图像调整到特定大小,使其适合背景图像。

使用锐化功能,我们还可以做很多事情,其中​​之一就是将图像的边角修圆,使其看起来更美观。

那么,我们先来导入这个sharp包。

import sharp from "sharp";

然后用它进行一些神奇的变形。

const beforeResize = await loadImage(filename);
const toResizeWidth = beforeResize.width - 48;
const toResizeHeight = beforeResize.height - 16;
const roundedCorners = Buffer.from(
  `<svg><rect x="0" y="0" width="${toResizeWidth}" height="${toResizeHeight}" rx="16" ry="16"/></svg>`
);
await sharp(filename)
  .resize(toResizeWidth, toResizeHeight)
  .composite([
    {
      input: roundedCorners,
      blend: "dest-in",
    },
  ])
  .toFile(__dirname + `/../rounded_corner.png`);
Enter fullscreen mode Exit fullscreen mode

作为参考,rounded_corner图片看起来会类似于这样

https://i.imgur.com/h0zZ0sN.png

现在,为了完成横幅制作,我们需要完成以下任务。

  • 合并图像
  • 在图片上写文字
  • 返回缓冲区

合并图像

我们并非直接合并它们,而是创建一个画布,然后将一张图片覆盖在另一张图片之上,为此我们使用node-canvas

通常推特横幅广告都会出现1000 X 420,所以我们来创建一个类似大小的画布。

import { createCanvas, loadImage } from "canvas";

const canvas = createCanvas(1000, 420);
const ctx = canvas.getContext("2d");
Enter fullscreen mode Exit fullscreen mode

将我们拥有的图像加载到画布中。

const img = await loadImage(__dirname + `/../rounded_corner.png`);
const base = await loadImage(__dirname + `/../resize_base.png`);
Enter fullscreen mode Exit fullscreen mode

在画布上按你喜欢的位置绘制(插入)图像。

请注意,如果您使用一些自定义尺寸,则可能需要进行一些反复试验。

ctx.drawImage(base, 0, 0);
ctx.drawImage(img, 0, 230);
Enter fullscreen mode Exit fullscreen mode

请注意,0,00,230是图像的坐标。

在图片上写文字

在图片上添加文字是所有步骤中最简单的。

我们选择字体、字号,然后开始写作 :)

ctx.font = "24px Arial";
ctx.fillStyle = "white";
ctx.fillText("(The GitHub contribution chart updated in realtime *)", 0, 60);
Enter fullscreen mode Exit fullscreen mode

0,60是文本必须开始的坐标。

然后我们返回缓冲区。

return canvas.toBuffer();

提示:如果您想要处理png文件或jpeg文件,可以使用createPNGStream模块fs来实现。

代码大致如下

canvas.createPNGStream().pipe(fs.createWriteStream(__dirname +/../output.png));

综上所述,该函数如下所示。

import { createCanvas, loadImage } from "canvas";
import sharp from "sharp";

export const addTextToImage = async (filename: string) => {
  // resize is required only for first time
  //   await sharp("base.png").resize(1000, 420).toFile("resize_base.png");
  const beforeResize = await loadImage(filename);
  const toResizeWidth = beforeResize.width - 48;
  const toResizeHeight = beforeResize.height - 16;
  const roundedCorners = Buffer.from(
    `<svg><rect x="0" y="0" width="${toResizeWidth}" height="${toResizeHeight}" rx="16" ry="16"/></svg>`
  );
  await sharp(filename)
    .resize(toResizeWidth, toResizeHeight)
    .composite([
      {
        input: roundedCorners,
        blend: "dest-in",
      },
    ])
    .toFile(__dirname + `/../rounded_corner.png`);

  const img = await loadImage(__dirname + `/../rounded_corner.png`);
  const base = await loadImage(__dirname + `/../resize_base.png`);

  const canvas = createCanvas(1000, 420);
  const ctx = canvas.getContext("2d");

  ctx.drawImage(base, 0, 0);
  ctx.drawImage(img, 0, 230);
  ctx.font = "24px Arial";
  ctx.fillStyle = "white";
  ctx.fillText("(The GitHub contribution chart updated in realtime *)", 0, 60);

  return canvas.toBuffer();
};
Enter fullscreen mode Exit fullscreen mode

更新推特横幅

现在到了有趣的部分,我们将用我们生成的图片更新我们的推特横幅。

首先,让我们安装 Twitter 软件包。

yarn add twitter
Enter fullscreen mode Exit fullscreen mode

启动 Twitter 客户端。

const TwitterV1 = require("twitter");

const credentials = {
  consumer_key: process.env.CONSUMER_KEY,
  consumer_secret: process.env.CONSUMER_SECRET,
  access_token_key: process.env.ACCESS_TOKEN,
  access_token_secret: process.env.ACCESS_TOKEN_SECRET,
};

const clientV1 = new TwitterV1(credentials); 
Enter fullscreen mode Exit fullscreen mode

Twitter API 接受 格式的横幅base64,因此我们需要将从 canvas 返回的缓冲区转换为base64格式。

const base64 = await addTextToImage(__dirname + `/../contributions.png`);
console.log("Done editing the screenshot!");

clientV1.post(
  "account/update_profile_banner",
  {
    banner: base64.toString("base64"),
  },
  (err: any, _data: any, response: { toJSON: () => any }) => {
    console.log("err", err);
    const json = response.toJSON();
    console.log(json.statusCode, json.headers, json.body);   
  }
);
Enter fullscreen mode Exit fullscreen mode

现在打开你的推特账号,瞧!

定期运行

为了定期运行脚本,我们使用 JavaScriptsetInterval函数。

main();
setInterval(() => {
  main();
}, 1000 * 60 * 2);
Enter fullscreen mode Exit fullscreen mode

现在,该函数将main每 120 秒运行一次。

把所有东西整合起来

import puppeteer from "puppeteer";
import { addTextToImage } from "./imageUtils";
const TwitterV1 = require("twitter");

require("dotenv").config();

const credentials = {
  consumer_key: process.env.CONSUMER_KEY,
  consumer_secret: process.env.CONSUMER_SECRET,
  access_token_key: process.env.ACCESS_TOKEN,
  access_token_secret: process.env.ACCESS_TOKEN_SECRET,
};

const clientV1 = new TwitterV1(credentials);

const GITHUB_USERNAME = "Rohithgilla12";

const GITHUB_CONTRIBUTION_SELECTOR =
  "#js-pjax-container > div.container-xl.px-3.px-md-4.px-lg-5 > div > div.flex-shrink-0.col-12.col-md-9.mb-4.mb-md-0 > div:nth-child(2) > div > div.mt-4.position-relative > div > div.col-12.col-lg-10 > div.js-yearly-contributions > div:nth-child(1)";

const REMOVE_SELECTOR =
  "#js-pjax-container > div.container-xl.px-3.px-md-4.px-lg-5 > div > div.flex-shrink-0.col-12.col-md-9.mb-4.mb-md-0 > div:nth-child(2) > div > div.mt-4.position-relative > div > div.col-12.col-lg-10 > div.js-yearly-contributions > div:nth-child(1) > div > div > div > div.float-left";

const CONTRIBUTION_SELECTOR =
  "#js-pjax-container > div.container-xl.px-3.px-md-4.px-lg-5 > div > div.flex-shrink-0.col-12.col-md-9.mb-4.mb-md-0 > div:nth-child(2) > div > div.mt-4.position-relative > div > div.col-12.col-lg-10 > div.js-yearly-contributions > div:nth-child(1) > h2";

const main = async () => {
  try {
    const browser = await puppeteer.launch({
      args: ["--no-sandbox", "--disable-setuid-sandbox"],
    });
    const page = await browser.newPage();
    await page.setViewport({ width: 1000, height: 800, deviceScaleFactor: 1 });

    await page.goto(`https://github.com/${GITHUB_USERNAME}`, {
      waitUntil: "networkidle2",
    });

    // Dark Mode
    await page.emulateMediaFeatures([
      {
        name: "prefers-color-scheme",
        value: "dark",
      },
    ]);
    await page.waitForSelector(GITHUB_CONTRIBUTION_SELECTOR);

    // puppeteer hide the selected element
    await page.evaluate((selector) => {
      const element = document.querySelector(selector);
      element.style.display = "none";
    }, REMOVE_SELECTOR);

    await page.evaluate((selector) => {
      const element = document.querySelector(selector);
      element.style.margin = "8px";
      element.style.paddingTop = "16px";
    }, CONTRIBUTION_SELECTOR);

    const element = await page.$(GITHUB_CONTRIBUTION_SELECTOR);
    if (element) {
      await element.screenshot({ path: "contributions.png" });
    }

    await browser.close();

    console.log("Done creating the screenshot");

    const base64 = await addTextToImage(__dirname + `/../contributions.png`);
    console.log("Done editing the screenshot!");

    clientV1.post(
      "account/update_profile_banner",
      {
        banner: base64.toString("base64"),
      },
      (err: any, _data: any, response: { toJSON: () => any }) => {
        console.log("err", err);
        const json = response.toJSON();
        console.log(json.statusCode, json.headers, json.body);
      }
    );
  } catch (e) {
    console.error(e);
  }
};

main();
setInterval(() => {
  main();
}, 1000 * 60 * 2);
Enter fullscreen mode Exit fullscreen mode

部署

我们可以直接将其部署到类型herokuworker

在根项目中创建一个文件Procfile,并按如下方式更新其内容。

worker: npm start
Enter fullscreen mode Exit fullscreen mode
heroku create

heroku buildpacks:add jontewks/puppeteer
git push heroku main
heroku ps:scale worker=1
Enter fullscreen mode Exit fullscreen mode

请确保.env在 Heroku 项目的config变量部分添加变量。

如果部署过程中遇到任何问题,请告诉我,必要时我会录制视频 :)

代码

代码位于heroku此仓库的某个分支中。

GitHub - Rohithgilla12/puppeteer-github-banner 在 heroku,下载puppeteer-github-banner 的源码_GitHub_酷徒

其他分支对应不同的部署方法,我很快会进行更新,敬请关注。

请在 GitHub 上为该仓库点赞并关注我,这真的会激励我创作出如此精彩的内容。

下一篇博文

接下来的博客文章将会非常精彩,我已经计划好了一些很棒的内容。

仅举几例:

  • 自己创建 Docker 容器并免费部署!!
  • 创建 Open Graph 图像生成器。
  • 无服务器 Puppeteer 函数 :)

关注我,就不会错过任何更新啦 :D

您可以在推特上关注我:https://twitter.com/gillarohith,获取最新动态。

谢谢

罗希特·吉拉

文章来源:https://dev.to/gillarohith/generate-realtime-github-contribution-chart-using-puppeteer-and-update-it-realtime-in-your-twitter-banner-3l32