🔥 将 NextJS 提升到新的水平:创建 GitHub 星星监视器 🤯
在本文中,您将学习如何创建GitHub 星星监视器来检查您几个月来的星星数以及每天获得的星星数。
- 使用 GitHub API 获取当前每天收到的星星数量。
- 在屏幕上绘制每天星星的精美图表。
- 创建一项工作来每天收集新星星。
您的后台工作平台🔌
Trigger.dev 是一个开源库,可让您使用 NextJS、Remix、Astro 等为您的应用程序创建和监控长时间运行的作业!
请帮我们点个星🥹。
这有助于我们创作更多类似的文章💖
这是你需要知道的😻
获取 GitHub 上的星星数量的大部分工作将通过 GitHub API 完成。
GitHub API 有一些限制:
- 每个请求最多 100 位观星者
- 最大并发请求数:100
- 每小时最多 60 个请求
TriggerDev存储库中有超过 5000 颗星,实际上不可能在合理的时间内(实时)统计出所有的星。
因此,我们将使用与GitHub Stars History相同的技巧。
- 获取星号总数(5,715)除以每页100 个结果 = 58 页
- 设置我们想要的最大请求数量(最多 20 页)除以58 页= 跳过 3 页。
- 从这些页面中提取星星(2000 颗星),然后提取剩余的星星,我们将按比例添加到其他日期(3715 颗星)。
它会为我们绘制一个漂亮的图表,并在需要的地方添加星星。
当我们每天获取新的星星数量时,事情会变得容易得多。
我们只需用当前星星总数减去 GitHub 上新增的星星数量即可。这样我们就不需要再迭代星星了。
让我们开始吧🔥
我们的应用程序将包含一个页面:
- 添加您想要监控的存储库。
- 查看存储库列表及其 GitHub 星图。
- 删除那些你不再需要的。
💡 我们将使用 NextJS 新应用路由器,请在安装项目之前确保您拥有节点版本 18+。
使用 NextJS 设置新项目
npx create-next-app@latest
我们必须将所有星星保存到我们的数据库中!
对于我们的演示,我们将使用 SQLite Prisma
。
安装非常简单,但也可以随意使用任何其他数据库。
npm install prisma @prisma/client --save
在我们的项目中安装 Prisma
npx prisma init --datasource-provider sqlite
转至prisma/schema.prisma
并将其替换为以下模式:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Repository {
id String @id @default(uuid())
month Int
year Int
day Int
name String
stars Int
@@unique([name, day, month, year])
}
然后运行
npx prisma db push
我们基本上在 SQLite 数据库中创建了一个名为的新表Repository
:
month
,,是日期year
。day
name
存储库的名称stars
以及该特定日期的星星数量。
您还可以看到我们@@unique
在底部添加了一个,这意味着我们可以同时拥有name
、month
、year
和的重复记录day
。这将引发错误。
让我们添加我们的 Prisma 客户端。
创建一个名为的新文件夹helper
并添加一个名为的新文件prisma.ts
,并在其中添加以下代码:
import {PrismaClient} from '@prisma/client';
export const prisma = new PrismaClient();
我们稍后可以使用该prisma
变量来查询我们的数据库。
应用程序 UI 骨架💀
我们需要一些库来完成本教程:
- Axios - 向服务器发送请求(如果您觉得更方便,也可以使用 fetch)
- Dayjs -处理日期的优秀库。它是 moment.js 的替代品,但已不再完全维护。
- Lodash——用于玩数据结构的酷库。
- react-hook-form -处理表单(验证/值/等)的最佳库
- chart.js - 我选择绘制我们的 GitHub 星图的库。
让我们安装它们:
npm install axios dayjs lodash @types/lodash chart.js react-hook-form react-chartjs-2 --save
创建一个名为的新文件夹components
并添加一个名为main.tsx
添加以下代码:
"use client";
import {useForm} from "react-hook-form";
import axios from "axios";
import {Repository} from "@prisma/client";
import {useCallback, useState} from "react";
export default function Main() {
const [repositoryState, setRepositoryState] = useState([]);
const {register, handleSubmit} = useForm();
const submit = useCallback(async (data: any) => {
const {data: repositoryResponse} = await axios.post('/api/repository', {todo: 'add', repository: data.name});
setRepositoryState([...repositoryState, ...repositoryResponse]);
}, [repositoryState])
const deleteFromList = useCallback((val: List) => () => {
axios.post('/api/repository', {todo: 'delete', repository: `https://github.com/${val.name}`});
setRepositoryState(repositoryState.filter(v => v.name !== val.name));
}, [repositoryState])
return (
<div className="w-full max-w-2xl mx-auto p-6 space-y-12">
<form className="flex items-center space-x-4" onSubmit={handleSubmit(submit)}>
<input className="flex-grow p-3 border border-black/20 rounded-xl" placeholder="Add Git repository" type="text" {...register('name', {required: 'true'})} />
<button className="flex-shrink p-3 border border-black/20 rounded-xl" type="submit">
Add
</button>
</form>
<div className="divide-y-2 divide-gray-300">
{repositoryState.map(val => (
<div key={val.name} className="space-y-4">
<div className="flex justify-between items-center py-10">
<h2 className="text-xl font-bold">{val.name}</h2>
<button className="p-3 border border-black/20 rounded-xl bg-red-400" onClick={deleteFromList(val)}>Delete</button>
</div>
<div className="bg-white rounded-lg border p-10">
<div className="h-[300px]]">
{/* Charts Component */}
</div>
</div>
</div>
))}
</div>
</div>
)
}
超级简单的 React 组件
- 允许我们添加新的 GitHub 库并将其发送到服务器 POST 的表单 -
/api/repository
{todo: 'add'}
- 删除我们不想要的存储库 POST -
/api/repository
{todo: 'delete'}
- 所有已添加库及其图表的列表。
让我们转到文章的复杂部分,添加新的存储库。
数星星
在里面helper
创建一个名为的新文件all.stars.ts
并添加以下代码:
import axios from "axios";
import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
const requestAmount = 20;
export const getAllGithubStars = async (owner: string, name: string) => {
// Get the amount of stars from GitHub
const totalStars = (await axios.get(`https://api.github.com/repos/${owner}/${name}`)).data.stargazers_count;
// get total pages
const totalPages = Math.ceil(totalStars / 100);
// How many pages to skip? We don't want to spam requests
const pageSkips = totalPages < requestAmount ? requestAmount : Math.ceil(totalPages / requestAmount);
// Send all the requests at the same time
const starsDates = (await Promise.all([...new Array(requestAmount)].map(async (_, index) => {
const getPage = (index * pageSkips) || 1;
return (await axios.get(`https://api.github.com/repos/${owner}/${name}/stargazers?per_page=100&page=${getPage}`, {
headers: {
Accept: "application/vnd.github.v3.star+json",
},
})).data;
}))).flatMap(p => p).reduce((acc: any, stars: any) => {
const yearMonth = stars.starred_at.split('T')[0];
acc[yearMonth] = (acc[yearMonth] || 0) + 1;
return acc;
}, {});
// how many stars did we find from a total of `requestAmount` requests?
const foundStars = Object.keys(starsDates).reduce((all, current) => all + starsDates[current], 0);
// Find the earliest date
const lowestMonthYear = Object.keys(starsDates).reduce((lowest, current) => {
if (lowest.isAfter(dayjs.utc(current.split('T')[0]))) {
return dayjs.utc(current.split('T')[0]);
}
return lowest;
}, dayjs.utc());
// Count dates until today
const splitDate = dayjs.utc().diff(lowestMonthYear, 'day') + 1;
// Create an array with the amount of stars we didn't find
const array = [...new Array(totalStars - foundStars)];
// Set the amount of value to add proportionally for each day
let splitStars: any[][] = [];
for (let i = splitDate; i > 0; i--) {
splitStars.push(array.splice(0, Math.ceil(array.length / i)));
}
// Calculate the amount of stars for each day
return [...new Array(splitDate)].map((_, index, arr) => {
const yearMonthDay = lowestMonthYear.add(index, 'day').format('YYYY-MM-DD');
const value = starsDates[yearMonthDay] || 0;
return {
stars: value + splitStars[index].length,
date: {
month: +dayjs.utc(yearMonthDay).format('M'),
year: +dayjs.utc(yearMonthDay).format('YYYY'),
day: +dayjs.utc(yearMonthDay).format('D'),
}
};
});
}
那么这里发生了什么:
totalStars
- 我们计算图书馆拥有的星星总数。totalPages
- 我们计算页数(每页100条记录)pageSkips
- 由于我们最多需要 20 个请求,因此我们每次都会检查必须跳过多少页面。starsDates
- 我们为每个日期填充星星的数量。foundStars
- 由于我们跳过了日期,我们需要计算实际发现的星星总数。lowestMonthYear
- 寻找我们拥有的恒星的最早日期。splitDate
- 最早的日期和今天之间有多少个日期?array
splitDate
- 具有一定数量项目的空数组。splitStars
- 我们缺少星星的数量,需要按比例添加每个日期。- 最终回报——自开始以来每天的星星数量的新数组。
因此,我们成功创建了一个可以每天给我们星星的功能。
我试过像这样显示,结果很乱。
你可能想显示每个月的星星数量。
此外,你可能想累计星星,而不是:
- 二月 - 300 颗星
- 三月 - 200 颗星
- 四月 - 400 颗星
如果像这样就更好了:
- 二月 - 300 颗星
- 三月 - 500颗星
- 四月 - 900 颗星
两种选择都有效。取决于你想展示什么!
因此,让我们转到我们的帮助文件夹并创建一个名为的新文件get.list.ts
。
以下是该文件的内容:
import {prisma} from "./prisma";
import {groupBy, sortBy} from "lodash";
import {Repository} from "@prisma/client";
function fixStars (arr: any[]): Array<{name: string, stars: number, month: number, year: number}> {
return arr.map((current, index) => {
return {
...current,
stars: current.stars + arr.slice(index + 1, arr.length).reduce((acc, current) => acc + current.stars, 0),
}
}).reverse();
}
export const getList = async (data?: Repository[]) => {
const repo = data || await prisma.repository.findMany();
const uniqMonth = Object.values(
groupBy(
sortBy(
Object.values(
groupBy(repo, (p) => p.name + '-' + p.year + '-' + p.month))
.map(current => {
const stars = current.reduce((acc, current) => acc + current.stars, 0);
return {
name: current[0].name,
stars,
month: current[0].month,
year: current[0].year
}
}),
[(p: any) => -p.year, (p: any) => -p.month]
),p => p.name)
);
const fixMonthDesc = uniqMonth.map(p => fixStars(p));
return fixMonthDesc.map(p => ({
name: p[0].name,
list: p
}));
}
首先,它将所有按天列出的星星转换为按月列出的星星。
稍后我们将累计每个月的星星数量。
这里要注意的一点是它data?: Repository[]
是可选的。
我们制定了一个简单的逻辑:如果我们不传递数据,它将对我们数据库中的所有存储库执行此操作。
如果我们传递数据,它将只对其起作用。
你问为什么?
- 当我们创建一个新的存储库时,我们需要将其添加到数据库后对特定的存储库数据进行处理。
- 当我们重新加载页面时,我们需要获取所有数据的数据。
现在,让我们开始创建/删除星星的路线。
前往src/app/api
并创建一个名为 的新文件夹repository
。在该文件夹中,创建一个名为 的新文件route.tsx
。
在那里添加以下代码:
import {getAllGithubStars} from "../../../../helper/all.stars";
import {prisma} from "../../../../helper/prisma";
import {Repository} from "@prisma/client";
import {getList} from "../../../../helper/get.list";
export async function POST(request: Request) {
const body = await request.json();
if (!body.repository) {
return new Response(JSON.stringify({error: 'Repository is required'}), {status: 400});
}
const {owner, name} = body.repository.match(/github.com\/(?<owner>.*)\/(?<name>.*)/).groups;
if (!owner || !name) {
return new Response(JSON.stringify({error: 'Repository is invalid'}), {status: 400});
}
if (body.todo === 'delete') {
await prisma.repository.deleteMany({
where: {
name: `${owner}/${name}`
}
});
return new Response(JSON.stringify({deleted: true}), {status: 200});
}
const starsMonth = await getAllGithubStars(owner, name);
const repo: Repository[] = [];
for (const stars of starsMonth) {
repo.push(
await prisma.repository.upsert({
where: {
name_day_month_year: {
name: `${owner}/${name}`,
month: stars.date.month,
year: stars.date.year,
day: stars.date.day,
},
},
update: {
stars: stars.stars,
},
create: {
name: `${owner}/${name}`,
month: stars.date.month,
year: stars.date.year,
day: stars.date.day,
stars: stars.stars,
}
})
);
}
return new Response(JSON.stringify(await getList(repo)), {status: 200});
}
我们共享了 DELETE 和 CREATE 路线,这些路线通常不应在生产用途中使用,但我们已在本文中这样做以使您更容易使用。
我们从请求中获取 JSON,检查“repository”字段是否存在,以及它是否是 GitHub 存储库的有效路径。
如果是删除请求,我们用prisma
存储库的名称从数据库中删除存储库并返回请求。
如果是创建,我们用它getAllGithubStars
来获取数据并保存到我们的数据库中。
💡 由于我们已经在 上放置了唯一索引
name
,month
因此如果记录已经存在,我们可以使用它来year
更新day
数据prisma
upsert
最后,我们将新积累的数据返回给客户端。
最困难的部分完成了🍾
主页人口💽
我们尚未创建主页面组件。
我们开始做吧。
转到app
文件夹创建或编辑page.tsx
并添加以下代码:
"use server";
import Main from "@/components/main";
import {getList} from "../../helper/get.list";
export default async function Home() {
const list: any[] = await getList();
return (
<Main list={list} />
)
}
我们使用相同的函数getList
来获取所有存储库累积的所有数据。
我们还修改主要组件以支持它。
编辑components/main.tsx
并替换为:
"use client";
import {useForm} from "react-hook-form";
import axios from "axios";
import {Repository} from "@prisma/client";
import {useCallback, useState} from "react";
interface List {
name: string,
list: Repository[]
}
export default function Main({list}: {list: List[]}) {
const [repositoryState, setRepositoryState] = useState(list);
const {register, handleSubmit} = useForm();
const submit = useCallback(async (data: any) => {
const {data: repositoryResponse} = await axios.post('/api/repository', {todo: 'add', repository: data.name});
setRepositoryState([...repositoryState, ...repositoryResponse]);
}, [repositoryState])
const deleteFromList = useCallback((val: List) => () => {
axios.post('/api/repository', {todo: 'delete', repository: `https://github.com/${val.name}`});
setRepositoryState(repositoryState.filter(v => v.name !== val.name));
}, [repositoryState])
return (
<div className="w-full max-w-2xl mx-auto p-6 space-y-12">
<form className="flex items-center space-x-4" onSubmit={handleSubmit(submit)}>
<input className="flex-grow p-3 border border-black/20 rounded-xl" placeholder="Add Git repository" type="text" {...register('name', {required: 'true'})} />
<button className="flex-shrink p-3 border border-black/20 rounded-xl" type="submit">
Add
</button>
</form>
<div className="divide-y-2 divide-gray-300">
{repositoryState.map(val => (
<div key={val.name} className="space-y-4">
<div className="flex justify-between items-center py-10">
<h2 className="text-xl font-bold">{val.name}</h2>
<button className="p-3 border border-black/20 rounded-xl bg-red-400" onClick={deleteFromList(val)}>Delete</button>
</div>
<div className="bg-white rounded-lg border p-10">
<div className="h-[300px]]">
{/* Charts Components */}
</div>
</div>
</div>
))}
</div>
</div>
)
}
显示图表!📈
转到components
文件夹并添加一个名为 的新文件chart.tsx
。
添加以下代码:
"use client";
import {Repository} from "@prisma/client";
import {useMemo} from "react";
import React from 'react';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
} from 'chart.js';
import { Line } from 'react-chartjs-2';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend
);
export default function ChartComponent({repository}: {repository: Repository[]}) {
const labels = useMemo(() => {
return repository.map(r => `${r.year}/${r.month}`);
}, [repository]);
const data = useMemo(() => ({
labels,
datasets: [
{
label: repository[0].name,
data: repository.map(p => p.stars),
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.5)',
tension: 0.2,
},
],
}), [repository]);
return (
<Line options={{
responsive: true,
}} data={data} />
);
}
我们使用该chart.js
库来绘制一种Line
图形。
这非常简单,因为我们在服务器端完成了所有数据结构。
这里需要注意的一点是,我们export default
的 ChartComponent 组件。这是因为它使用了Canvas
。服务器端不可用,我们需要延迟加载此组件。
让我们修改一下main.tsx
:
"use client";
import {useForm} from "react-hook-form";
import axios from "axios";
import {Repository} from "@prisma/client";
import dynamic from "next/dynamic";
import {useCallback, useState} from "react";
const ChartComponent = dynamic(() => import('@/components/chart'), { ssr: false, })
interface List {
name: string,
list: Repository[]
}
export default function Main({list}: {list: List[]}) {
const [repositoryState, setRepositoryState] = useState(list);
const {register, handleSubmit} = useForm();
const submit = useCallback(async (data: any) => {
const {data: repositoryResponse} = await axios.post('/api/repository', {todo: 'add', repository: data.name});
setRepositoryState([...repositoryState, ...repositoryResponse]);
}, [repositoryState])
const deleteFromList = useCallback((val: List) => () => {
axios.post('/api/repository', {todo: 'delete', repository: `https://github.com/${val.name}`});
setRepositoryState(repositoryState.filter(v => v.name !== val.name));
}, [repositoryState])
return (
<div className="w-full max-w-2xl mx-auto p-6 space-y-12">
<form className="flex items-center space-x-4" onSubmit={handleSubmit(submit)}>
<input className="flex-grow p-3 border border-black/20 rounded-xl" placeholder="Add Git repository" type="text" {...register('name', {required: 'true'})} />
<button className="flex-shrink p-3 border border-black/20 rounded-xl" type="submit">
Add
</button>
</form>
<div className="divide-y-2 divide-gray-300">
{repositoryState.map(val => (
<div key={val.name} className="space-y-4">
<div className="flex justify-between items-center py-10">
<h2 className="text-xl font-bold">{val.name}</h2>
<button className="p-3 border border-black/20 rounded-xl bg-red-400" onClick={deleteFromList(val)}>Delete</button>
</div>
<div className="bg-white rounded-lg border p-10">
<div className="h-[300px]]">
<ChartComponent repository={val.list} />
</div>
</div>
</div>
))}
</div>
</div>
)
}
您可以看到我们使用了nextjs/dynamic
延迟加载组件。
我希望将来 NextJS 能"use lazy-load"
为客户端组件添加类似的功能😺
那么新星呢?来认识一下 Trigger.Dev!
每天添加新星星的最佳方式是运行 cron 请求来检查新添加的星星并将其添加到我们的数据库中。
而不是使用 Vercel cron / GitHub 操作,或者,上帝保佑,为此创建一个新的服务器。
我们可以使用Trigger.DEV直接与我们的 NextJS 应用程序协同工作。
那么让我们开始设置吧!
注册一个 Trigger.dev 帐户。
注册后,创建一个组织并为您的工作选择一个项目名称。
选择 Next.js 作为您的框架,并按照将 Trigger.dev 添加到现有 Next.js 项目的过程进行操作。
否则,请单击 Environments & API Keys
项目仪表板的侧边栏菜单。
复制您的 DEV 服务器 API 密钥并运行下面的代码片段来安装 Trigger.dev。
请仔细遵循说明。
npx @trigger.dev/cli@latest init
在另一个终端中运行以下代码片段,以在 Trigger.dev 和 Next.js 项目之间建立隧道。
npx @trigger.dev/cli@latest dev
让我们创建我们的 TriggerDev 作业!
您将看到一个名为的新文件夹jobs
。
在那里创建一个名为sync.stars.ts
添加以下代码:
import { cronTrigger, invokeTrigger } from "@trigger.dev/sdk";
import { client } from "@/trigger";
import { prisma } from "../../helper/prisma";
import axios from "axios";
import { z } from "zod";
// Your first job
// This Job will be triggered by an event, log a joke to the console, and then wait 5 seconds before logging the punchline.
client.defineJob({
id: "sync-stars",
name: "Sync Stars Daily",
version: "0.0.1",
// Run a cron every day at 23:00 AM
trigger: cronTrigger({
cron: "0 23 * * *",
}),
run: async (payload, io, ctx) => {
const repos = await io.runTask("get-stars", async () => {
// get all libraries and current amount of stars
return await prisma.repository.groupBy({
by: ["name"],
_sum: {
stars: true,
},
});
});
//loop through all repos and invoke the Job that gets the latest stars
for (const repo of repos) {
getStars.invoke(repo.name, {
name: repo.name,
previousStarCount: repo?._sum?.stars || 0,
});
}
},
});
const getStars = client.defineJob({
id: "get-latest-stars",
name: "Get latest stars",
version: "0.0.1",
// Run a cron every day at 23:00 AM
trigger: invokeTrigger({
schema: z.object({
name: z.string(),
previousStarCount: z.number(),
}),
}),
run: async (payload, io, ctx) => {
const stargazers_count = await io.runTask("get-stars", async () => {
const { data } = await axios.get(
`https://api.github.com/repos/${payload.name}`,
{
headers: {
authorization: `token ${process.env.TOKEN}`,
},
}
);
return data.stargazers_count as number;
});
await prisma.repository.upsert({
where: {
name_day_month_year: {
name: payload.name,
month: new Date().getMonth() + 1,
year: new Date().getFullYear(),
day: new Date().getDate(),
},
},
update: {
stars: stargazers_count - payload.previousStarCount,
},
create: {
name: payload.name,
stars: stargazers_count - payload.previousStarCount,
month: new Date().getMonth() + 1,
year: new Date().getFullYear(),
day: new Date().getDate(),
},
});
},
});
我们创建了一项名为“Sync Stars Daily”的新任务,该任务将在每天 23:00 运行 - 它在 cron 文本中的表示为:0 23 * * *
我们将所有当前的存储库放入数据库中,按名称对它们进行分组,然后对星级进行求和。
由于一切都在 Vercel 无服务器上运行,我们可能会在检查所有存储库时超时。
为此,我们将每个存储库发送到不同的作业。
我们使用它invoke
来创建新的工作,然后在内部处理它们Get latest stars
我们遍历所有新的存储库并获取当前的星号。
我们用新的星星数量减去旧的星星数量,就得到了今天的星星数量。
我们使用 将其添加到数据库中prisma
。没有比这更简单的了!
最后一件事是编辑jobs/index.ts
并替换以下内容:
export * from "./sync.stars";
你就完成了🥳
让我们联系吧!🔌
作为开源开发者,我们诚邀您加入我们的 社区 ,贡献力量并与维护人员互动。欢迎访问我们的 GitHub 代码库 ,贡献代码并创建与 Trigger.dev 相关的问题。
本教程的源代码可以在这里找到:
https://github.com/triggerdotdev/blog/tree/main/stars-monitor
感谢您的阅读!
文章来源:https://dev.to/triggerdotdev/take-nextjs-to-the-next-level-create-a-github-stars-monitor-130a