使用 Puppeteer 生成实时 GitHub 贡献图表,并在您的 Twitter 横幅中实时更新。
使用 Puppeteer 生成实时 GitHub 贡献图表,并在您的 Twitter 横幅中实时更新。
由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!
使用 Puppeteer 生成实时 GitHub 贡献图表,并在您的 Twitter 横幅中实时更新。
借助 Node JS 和puppeteer ,构建如此惊人的动态实时更新图像。
介绍
我们通常更喜欢动态生成的内容,它功能更多,感觉也更酷。
下面这张图片就是此类图片的一个例子,它是直接由云函数生成的。
PS:请注意,生成可能需要一些时间,这取决于多种因素。
https://relaxed-joliot-41cdfa.netlify.app/.netlify/functions/unmeta
我们将学习如何使用 Puppeteer,如何自定义内容等等。
让我们直接进入正题。
先决条件
- 基础 NodeJS
- TypeScript
- Twitter开发者账号(如果您需要实时横幅广告自动化功能)
- 占用您15分钟时间 :)
我们要建造什么?
我们将编写一个脚本来生成这类图像。
你可以在我的推特横幅图片中看到我的实时Github贡献图表。
Twitter:gillarohith
如果我们观察这张图片,会发现它是两张图片的混合体,上面还有一些自定义文字。
发展
本节内容分为多个小节,以便于读者理解。
您可以使用npmoryarn或pnpm作为您的包管理器,只需相应地替换命令即可。
接下来的步骤我将使用yarn我的包管理器。
安装应用程序
让我们创建一个文件夹,初始化一个空的节点应用程序。
mkdir github-live-banner
cd github-live-banner
yarn init -y
我们需要puppeteer,dotenv因为"dependencies"
dependencies嘘!文章末尾我们还会添加几个,敬请期待。
typescript由于我们将使用 TypeScript ,ts-node因此我们需要nodemon……devDependencies
yarn add puppeteer dotenv
yarn add -D typescript ts-node @types/node nodemon
安装完成后,我们就可以配置脚本了。
"scripts": {
"start": "node dist/index.js",
"watch": "tsc -w",
"dev": "nodemon dist/index.js",
"build": "tsc",
"postinstall": "npm run build"
},
该watch脚本ts-node以监视模式运行,也就是说,它会监听 typescript 文件中的更改,并.js在我们保存后立即将其编译成文件。在开发期间,您可以让它在后台运行。
该dev脚本用于在文件更改后立即nodemon运行该文件。dist/index.js
postinstall并且在部署期间和部署后build都start需要。
由于我们使用 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"]
}
有了这些,我们就可以开始开发之旅了。
环境文件
如果您想动态更新横幅广告,我们将需要您的 Twitter 账号信息。
您需要按照完全相同的步骤生成所需的凭据,本文中您可以查看Twitter Developer Account相关部分以获取带有图片的详细说明。
开发并部署一个无服务器 Python 应用程序,用于实时更新 Twitter 横幅。
完成以上步骤后,您将得到以下数值。
- 消费者密钥
- 消费者秘密
- 访问令牌
- 访问令牌密钥
请将.env文件中的详细信息更新如下。
CONSUMER_KEY="your key"
CONSUMER_SECRET="your key"
ACCESS_TOKEN="your key"
ACCESS_TOKEN_SECRET="your key"
使用 Puppeteer 进行屏幕截图
首先,我们需要在截屏之前初始化一个无头 Chrome 实例,为此,以下命令将启动该实例。
const browser = await puppeteer.launch({
// the flags are useful when we deploy
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
打开浏览器后,我们需要打开一个页面,这可以通过以下命令完成。
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",
});
现在我们需要等待 GitHub 贡献图表填充完毕,这可以通过选择器来实现。
获取所需的 CSS 选择器
- 前往开发者控制台
- 选择要选择的元素
- 右键单击元素 → 复制 → 复制选择器
选择器将是
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)";
现在我们让 puppeteer 等待直到选择器加载完成。
await page.waitForSelector(GITHUB_CONTRIBUTION_SELECTOR);
生成完成后,我们选择选择器,然后截屏。
const element = await page.$(GITHUB_CONTRIBUTION_SELECTOR);
if (element) {
await element.screenshot({ path: "contributions.png" });
}
搞定!现在你可以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();
木偶师定制
但现在如果我们仔细观察,会发现截图中有一些地方需要修改。
- 深色模式🌑
Learn how we count contributions从图片中移除文字。- 在图表周围添加一些内边距和外边距。
深色模式
对于深色模式,我们需要模拟深色模式,为此,运行以下命令即可模拟深色模式。
我们需要在访问网站后运行该命令。
await page.emulateMediaFeatures([
{
name: "prefers-color-scheme",
value: "dark",
},
]);
隐藏不需要的线条
我们采用与第一步中类似的方法来获取该行的 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";
选定元素后,我们自定义 CSS 样式并进行display调整。none
// puppeteer hide the selected element
await page.evaluate((selector) => {
const element = document.querySelector(selector);
element.style.display = "none";
}, REMOVE_SELECTOR);
添加边距和内边距
我们需要在贡献选择器周围添加边距和内边距。
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);
现在,定制选项可以无穷无尽,例如定制颜色、尺寸等等。
把所有东西整合在一起。
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();
修改完成后,截图看起来已经很漂亮了。
节点画布和夏普
现在是时候进行一些变革,融合微调了。
本部分我们需要canvas一些sharp软件包。
yarn add canvas sharp
yarn add -D @types/sharp
现在,如果我们看一下引言部分生成的图像,它包含了以下两幅图像的合并。
您可以从https://www.headers.me/获取如此精美的背景图片。
首先,我们需要将图表图像调整到特定大小,使其适合背景图像。
使用锐化功能,我们还可以做很多事情,其中之一就是将图像的边角修圆,使其看起来更美观。
那么,我们先来导入这个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`);
作为参考,rounded_corner图片看起来会类似于这样
现在,为了完成横幅制作,我们需要完成以下任务。
- 合并图像
- 在图片上写文字
- 返回缓冲区
合并图像
我们并非直接合并它们,而是创建一个画布,然后将一张图片覆盖在另一张图片之上,为此我们使用node-canvas
通常推特横幅广告都会出现1000 X 420,所以我们来创建一个类似大小的画布。
import { createCanvas, loadImage } from "canvas";
const canvas = createCanvas(1000, 420);
const ctx = canvas.getContext("2d");
将我们拥有的图像加载到画布中。
const img = await loadImage(__dirname + `/../rounded_corner.png`);
const base = await loadImage(__dirname + `/../resize_base.png`);
在画布上按你喜欢的位置绘制(插入)图像。
请注意,如果您使用一些自定义尺寸,则可能需要进行一些反复试验。
ctx.drawImage(base, 0, 0);
ctx.drawImage(img, 0, 230);
请注意,0,0和0,230是图像的坐标。
在图片上写文字
在图片上添加文字是所有步骤中最简单的。
我们选择字体、字号,然后开始写作 :)
ctx.font = "24px Arial";
ctx.fillStyle = "white";
ctx.fillText("(The GitHub contribution chart updated in realtime *)", 0, 60);
这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();
};
更新推特横幅
现在到了有趣的部分,我们将用我们生成的图片更新我们的推特横幅。
首先,让我们安装 Twitter 软件包。
yarn add twitter
启动 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);
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);
}
);
现在打开你的推特账号,瞧!
定期运行
为了定期运行脚本,我们使用 JavaScriptsetInterval函数。
main();
setInterval(() => {
main();
}, 1000 * 60 * 2);
现在,该函数将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);
部署
我们可以直接将其部署到类型heroku中worker。
在根项目中创建一个文件Procfile,并按如下方式更新其内容。
worker: npm start
heroku create
heroku buildpacks:add jontewks/puppeteer
git push heroku main
heroku ps:scale worker=1
请确保.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





