Skip to content

Commit ff0ebbe

Browse files
authored
Merge pull request #3 from amkayondo/copilot/add-github-user-finder
[WIP] Add GitHub user finder for Uganda location
2 parents 7cf505e + 6b12ebb commit ff0ebbe

File tree

21 files changed

+2524
-20
lines changed

21 files changed

+2524
-20
lines changed

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
# Copy this file to .env and fill in your GitHub Personal Access Token.
1+
# Copy this file to .env (or .env.local for Next.js) and fill in your
2+
# GitHub Personal Access Token.
23
# A classic PAT with at least the public_repo scope is sufficient.
34
GITHUB_TOKEN=ghp_your_token_here

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,8 @@ node_modules/
22
.env
33
uganda_users.json
44
uganda_users.csv
5+
6+
# Next.js
7+
.next/
8+
out/
9+
next-env.d.ts

README.md

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# gitfast
22

3-
A Node.js script that finds GitHub users who appear to be in **Uganda** by searching GitHub's public API and exporting the results to `uganda_users.json` and `uganda_users.csv`.
3+
A **Next.js (App Router)** web app and CLI tool that discovers GitHub users who appear to be in **Uganda** using the GitHub API. Search, filter, review results, and download JSON/CSV.
44

55
> **Note:** GitHub's `location` field is free-text and self-reported. Results are **best-effort** and may include false positives or miss users who haven't filled in their location.
66
@@ -28,7 +28,44 @@ cp .env.example .env
2828

2929
---
3030

31-
## Usage
31+
## Web App (Next.js)
32+
33+
Start the development server:
34+
35+
```bash
36+
npm run dev
37+
```
38+
39+
Open [http://localhost:3000](http://localhost:3000) to use the web UI.
40+
41+
### Features
42+
43+
- **Search form** — select cities, set min repos/followers, max pages, concurrency, and min confidence score
44+
- **Results table** — avatar, username, name, location, followers, repos, confidence score, profile link
45+
- **Client-side filters** — text search (login/name/bio), "has bio", "has company", "has blog", "has email"
46+
- **Sorting** — by score, followers, repos, or newest
47+
- **Download JSON / CSV** — export results as attachment files
48+
49+
### API Endpoints
50+
51+
| Method | Route | Description |
52+
|--------|-------|-------------|
53+
| `POST` | `/api/scrape` | Run a scrape with configurable parameters |
54+
| `GET` | `/api/export/json?runId=...` | Download JSON results |
55+
| `GET` | `/api/export/csv?runId=...` | Download CSV results |
56+
57+
### Confidence Scoring
58+
59+
Each user is assigned a score (0–100) based on their location:
60+
- **100** — location includes "uganda"
61+
- **85** — location includes "kampala"
62+
- **75** — other known Uganda cities
63+
- **50** — "UG" or "U.G." abbreviation
64+
- **0** — no match
65+
66+
---
67+
68+
## CLI Scraper
3269

3370
```bash
3471
npm start

app/api/export/csv/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { cacheGet } from "@/lib/cache";
3+
import { buildCsv } from "@/lib/csv";
4+
5+
export async function GET(request: NextRequest) {
6+
const runId = request.nextUrl.searchParams.get("runId");
7+
if (!runId) {
8+
return NextResponse.json(
9+
{ error: "Missing runId query parameter" },
10+
{ status: 400 }
11+
);
12+
}
13+
14+
const users = cacheGet(runId);
15+
if (!users) {
16+
return NextResponse.json(
17+
{ error: "Run not found or expired" },
18+
{ status: 404 }
19+
);
20+
}
21+
22+
const csv = buildCsv(users);
23+
24+
return new NextResponse(csv, {
25+
status: 200,
26+
headers: {
27+
"Content-Type": "text/csv",
28+
"Content-Disposition":
29+
'attachment; filename="uganda_github_users.csv"',
30+
},
31+
});
32+
}

app/api/export/json/route.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { cacheGet } from "@/lib/cache";
3+
4+
export async function GET(request: NextRequest) {
5+
const runId = request.nextUrl.searchParams.get("runId");
6+
if (!runId) {
7+
return NextResponse.json(
8+
{ error: "Missing runId query parameter" },
9+
{ status: 400 }
10+
);
11+
}
12+
13+
const users = cacheGet(runId);
14+
if (!users) {
15+
return NextResponse.json(
16+
{ error: "Run not found or expired" },
17+
{ status: 404 }
18+
);
19+
}
20+
21+
return new NextResponse(JSON.stringify(users, null, 2), {
22+
status: 200,
23+
headers: {
24+
"Content-Type": "application/json",
25+
"Content-Disposition":
26+
'attachment; filename="uganda_github_users.json"',
27+
},
28+
});
29+
}

app/api/scrape/route.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { NextResponse } from "next/server";
2+
import { scrapeUsers, ScrapeOptions } from "@/lib/github";
3+
import { cacheSet } from "@/lib/cache";
4+
import { ScrapeRequest, ScrapeResponse } from "@/lib/types/user";
5+
6+
export async function POST(request: Request) {
7+
let body: ScrapeRequest;
8+
try {
9+
body = await request.json();
10+
} catch {
11+
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
12+
}
13+
14+
const {
15+
locations = ["Uganda", "Kampala"],
16+
minRepos = 0,
17+
minFollowers = 0,
18+
maxPagesPerQuery = 3,
19+
perPage = 100,
20+
concurrency = 5,
21+
minScore = 50,
22+
} = body;
23+
24+
if (!Array.isArray(locations) || locations.length === 0) {
25+
return NextResponse.json(
26+
{ error: "locations must be a non-empty array" },
27+
{ status: 400 }
28+
);
29+
}
30+
31+
const opts: ScrapeOptions = {
32+
locations,
33+
minRepos,
34+
minFollowers,
35+
maxPagesPerQuery: Math.min(maxPagesPerQuery, 10),
36+
perPage: Math.min(perPage, 100),
37+
concurrency: Math.min(concurrency, 10),
38+
minScore,
39+
};
40+
41+
try {
42+
const { users, totalCandidates, uniqueUsers } = await scrapeUsers(opts);
43+
44+
const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
45+
cacheSet(runId, users);
46+
47+
const resp: ScrapeResponse = {
48+
runId,
49+
stats: {
50+
totalCandidates,
51+
uniqueUsers,
52+
keptAfterFilter: users.length,
53+
},
54+
users,
55+
};
56+
57+
return NextResponse.json(resp);
58+
} catch (err) {
59+
const message = err instanceof Error ? err.message : "Unknown error";
60+
return NextResponse.json({ error: message }, { status: 500 });
61+
}
62+
}

app/layout.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { Metadata } from "next";
2+
3+
export const metadata: Metadata = {
4+
title: "GitHub Uganda User Finder",
5+
description:
6+
"Discover GitHub users in Uganda — search, filter, and download JSON/CSV.",
7+
};
8+
9+
export default function RootLayout({
10+
children,
11+
}: {
12+
children: React.ReactNode;
13+
}) {
14+
return (
15+
<html lang="en">
16+
<body style={{ margin: 0, fontFamily: "system-ui, sans-serif" }}>
17+
{children}
18+
</body>
19+
</html>
20+
);
21+
}

0 commit comments

Comments
 (0)