我已经受够了查看 GitHub 趋势提要...😡 TL;DR

2025-05-24

我已经受够了查看 GitHub 趋势提要...😡

TL;DR

TL;DR

如果您是 GitHub 存储库的维护者,您可能希望获得一些贡献者、星星和知名度。

最好的方法是进入GitHub 热门内容推送
举个小例子,10 月初,Novu出现在热门内容推送中一周,获得了超过 4,000 颗星。

问题是有些人不知道自己已经进入了热门榜单。

查看这个 github 趋势监控工具,点击这里

GitTrend


你需要知道什么时候流行

一旦成为趋势,您就需要添加更多气体,推广它,并保持更长时间。

到目前为止,唯一能知道你在那里的方法就是查看这个 feed。GitHub
不会告诉你这一点。

见鬼,你甚至不能使用 GitHub GraphQL 来查看趋势列表。

我决定建立一个简单的解决方案,可以帮助每个人了解他们何时流行趋势。

哇

 

最好的技术是🥁

好吧,它可能不是最好的,但对我来说是最好的。

  • 我是一个 React 用户,所以NextJS对我来说是一个显而易见的解决方案。
    它同时提供了前端和后端。它能够在云端自动扩展,无需手动添加更多容器(使用 Vercel 时)
     

  • 为了将所有内容存储在数据库中,我决定使用 Postgres 和Prisma
    在我们的演示中,我们将使用 SQLite。
     

  • 我不想再创建另一个后台作业实例并负责扩展它(cron + 队列)。我更喜欢坚持使用 Vercel 部署(它也是免费的)。为此,我选择了Trigger.dev。它允许我在 NextJS 中创建后台作业,以及更多功能,例如监控和日志记录。
     

  • 我需要向热门用户发送通知。为此,我选择了Novu。你可能觉得这有点儿多此一举,但实际上,如果没有 Novu,我根本做不到,你稍后就会明白为什么。
     

覆盖


如何构建这个东西🤔

使用 NextJS 设置新项目

npx create-next-app@latest
Enter fullscreen mode Exit fullscreen mode

之后,添加 Prisma

npm install prisma @prisma/client --save
npx prisma init --datasource-provider sqlite
Enter fullscreen mode Exit fullscreen mode

添加我们将要使用的其他库:

npm install axios jsdom @types/axios @types/jsdom --save
Enter fullscreen mode Exit fullscreen mode

我不用 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])
}
Enter fullscreen mode Exit fullscreen mode

让我们看看这里发生了什么。

  • 使用NextAuth进行身份验证/授权时,Account、Session、User 和 VerificationToken是必需字段。它保存用户信息、令牌、oAuth(如果您实现了)等。
     

  • Repositories是包含用户将来添加的所有存储库的表

    1. url——存储库的完整 URL。
    2. 语言 - 存储库的主要语言(例如,Novu 是 typescript,它可以在 typescript feed 上流行)
    3. languagePlace - 特定语言趋势提要上的最后已知位置。
    4. trendingPlace - 主要趋势信息流上的最后已知位置。
    5. updatedAt - 我们上次更新了它。  
  • RepositoriesHistory是保存所有过去趋势的表,以了解之前发生的事情。

完成后,我们可以运行npx prisma db push并使用新表来更新数据库。


下一步🚶🏻‍♂️

我不会教你如何创建 React 基础架构并登录。我在其他63 篇文章
中已经讨论过很多了 你也可以阅读 NextAuth快速入门指南 现在,我们假设登录已完成。 用户已登录系统。 以下是我们的后续步骤:



  • 我们希望人们添加他们的存储库
  • 我们希望将此人(以及所有其他已注册以获取该存储库更新的人)注册到 Novu,以便我们稍后可以告诉他们所有人该存储库正在流行。
  • 我们想要创建一个后台进程来检查趋势存储库。
  • 我们想让人们知道他们正在流行。

设置通知

  1. 继续注册Novu Cloud
  2. 前往设置并将 API 密钥和应用程序标识符复制到您的.env.local文件中

诺武

NEXT_PUBLIC_NOVU_APP_ID=
NOVU_SECRET=
Enter fullscreen mode Exit fullscreen mode

