我已经受够了查看 GitHub 趋势提要...😡
TL;DR
TL;DR
如果您是 GitHub 存储库的维护者,您可能希望获得一些贡献者、星星和知名度。
最好的方法是进入GitHub 热门内容推送。
举个小例子,10 月初,Novu出现在热门内容推送中一周,获得了超过 4,000 颗星。
问题是有些人不知道自己已经进入了热门榜单。
查看这个 github 趋势监控工具,点击这里。

你需要知道什么时候流行
一旦成为趋势,您就需要添加更多气体,推广它,并保持更长时间。
到目前为止,唯一能知道你在那里的方法就是查看这个 feed。GitHub
不会告诉你这一点。
见鬼,你甚至不能使用 GitHub GraphQL 来查看趋势列表。
我决定建立一个简单的解决方案,可以帮助每个人了解他们何时流行趋势。
最好的技术是🥁
好吧,它可能不是最好的,但对我来说是最好的。
-
我是一个 React 用户,所以NextJS对我来说是一个显而易见的解决方案。
它同时提供了前端和后端。它能够在云端自动扩展,无需手动添加更多容器(使用 Vercel 时)。
-
为了将所有内容存储在数据库中,我决定使用 Postgres 和Prisma。
在我们的演示中,我们将使用 SQLite。
-
我不想再创建另一个后台作业实例并负责扩展它(cron + 队列)。我更喜欢坚持使用 Vercel 部署(它也是免费的)。为此,我选择了Trigger.dev。它允许我在 NextJS 中创建后台作业,以及更多功能,例如监控和日志记录。
-
我需要向热门用户发送通知。为此,我选择了Novu。你可能觉得这有点儿多此一举,但实际上,如果没有 Novu,我根本做不到,你稍后就会明白为什么。
如何构建这个东西🤔
使用 NextJS 设置新项目
npx create-next-app@latest
之后,添加 Prisma
npm install prisma @prisma/client --save
npx prisma init --datasource-provider sqlite
添加我们将要使用的其他库:
npm install axios jsdom @types/axios @types/jsdom --save
我不用 fetch。我爱 Axios 😻
编辑创建schema.prisma
并添加以下代码:
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
handle String? @unique
emailVerified DateTime?
image String?
userRepo UserRepository[]
accounts Account[]
sessions Session[]
}
model UserRepository {
id String @id @default(cuid())
repositoryId Int
userId String
user User @relation(fields: [userId], references: [id])
repository Repositories @relation(fields: [repositoryId], references: [id])
@@unique([repositoryId, userId])
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Repositories {
id Int @id @default(autoincrement())
url String
language String
history RepositoriesHistory[]
userRepo UserRepository[]
languagePlace Int
trendingPlace Int
updatedAt DateTime @default(now())
@@unique([url])
@@index([url])
@@index([language])
@@index([languagePlace])
@@index([trendingPlace])
}
model RepositoriesHistory {
id Int @id @default(autoincrement())
repositoryId Int
place Int
language String?
repository Repositories @relation(fields: [repositoryId], references: [id])
createdAt DateTime @default(now())
@@index([place])
@@index([language])
}
让我们看看这里发生了什么。
-
使用NextAuth进行身份验证/授权时,Account、Session、User 和 VerificationToken是必需字段。它保存用户信息、令牌、oAuth(如果您实现了)等。
-
Repositories是包含用户将来添加的所有存储库的表
- url——存储库的完整 URL。
- 语言 - 存储库的主要语言(例如,Novu 是 typescript,它可以在 typescript feed 上流行)
- languagePlace - 特定语言趋势提要上的最后已知位置。
- trendingPlace - 主要趋势信息流上的最后已知位置。
- updatedAt - 我们上次更新了它。
-
RepositoriesHistory是保存所有过去趋势的表,以了解之前发生的事情。
完成后,我们可以运行npx prisma db push
并使用新表来更新数据库。
下一步🚶🏻♂️
我不会教你如何创建 React 基础架构并登录。我在其他63 篇文章
中已经讨论过很多了。 你也可以阅读 NextAuth快速入门指南。 现在,我们假设登录已完成。 用户已登录系统。 以下是我们的后续步骤:
- 我们希望人们添加他们的存储库
- 我们希望将此人(以及所有其他已注册以获取该存储库更新的人)注册到 Novu,以便我们稍后可以告诉他们所有人该存储库正在流行。
- 我们想要创建一个后台进程来检查趋势存储库。
- 我们想让人们知道他们正在流行。
设置通知
- 继续注册Novu Cloud
- 前往设置并将 API 密钥和应用程序标识符复制到您的
.env.local
文件中
NEXT_PUBLIC_NOVU_APP_ID=
NOVU_SECRET=
转到工作流程并创建一个名为“趋势”的新工作流程。
该工作流程应包含三个步骤。
- 摘要- 如果用户关注多个仓库或列表的趋势,我们不想向他们发送垃圾邮件。我们可以使用摘要功能将所有内容合并到一条通知中。将摘要设置为 5 分钟。这项工作应该不会花费很长时间。
- 应用内- 我们想在仪表板上的用户“铃铛图标”中添加一条通知 - 它可能不太实用,但查看历史记录会很好。
- 电子邮件——我们希望通过电子邮件告知用户他们的趋势。
如果您正在为此构建移动应用程序,请添加类似推送通知的内容。
在每个步骤中,您需要添加带有摘要消息的通知,例如
{{#if step.digest}}
{{#each step.events}}
{{text}}
{{/each}}
{{else}}
{{text}}
{{/if}}
后者{{text}}
可以是“你在第 8 位点击投票的趋势”
在里面创建一个src
文件夹helpers
,创建一个名为的新文件novu.ts
,并添加以下代码:
import {Novu} from "@novu/node";
export const novu = new Novu(process.env.NOVU_SECRET!);
在向用户发送通知之前,我们必须先识别用户。
为此,请前往您的 NextAuth 代码并添加以下events
内容NextAuthOptions
:
events: {
async signIn({ user, account }) {
await novu.subscribers.identify(user.email!, {
email: user.email!
});
}
}
在助手中创建一个名为all.languages.ts
这基本上是我为添加所有 GitHub 语言及其菜单 slug 所做的前期工作。
这是一个巨大的文件,因此请从以下位置复制: https:
//github.com/github-20k/trending-list/blob/main/src/helpers/all.languages.ts
现在,让我们创建一个新的 API 端点来添加新的存储库/api/add
import type { NextApiRequest, NextApiResponse } from 'next'
import {prisma} from "../../../prisma/prisma";
import axios from "axios";
import {nextOptions} from "@trending/pages/api/auth/[...nextauth]";
import {getServerSession} from "next-auth/next";
import {allLanguages} from "@trending/helpers/all.languages";
import {novu} from "@trending/helpers/novu";
export const extractGithubInfo = (url: string) => {
const regex = /https?:\/\/github\.com\/([^\/]+)\/([^\/]+)/;
const match = url.match(regex);
if (match) {
return {
owner: match[1],
name: match[2]
};
} else {
return false;
}
}
const getLanguages = async (url: string, token: string) => {
const extract = extractGithubInfo(url);
if (!extract) return false;
try {
const {data} = await axios.get(`https://api.github.com/repos/${extract.owner}/${extract.name}/languages`, {
withCredentials: true
});
const findLanguage = Object.keys(data).reduce((all, current) => {
if (data[current] > all) {
return data[current];
}
return all;
});
const slug = allLanguages.find(p => p.name.toLowerCase() === findLanguage.toLowerCase());
if (!slug?.slug) {
return false;
}
return slug?.slug;
}
catch (err) {
return false;
}
}
const createRepository = async (repository: string, language: string) => {
try {
const create = await prisma.repositories.create({
data: {
url: repository, language: language as string, languagePlace: 0, trendingPlace: 0
}
});
await novu.topics.create({
name: 'notifications for repository',
key: `repository:${create.id}`
});
return create;
}
catch (err) {
return false;
}
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST' || !req?.body?.repository || !req.body.repository.match(/^https:\/\/github\.com\/[^/]+\/[^/]+\/?$/)) {
res.status(200).json({ valid: false });
return ;
}
if (req.body.repository.at(-1) === '/') {
req.body.repository = req.body.repository.slice(0, -1);
}
const session = await getServerSession(req, res, nextOptions);
if (!session?.user) {
res.status(200).json({ valid: false });
return ;
}
// @ts-ignore
const language = await getLanguages(req.body.repository, session.user.access_token);
if (!language) {
res.status(200).json({ valid: false });
}
const repository = await createRepository(req.body.repository, language as string) || await prisma.repositories.findFirst({
where: {
url: req.body.repository
}
});
try {
await prisma.userRepository.create({
data: {
// @ts-ignore
userId: session.user.id as string, // @ts-ignore
repositoryId: repository.id as number
}
});
await novu.topics.addSubscribers(`repository:${repository?.id!}`, {
// @ts-ignore
subscribers: [session.user.email]
});
}
catch (err) {
res.status(200).json({ valid: false });
}
res.status(200).json({ valid: true })
}
让我们看看这里发生了什么:
- 我们收到一个新请求,并从 GitHub URL 中提取
owner
和name
,例如https://github.com/novuhq/novu(所有者是novuhq
,名称是novu
)。 - 然后我们去 GitHub 检查仓库是否存在,并获取仓库的主要语言,例如,
typescript
- 我们将新的存储库插入到
Repositories
表中。如果成功,我们将在 Novu 内部创建一个新主题,并使用数据库中存储库的 ID。稍后,我们可以告诉 Novu 向所有人通知该主题的热门趋势。 - 如果存储库已经存在,它只需从我们的表中获取现有的存储库。
- 它在存储库和用户之间添加了连接(因此我们可以在仪表板上看到它)。
- 它将用户添加到主题,因此稍后我们可以向他们发送有关此存储库的通知。
现在,我们可以创建一个路由来删除该存储库的注册/api/remove
import type { NextApiRequest, NextApiResponse } from 'next'
import {nextOptions} from "@trending/pages/api/auth/[...nextauth]";
import {getServerSession} from "next-auth/next";
import {prisma} from "../../../prisma/prisma";
import {novu} from "@trending/helpers/novu";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST' || !req?.body?.repository || !req.body.repository.match(/^https:\/\/github\.com\/[^/]+\/[^/]+\/?$/)) {
res.status(200).json({ valid: false });
return ;
}
if (req.body.repository.at(-1) === '/') {
req.body.repository = req.body.repository.slice(0, -1);
}
const session = await getServerSession(req, res, nextOptions);
if (!session?.user) {
res.status(200).json({ valid: false });
return ;
}
const repository = await prisma.repositories.findFirst({
where: {
url: req.body.repository
}
});
await prisma.userRepository.deleteMany({
// @ts-ignore
where: {
repositoryId: repository?.id! as number,
// @ts-ignore
userId: session?.user?.id!
}
});
await novu.topics.removeSubscribers(`repository:${repository?.id!}`, {
// @ts-ignore
subscribers: [session.user.email]
});
res.status(200).json({ valid: true })
}
我们基本上会从存储库中删除该用户的连接。
我们还会删除该用户在 Novu 中订阅该主题的权限,但不会删除存储库。
如果您想做一些额外的工作,您可以检查存储库中是否没有订阅者,从数据库中删除存储库,并删除主题。
让我们设置一些背景工作
我们需要在我们的项目上设置Trigger.dev 。
在我们的主根项目上,运行npx @trigger.dev/cli@latest init
。
它会处理所有事情。
如果您需要帮助设置一切,请查看他们的快速入门指南。
完成后,您将看到创建了一些新文件和文件夹:
/api/trigger.ts
- 这是他们那边的API 调用触发器。dev调用(永远不要碰这个)- jobs 文件夹,您可以在其中定义不同的作业,如 cron 和 queues。
让我们创建一个名为“检查趋势”的新 cron 作业,每小时运行一次。
创建一个名为 的新文件check-trending.ts
并添加以下代码:
import { Job, cronTrigger } from "@trigger.dev/sdk";
import {client} from "@trending/trigger";
import {prisma} from "../../prisma/prisma";
client.defineJob({
id: "check-trending",
name: "Check trending",
version: "0.0.1",
trigger: cronTrigger({
cron: "0 * * * *",
}),
run: async (payload, io, ctx) => {
const repositories = await prisma.repositories.findMany({
select: {
language: true,
},
distinct: ["language"],
});
for (const repository of [{language: ''}, ...repositories]) {
await io.logger.info("trigger for " + repository.language);
await io.sendEvent('process-language-' + repository.language, {
name: "process.language",
payload: {
language: repository.language,
}
});
}
await io.logger.info("repo", {
repositories,
});
return { repositories };
},
});
此项工作将每小时运行一次。
它将进入我们的数据库并获取我们拥有的所有语言,例如typescript
,,,,等等python
。go
每种语言将被发送到不同的队列,以抓取特定语言的趋势信息。
您可以看到我们发送了一种用于主要趋势提要的空语言。
好了,现在我们来处理每种语言的 feed。
创建一个名为process-language.ts
以下是代码:
import {eventTrigger} from "@trigger.dev/sdk";
import {client} from "@trending/trigger";
import {z} from "zod";
import axios from "axios";
import { JSDOM } from 'jsdom';
import {prisma} from "../../prisma/prisma";
client.defineJob({
id: "process-language",
name: "Process language",
version: "0.1.0",
trigger: eventTrigger({
name: "process.language",
schema: z.object({
language: z.string(),
}),
}),
//this function is run when the custom event is received
run: async (payload, io, ctx) => {
const {data} = await axios.get(`https://github.com/trending/${payload.language.replace('#', '%23')}`);
const dom = new JSDOM(data);
const list = Array.from(dom.window.document.querySelectorAll('article h2')).map((p, index) => ({
rank: index + 1,
name: p?.textContent?.replace(/\s+/g, ' ').trim().split('/').map(p => p.trim()).join('/'),
}));
const foundRepositories = await prisma.repositories.findMany({
where: {
...(payload.language === '' ? {} : {language: payload.language}),
url: {
in: list.map(p => 'https://github.com/' + p.name),
}
}
});
for (const repository of foundRepositories) {
const findRank = list.find(p => 'https://github.com/' + p.name === repository.url);
if (
(payload.language === '' && repository.trendingPlace !== findRank?.rank) ||
(payload.language !== '' && repository.languagePlace !== findRank?.rank)
) {
await io.sendEvent('update-position-' + findRank?.name?.replace('/', '-'), {
name: 'update.position',
payload: {
link: repository.url,
rank: findRank?.rank!,
language: payload.language,
}
});
}
}
await io.sendEvent('reset-position-' + payload.language, {
name: 'reset.positions',
payload: {
links: foundRepositories.map(p => p.url),
language: payload.language,
}
});
return payload;
},
});
我们发送一个 HTTP 请求来https://github.com/trending/{language}
检查该语言的所有流行存储库。
由于我们要“抓取”页面,因此需要将 HTML 转换为 JavaScript。
为了解析页面内容,我使用了jsdom
。
然后,我们查询数据库以查找该特定语言的所有存储库。
我们迭代并检查存储库是否更改了位置。
如果更改了,我们将它发送到名为update.position
然后,我们为所有不在趋势 feed 上的存储库发送一个重置位置事件reset.positions
(为了实现这一点,我们需要发送在 feed 上的存储库,并且在查询中,我们将要求所有不在 0 位置上的存储库,而不是其中一个存储库)
现在,让我们创建update.position
工作。
创建一个名为:的新文件update-position.ts
import {eventTrigger} from "@trigger.dev/sdk";
import {client} from "@trending/trigger";
import {z} from "zod";
import {prisma} from "../../prisma/prisma";
import {novu} from "@trending/helpers/novu";
import {TriggerRecipientsTypeEnum} from "@novu/shared";
import {extractGithubInfo} from "@trending/pages/api/add";
const buildMessage = (link: string, newRank: number, oldRank: number, language: string) => {
const extract = extractGithubInfo(link);
if (!extract) {
return '';
}
if (oldRank === 0) {
return language ?
`Wow! ${extract.owner}/${extract.name} is now trending for ${language} on place ${newRank}` :
`OMG! ${extract.owner}/${extract.name} is now trending on the main feed on place ${newRank}`;
}
else if (oldRank > newRank) {
return language ?
`Yay! ${extract.owner}/${extract.name} bumped from place ${oldRank} to place ${newRank} on ${language}` :
`Super! ${extract.owner}/${extract.name} bumped from place ${oldRank} to place ${newRank} on the main feed`;
}
else if (newRank > oldRank) {
return language ?
`Bummer! ${extract.owner}/${extract.name} downgraded from place ${oldRank} to place ${newRank} on ${language}` :
`Damn! ${extract.owner}/${extract.name} downgraded from place ${oldRank} to place ${newRank} on the main feed`;
}
}
client.defineJob({
id: "update-position",
name: "Update position",
version: "0.1.0",
trigger: eventTrigger({
name: "update.position",
schema: z.object({
link: z.string(),
language: z.string(),
rank: z.number(),
}),
}),
//this function is run when the custom event is received
run: async (payload, io, ctx) => {
const find = await prisma.repositories.findFirst({
where: {
url: payload.link,
}
});
if (!find) {
return ;
}
await prisma.repositories.updateMany({
where: {
id: find.id,
},
data: payload.language === '' ? {
trendingPlace: payload.rank
} : {
languagePlace: payload.rank
}
});
await prisma.repositoriesHistory.create({
data: {
repositoryId: find.id,
language: payload.language,
place: payload.rank
}
});
const message = buildMessage(payload.link, payload.rank, find.language ? find.languagePlace : find.trendingPlace, payload.language);
if (!message) {
return ;
}
await novu.trigger('trending', {
to: [{
type: TriggerRecipientsTypeEnum.TOPIC,
topicKey: `repository:${find.id}`
}],
payload: {
text: message,
}
});
},
});
我们首先根据存储库名称从数据库中获取存储库。
然后,我们用趋势位置的新值更新数据库。
我们将新值添加到历史记录表中。了解过去发生的事情总是好的。
我们构建想要发送给用户的消息。它可以是以下任意一种:
- 特定语言的流行度更高
- 主信息流趋势较高位置
- 特定语言的流行度排名较低
- 主供稿下行趋势
然后我们使用 Novu 将事件发送给该存储库 ID 的所有注册人员(很酷,对吧?)
它将触发所有工作流程,包括摘要、应用内和电子邮件。
现在,我们最不想做的就是让人们知道他们的潮流已经结束了。
为此,我们将创建一个名为reset.positions
创建一个名为 的新文件reset-position.ts
。以下是完整代码:
import {eventTrigger} from "@trigger.dev/sdk";
import {client} from "@trending/trigger";
import {z} from "zod";
import {prisma} from "../../prisma/prisma";
import {novu} from "@trending/helpers/novu";
import {TriggerRecipientsTypeEnum} from "@novu/shared";
import {extractGithubInfo} from "@trending/pages/api/add";
client.defineJob({
id: "Reset positions",
name: "Reset positions",
version: "0.1.0",
trigger: eventTrigger({
name: "reset.positions",
schema: z.object({
links: z.array(z.string()),
language: z.string(),
}),
}),
//this function is run when the custom event is received
run: async (payload, io, ctx) => {
const findMany = await prisma.repositories.findMany({
where: {
url: {
notIn: payload.links,
},
...(payload.language === '' ? {} : {language: payload.language}),
...payload.language === '' ? {
trendingPlace: {
gt: 0
}
} : {
languagePlace: {
gt: 0
}
}
}
});
for (const repo of findMany) {
const extract = extractGithubInfo(repo.url);
if (!extract) {
continue;
}
await prisma.repositories.update({
where: {
id: repo.id
},
data: payload.language === '' ? {
trendingPlace: 0
} : {
languagePlace: 0
}
});
await prisma.repositoriesHistory.create({
data: {
place: 0,
language: payload.language,
repositoryId: repo.id
}
});
await novu.trigger('trending', {
to: [{
type: TriggerRecipientsTypeEnum.TOPIC,
topicKey: `repository:${repo.id}`
}],
payload: {
text: payload.language ?
`That was a good run! ${extract.owner}/${extract.name} is not trending for ${repo.language} anymore` :
`Nice run! ${extract.owner}/${extract.name} is not trending on the main feed anymore`
}
});
}
},
});
- 我们找到所有高于 0 但不在趋势提要中的存储库位置。
- 我们用新的职位更新我们的数据库。
- 我们将其添加到我们的趋势历史中。
- 我们将所有注册到此存储库的人告知他们不再使用 Novu。
现在编辑index.ts
作业内部的文件并添加以下代码:
//Export all your job files here
export * from "./check-trending";
export * from "./process-language";
export * from "./update-position";
export * from "./reset-position";
要在本地运行所有作业,请打开一个新终端并运行npx @trigger.dev/cli@latest dev
它非常酷,它使用 ngrok 公开你的路径,以便他们可以向你发送请求。
在生产中,您可以使用此部署教程
大功告成!🥳
如果您想监控您的存储库(或其他人的存储库)的趋势,请随意使用此链接:https://gitup.dev
如果您想自行托管,可以克隆此存储库:
https://github.com/github-20k/trending-list
如果您喜欢这篇文章,请确保:
下次见😎
在 X 上关注我。
我分享一些关于开源发展的精彩内容:
https://twitter.com/nevodavid
