Skip to content

Commit 05246bd

Browse files
committed
GitHub trending API for Node.js
0 parents  commit 05246bd

11 files changed

Lines changed: 1381 additions & 0 deletions

File tree

‎.env.example‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Your Apify API token: https://console.apify.com/settings/integrations
2+
APIFY_TOKEN=

‎.github/workflows/ci.yml‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
test:
12+
runs-on: ubuntu-latest
13+
strategy:
14+
matrix:
15+
node-version: [20, 24]
16+
steps:
17+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
18+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
19+
with:
20+
node-version: ${{ matrix.node-version }}
21+
cache: npm
22+
- run: npm ci
23+
- run: node --check index.js
24+
- run: npm test

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.env
2+
results.json
3+
results.csv
4+
node_modules/

‎LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Devil Scrapes
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎README.md‎

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
<p align="center"><a href="https://apify.com/DevilScrapes/github-trending-scraper"><img src="assets/banner.svg" alt="GitHub Trending Scraper — JavaScript example" width="100%"></a></p>
2+
3+
# GitHub Trending API for JavaScript and Node.js: trending repositories as JSON
4+
5+
<p align="center">
6+
<a href="https://apify.com/DevilScrapes/github-trending-scraper"><img alt="Run on Apify" src="https://img.shields.io/badge/Run%20on-Apify-DC2626?logo=apify&logoColor=white"></a>
7+
<img alt="JavaScript" src="https://img.shields.io/badge/Node.js-20%2B-339933?logo=nodedotjs&logoColor=white">
8+
<a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-0A0606"></a>
9+
<a href="https://github.com/devilscrapes/github-trending-scraper-javascript/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/devilscrapes/github-trending-scraper-javascript/actions/workflows/ci.yml/badge.svg"></a>
10+
</p>
11+
12+
GitHub never shipped an endpoint for its Trending list, so apps that want it end up parsing HTML. This repository skips that chore: it is a single ES module that calls the **[GitHub Trending Scraper](https://apify.com/DevilScrapes/github-trending-scraper)** through the official `apify-client` package and hands your code the list as plain objects. Each object carries the repository's rank, its `owner/name`, URL, tagline, language, forks and two star counts, one for the selected period and one for all time. The scraping happens on Apify's infrastructure, which means your Node service, serverless function or bot only starts a run and reads the result. Consider it a Trending endpoint you never had to sell your soul to GitHub for. Rows are also saved to `results.json` and `results.csv` when you run the script directly.
13+
14+
> **Actor:** [GitHub Trending Scraper](https://apify.com/DevilScrapes/github-trending-scraper) · **Publisher:** [Devil Scrapes](https://apify.com/DevilScrapes) · **Also in Python:** [github-trending-scraper-python](https://github.com/devilscrapes/github-trending-scraper-python)
15+
16+
## 🔥 What you get
17+
18+
- **One row for every repository on the list**, with its position, the `owner/name` pair split into separate columns, the link, the tagline, the primary language, star and fork counts and the contributor avatars GitHub shows on the card.
19+
- **Star velocity as its own number**: `stars_in_window` counts only the stars earned during the chosen period, kept apart from the lifetime `stars_total`, so you never diff two snapshots to estimate momentum.
20+
- **Three periods, two filters**: switch `since` between `daily`, `weekly` and `monthly`, and narrow by programming language, spoken language or both, all producing the same row shape.
21+
- **GitHub's ordering survives export**: the `rank` column keeps position 1 at position 1 after the rows pass through CSV, a spreadsheet or a database sort.
22+
- **No GitHub token or rate budget of your own**: requests go out from the Apify run, and throttled or failed page loads are retried with backoff before anything is written.
23+
- **Every row is stamped** with the window it came from and a UTC collection time, which makes stacking scheduled snapshots into a history table straightforward.
24+
25+
## 🎯 Use cases
26+
27+
### Post a daily trending digest to Slack or Discord
28+
29+
Schedule `node index.js` in GitHub Actions or a cron container, take the top five rows and send them to an incoming webhook. Each message can link `html_url` and show `stars_in_window`, which tells readers why the project is on the list.
30+
31+
### Serve trending data from your own API
32+
33+
Wrap the core call in an Express or Fastify route, cache the response for a few hours and expose `/trending?lang=go` to your frontend. Since every field has a stable name, the response mapping is written once.
34+
35+
### Alert when a watched repository lands on the list
36+
37+
Keep a set of `full_name` values for your own projects or competitors and check each new run against it. A match can open an issue, ping a channel or log an event in your analytics stack.
38+
39+
### Give an AI agent a fresh open source signal
40+
41+
Expose the call as a tool in your agent framework so a briefing bot can ask what is climbing in TypeScript this week. The compact rows fit comfortably into a model's context.
42+
43+
## ⚡ Quick start
44+
45+
You need an Apify account and an API token from [Console → Settings → Integrations](https://console.apify.com/settings/integrations). Apify's free plan includes monthly usage credit; this Actor's event prices are listed under Pricing below.
46+
47+
```bash
48+
git clone https://github.com/devilscrapes/github-trending-scraper-javascript.git
49+
cd github-trending-scraper-javascript
50+
npm install
51+
export APIFY_TOKEN=your_token_here
52+
node index.js # uses input.json
53+
node index.js my-input.json --out data --max-charge 2
54+
```
55+
56+
The script starts the [GitHub Trending Scraper](https://apify.com/DevilScrapes/github-trending-scraper), waits for it to finish, prints a link to the run in Apify Console and saves every result to `results.json` and `results.csv`. It exits with code 1 and the run's status message if the run fails.
57+
58+
## 🧩 The core call
59+
60+
Four statements are all the integration needs. `call()` waits for the run to end, and `listItems()` returns the repositories from its default dataset as an array.
61+
62+
```javascript
63+
import { ApifyClient } from 'apify-client';
64+
65+
const client = new ApifyClient({ token: 'YOUR_APIFY_TOKEN' });
66+
const run = await client.actor('DevilScrapes/github-trending-scraper').call({"since": "weekly", "language": "rust", "maxResults": 10, "proxyConfiguration": {"useApifyProxy": true}});
67+
const { items } = await client.dataset(run.defaultDatasetId).listItems();
68+
console.log(items);
69+
```
70+
71+
## 📥 Input
72+
73+
`index.js` reads `input.json` and passes it through untouched. The sample requests this week's Rust list and stops at 10 repositories, enough to wire up a prototype without spending much. Change `since`, `language` or `spokenLanguage` per request when you call it from a server.
74+
75+
```json
76+
{
77+
"since": "weekly",
78+
"language": "rust",
79+
"maxResults": 10,
80+
"proxyConfiguration": {
81+
"useApifyProxy": true
82+
}
83+
}
84+
```
85+
86+
| Field | Type | Required | Default | Allowed values |
87+
|---|---|---|---|---|
88+
| `since` | string | no | `"daily"` | `daily`, `weekly`, `monthly` |
89+
| `language` | string | no | `""` | — |
90+
| `spokenLanguage` | string | no | `""` | — |
91+
| `maxResults` | integer | no | `25` | — |
92+
| `proxyConfiguration` | object | no | `{"useApifyProxy": true}` | — |
93+
94+
## 📤 Output
95+
96+
The result is an array with one object per listed repository. Numeric fields arrive as integers, `description` and `language` may be `null`, and `built_by` is an array of avatar URL strings that can be empty.
97+
98+
| Field | Type | Always present |
99+
|---|---|---|
100+
| `rank` | integer | yes |
101+
| `full_name` | string | yes |
102+
| `owner` | string | no |
103+
| `name` | string | no |
104+
| `html_url` | string | yes |
105+
| `description` | string or null | no |
106+
| `language` | string or null | no |
107+
| `stars_total` | integer | no |
108+
| `stars_in_window` | integer | no |
109+
| `forks` | integer | no |
110+
| `built_by` | array | no |
111+
| `scraped_at` | string | no |
112+
| `window` | string | no |
113+
114+
## 💰 Pricing
115+
116+
| Event | Price (USD) |
117+
|---|---|
118+
| Actor start | $0.2 |
119+
| Result emitted | $0.002 |
120+
121+
Prices as configured on 2026-08-20. The [Actor page](https://apify.com/DevilScrapes/github-trending-scraper) always shows the current price. Runs are billed to the Apify account that owns the token. Use `--max-charge` to cap what a single run can cost.
122+
123+
## 🛠️ Working with the data in JavaScript
124+
125+
The snippet below loads the saved rows, orders them by stars earned in the period and writes a Markdown digest you could post to a chat or commit to a repository. Only Node.js built-ins are used.
126+
127+
```javascript
128+
import { readFile, writeFile } from 'node:fs/promises';
129+
130+
const rows = JSON.parse(await readFile('results.json', 'utf8'));
131+
const top = [...rows]
132+
.sort((a, b) => (b.stars_in_window ?? 0) - (a.stars_in_window ?? 0))
133+
.slice(0, 10);
134+
135+
const period = rows[0]?.window ?? 'daily';
136+
const lines = top.map((repo, i) => {
137+
const lang = repo.language ?? 'n/a';
138+
const blurb = repo.description ? ` - ${repo.description}` : '';
139+
return `${i + 1}. [${repo.full_name}](${repo.html_url}) (${lang}, +${repo.stars_in_window ?? 0} stars, ${repo.stars_total ?? 0} total)${blurb}`;
140+
});
141+
142+
const digest = `# GitHub trending (${period})\n\n${lines.join('\n')}\n`;
143+
await writeFile('digest.md', digest, 'utf8');
144+
console.log(digest);
145+
```
146+
147+
## ❓ FAQ
148+
149+
### Is this the same as GitHub's search API sorted by stars?
150+
151+
No. Search sorted by stars ranks all-time popularity, while Trending ranks recent activity over a day, week or month. This Actor returns GitHub's own published Trending order, including the stars gained during that period.
152+
153+
### Will this fit inside a Lambda or Vercel function?
154+
155+
Usually, provided the platform lets the handler wait for the Actor to finish. Under a tight timeout, kick the run off with `start()` rather than `call()` and pick up the dataset later from a webhook or a second invocation.
156+
157+
### What about a TypeScript codebase?
158+
159+
The client package bundles its own declarations, so the core call drops into a `.ts` file as is. Declare an interface for the row fields in the Output table and cast the dataset items to it.
160+
161+
### How do I keep costs predictable in production?
162+
163+
Set `maxResults` to what you actually display and pass a cap such as `--max-charge 0.5`. Each run costs the start fee plus one result charge per repository written.
164+
165+
### Why is `built_by` sometimes an empty array?
166+
167+
GitHub shows contributor avatars on some cards and not on others. The Actor returns what the page includes, so treat the field as optional in your UI.
168+
169+
## 🧯 Troubleshooting
170+
171+
- **`Set APIFY_TOKEN first`**: export the token in the same shell, or put it in a local `.env` file your shell loads. Never commit it.
172+
- **Run finished with `FAILED` or `ABORTED`**: open the printed Console link; the run log says why. Input validation errors name the field.
173+
- **Fewer rows than expected**: a `--max-charge` cap also limits how many results can be charged. Raise the cap or lower the limits in your input.
174+
- **Something looks wrong in the data**: open an issue on the [Actor page](https://apify.com/DevilScrapes/github-trending-scraper) so the maintainers see it.
175+
176+
## 🔗 More from Devil Scrapes
177+
178+
- [GitHub Trending Scraper on Apify Store](https://apify.com/DevilScrapes/github-trending-scraper)
179+
- [Python version of this example](https://github.com/devilscrapes/github-trending-scraper-python)
180+
- [All Devil Scrapes Actors](https://apify.com/DevilScrapes)
181+
- [Apify web scraping cookbook](https://github.com/devilscrapes/apify-web-scraping-cookbook)
182+
183+
Related Actors:
184+
185+
- [GitHub Repo Scraper](https://apify.com/DevilScrapes/github-repo-scraper) — fuller metadata for any repository that catches your eye on Trending.
186+
- [GitHub Organization Scraper](https://apify.com/DevilScrapes/github-org-scraper) — profile data for the organizations behind the projects that keep climbing.
187+
- [Hacker News Scraper](https://apify.com/DevilScrapes/hacker-news-scraper) — see whether a climbing repository is also being discussed on Hacker News.
188+
189+
---
190+
191+
MIT licensed example code. The Actor itself runs on [Apify](https://apify.com/DevilScrapes/github-trending-scraper) and is maintained by [Devil Scrapes](https://apify.com/DevilScrapes). Not affiliated with GitHub, Inc..

‎assets/banner.svg‎

Lines changed: 41 additions & 0 deletions
Loading

‎index.js‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Run the GitHub Trending Scraper on Apify and save its results as JSON and CSV.
2+
// Actor page: https://apify.com/DevilScrapes/github-trending-scraper
3+
4+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
5+
import { join } from 'node:path';
6+
import { parseArgs } from 'node:util';
7+
import { pathToFileURL } from 'node:url';
8+
import { ApifyClient } from 'apify-client';
9+
10+
export const ACTOR_ID = 'DevilScrapes/github-trending-scraper';
11+
12+
class UsageError extends Error {}
13+
14+
function numberOption(name, value) {
15+
if (value === undefined) return undefined;
16+
const number = Number(value);
17+
if (!Number.isFinite(number) || number <= 0) throw new UsageError(`--${name} must be a positive number`);
18+
return number;
19+
}
20+
21+
function parseCli(argv) {
22+
const { values, positionals } = parseArgs({
23+
args: argv,
24+
allowPositionals: true,
25+
options: {
26+
out: { type: 'string', default: '.' },
27+
'max-charge': { type: 'string' },
28+
memory: { type: 'string' },
29+
},
30+
});
31+
return {
32+
inputPath: positionals[0] ?? 'input.json',
33+
out: values.out,
34+
maxCharge: numberOption('max-charge', values['max-charge']),
35+
memory: numberOption('memory', values.memory),
36+
};
37+
}
38+
39+
async function loadInput(path) {
40+
let text;
41+
try {
42+
text = await readFile(path, 'utf8');
43+
} catch {
44+
throw new UsageError(`Input file not found: ${path}`);
45+
}
46+
let data;
47+
try {
48+
data = JSON.parse(text);
49+
} catch (error) {
50+
throw new UsageError(`Input file ${path} is not valid JSON: ${error.message}`);
51+
}
52+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
53+
throw new UsageError(`Input file ${path} must contain a JSON object`);
54+
}
55+
return data;
56+
}
57+
58+
export async function main(argv = process.argv.slice(2)) {
59+
const args = parseCli(argv);
60+
const token = process.env.APIFY_TOKEN;
61+
if (!token) {
62+
throw new UsageError('Set APIFY_TOKEN first. Get a token at https://console.apify.com/settings/integrations');
63+
}
64+
const input = await loadInput(args.inputPath);
65+
const client = new ApifyClient({ token, baseUrl: process.env.APIFY_API_URL ?? 'https://api.apify.com' });
66+
67+
console.log(`Starting ${ACTOR_ID} ...`);
68+
const run = await client.actor(ACTOR_ID).call(input, {
69+
maxTotalChargeUsd: args.maxCharge,
70+
memory: args.memory,
71+
});
72+
73+
console.log(`Run ${run.id} finished: ${run.status}`);
74+
console.log(`Details: https://console.apify.com/actors/runs/${run.id}`);
75+
if (run.status !== 'SUCCEEDED') {
76+
console.error(run.statusMessage ?? 'No status message.');
77+
return 1;
78+
}
79+
80+
const dataset = client.dataset(run.defaultDatasetId);
81+
await mkdir(args.out, { recursive: true });
82+
const { items } = await dataset.listItems({ clean: true });
83+
await writeFile(join(args.out, 'results.json'), `${JSON.stringify(items, null, 2)}\n`);
84+
await writeFile(join(args.out, 'results.csv'), await dataset.downloadItems('csv', { clean: true }));
85+
86+
console.log(`Saved ${items.length} items to ${join(args.out, 'results.json')} and ${join(args.out, 'results.csv')}`);
87+
return 0;
88+
}
89+
90+
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
91+
try {
92+
process.exitCode = await main();
93+
} catch (error) {
94+
console.error(error instanceof UsageError ? error.message : error);
95+
process.exitCode = 1;
96+
}
97+
}

‎input.json‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"since": "weekly",
3+
"language": "rust",
4+
"maxResults": 10,
5+
"proxyConfiguration": {
6+
"useApifyProxy": true
7+
}
8+
}

0 commit comments

Comments
 (0)