转到工作流程并创建一个名为“趋势”的新工作流程。
该工作流程应包含三个步骤。

  • 摘要- 如果用户关注多个仓库或列表的趋势,我们不想向他们发送垃圾邮件。我们可以使用摘要功能将所有内容合并到一条通知中。将摘要设置为 5 分钟。这项工作应该不会花费很长时间。
  • 应用内- 我们想在仪表板上的用户“铃铛图标”中添加一条通知 - 它可能不太实用,但查看历史记录会很好。
  • 电子邮件——我们希望通过电子邮件告知用户他们的趋势。

如果您正在为此构建移动应用程序,请添加类似推送通知的内容。

Novu2

在每个步骤中,您需要添加带有摘要消息的通知,例如

{{#if step.digest}}
  {{#each step.events}}
    {{text}}
  {{/each}}
{{else}}
  {{text}}
{{/if}}
Enter fullscreen mode Exit fullscreen mode

后者{{text}}可以是“你在第 8 位点击投票的趋势”

在里面创建一个src文件夹helpers,创建一个名为的新文件novu.ts,并添加以下代码:

import {Novu} from "@novu/node";

export const novu = new Novu(process.env.NOVU_SECRET!);
Enter fullscreen mode Exit fullscreen mode

在向用户发送通知之前,我们必须先识别用户。
为此,请前往您的 NextAuth 代码并添加以下events内容NextAuthOptions

events: {
    async signIn({ user, account }) {
        await novu.subscribers.identify(user.email!, {
            email: user.email!
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

在助手中创建一个名为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 })
}
Enter fullscreen mode Exit fullscreen mode

让我们看看这里发生了什么:

  • 我们收到一个新请求,并从 GitHub URL 中提取ownername,例如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 })
}
Enter fullscreen mode Exit fullscreen mode

我们基本上会从存储库中删除该用户的连接。
我们还会删除该用户在 Novu 中订阅该主题的权限,但不会删除存储库。

如果您想做一些额外的工作,您可以检查存储库中是否没有订阅者,从数据库中删除存储库,并删除主题。


让我们设置一些背景工作

我们需要在我们的项目上设置Trigger.dev 。

在我们的主根项目上,运行npx @trigger.dev/cli@latest init
它会处理所有事情。

如果您需要帮助设置一切,请查看他们的快速入门指南

完成后,您将看到创建了一些新文件和文件夹:

  1. /api/trigger.ts- 这是他们那边的API 调用触发器。dev调用(永远不要碰这个)
  2. 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 };
  },
});
Enter fullscreen mode Exit fullscreen mode

此项工作将每小时运行一次。

它将进入我们的数据库并获取我们拥有的所有语言,例如typescript,,,,等等pythongo

每种语言将被发送到不同的队列,以抓取特定语言的趋势信息。

您可以看到我们发送了一种用于主要趋势提要的空语言。

好了,现在我们来处理每种语言的 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;
  },
});
Enter fullscreen mode Exit fullscreen mode

我们发送一个 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,
          }
      });
  },
});
Enter fullscreen mode Exit fullscreen mode

我们首先根据存储库名称从数据库中获取存储库。

然后,我们用趋势位置的新值更新数据库。
我们将新值添加到历史记录表中。了解过去发生的事情总是好的。

我们构建想要发送给用户的消息。它可以是以下任意一种:

  • 特定语言的流行度更高
  • 主信息流趋势较高位置
  • 特定语言的流行度排名较低
  • 主供稿下行趋势

然后我们使用 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`
              }
          });
      }
  },
});
Enter fullscreen mode Exit fullscreen mode
  • 我们找到所有高于 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";
Enter fullscreen mode Exit fullscreen mode

要在本地运行所有作业,请打开一个新终端并运行npx @trigger.dev/cli@latest dev

它非常酷,它使用 ngrok 公开你的路径,以便他们可以向你发送请求。

在生产中,您可以使用此部署教程

大功告成!🥳


如果您想监控您的存储库(或其他人的存储库)的趋势,请随意使用此链接:https://gitup.dev

如果您想自行托管,可以克隆此存储库:
https://github.com/github-20k/trending-list

如果您喜欢这篇文章,请确保:

为 Novu 存储库加星标⭐️

为 Trigger.dev 仓库加星标⭐️

下次见😎


在 X 上关注我。
我分享一些关于开源发展的精彩内容:
https://twitter.com/nevodavid

科技

文章来源:https://dev.to/github20k/ive-had-enough-of-checking-the-github-trending-feed-4l16
PREV
🎯 Medium、DEV、Hashnode、Hackernoon 🔥
NEXT
我用一种新的编程语言微调了我的模型。你也可以!🚀 你的 LLM 数据集 Autotrain,你的模型