1import { Actor } from 'apify';
2
3const DEFAULT_TIMEOUT_SECONDS = 10;
4const MAX_REPOS = 50;
5const USER_AGENT = 'GitHubRepoMetadataEnricher/0.1 (+https://apify.com)';
6
7export function parseGitHubRepoUrl(rawUrl) {
8 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('Repository URL must be a non-empty string');
9 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
10 const url = new URL(withScheme);
11
12 if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('Only HTTP(S) GitHub repository URLs are supported');
13 if (url.username || url.password) throw new Error('URLs with credentials are not allowed');
14 if (url.hostname.toLowerCase() !== 'github.com') throw new Error('Only github.com repository URLs are accepted');
15
16 const parts = url.pathname.split('/').filter(Boolean);
17 if (parts.length < 2) throw new Error('GitHub repository URL must include owner and repo');
18 const owner = parts[0];
19 const repo = parts[1].replace(/\.git$/i, '');
20
21 const safeName = /^[A-Za-z0-9_.-]+$/;
22 if (!safeName.test(owner) || !safeName.test(repo)) throw new Error('Owner/repo contains unsupported characters');
23 if (owner.startsWith('.') || repo.startsWith('.')) throw new Error('Owner/repo cannot start with a dot');
24
25 return {
26 owner,
27 repo,
28 fullName: `${owner}/${repo}`,
29 htmlUrl: `https://github.com/${owner}/${repo}`,
30 apiUrl: `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
31 };
32}
33
34function clampTimeout(input) {
35 return Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
36}
37
38async function fetchJson(url, timeoutSeconds) {
39 const controller = new AbortController();
40 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
41 try {
42 const response = await fetch(url, {
43 redirect: 'follow',
44 signal: controller.signal,
45 headers: {
46 'user-agent': USER_AGENT,
47 accept: 'application/vnd.github+json',
48 'x-github-api-version': '2022-11-28',
49 },
50 });
51 const text = await response.text();
52 let json = null;
53 try {
54 json = text ? JSON.parse(text) : null;
55 } catch {
56 json = { raw: text.slice(0, 500) };
57 }
58 return {
59 ok: response.ok,
60 status: response.status,
61 rateLimitRemaining: response.headers.get('x-ratelimit-remaining'),
62 rateLimitReset: response.headers.get('x-ratelimit-reset'),
63 json,
64 };
65 } catch (error) {
66 return { ok: false, status: null, json: null, error: error.message };
67 } finally {
68 clearTimeout(timeout);
69 }
70}
71
72function ageInDays(isoDate) {
73 if (!isoDate) return null;
74 const time = Date.parse(isoDate);
75 if (Number.isNaN(time)) return null;
76 return Math.round((Date.now() - time) / 86_400_000);
77}
78
79export function buildLeadSignals(repo) {
80 const daysSincePush = ageInDays(repo.pushed_at);
81 const stars = Number(repo.stargazers_count || 0);
82 const forks = Number(repo.forks_count || 0);
83 const openIssues = Number(repo.open_issues_count || 0);
84 const archived = Boolean(repo.archived);
85 const hasWebsite = Boolean(repo.homepage);
86 const hasLicense = Boolean(repo.license?.spdx_id);
87
88 const signals = [];
89 if (stars >= 1000) signals.push('high_visibility');
90 if (stars >= 100 && stars < 1000) signals.push('emerging_project');
91 if (forks >= 50) signals.push('developer_adoption');
92 if (daysSincePush !== null && daysSincePush <= 30) signals.push('recently_active');
93 if (openIssues >= 25) signals.push('active_issue_queue');
94 if (hasWebsite) signals.push('has_marketing_site');
95 if (hasLicense) signals.push('license_declared');
96 if (archived) signals.push('archived_project');
97
98 let score = 0;
99 if (stars >= 1000) score += 35;
100 else if (stars >= 100) score += 20;
101 else if (stars >= 25) score += 10;
102 if (forks >= 50) score += 15;
103 if (daysSincePush !== null && daysSincePush <= 30) score += 20;
104 else if (daysSincePush !== null && daysSincePush <= 180) score += 10;
105 if (openIssues >= 25) score += 10;
106 if (hasWebsite) score += 10;
107 if (hasLicense) score += 5;
108 if (archived) score -= 40;
109
110 return {
111 score: Math.max(0, Math.min(100, score)),
112 signals,
113 daysSinceLastPush: daysSincePush,
114 };
115}
116
117function compactRepoResult(parsed, repo, apiMeta) {
118 const lead = buildLeadSignals(repo);
119 return {
120 inputUrl: parsed.htmlUrl,
121 fullName: repo.full_name || parsed.fullName,
122 owner: {
123 login: repo.owner?.login || parsed.owner,
124 type: repo.owner?.type || null,
125 htmlUrl: repo.owner?.html_url || `https://github.com/${parsed.owner}`,
126 },
127 repository: {
128 name: repo.name || parsed.repo,
129 description: repo.description || null,
130 htmlUrl: repo.html_url || parsed.htmlUrl,
131 homepage: repo.homepage || null,
132 defaultBranch: repo.default_branch || null,
133 language: repo.language || null,
134 topics: Array.isArray(repo.topics) ? repo.topics : [],
135 license: repo.license ? { key: repo.license.key, name: repo.license.name, spdxId: repo.license.spdx_id } : null,
136 createdAt: repo.created_at || null,
137 updatedAt: repo.updated_at || null,
138 pushedAt: repo.pushed_at || null,
139 archived: Boolean(repo.archived),
140 disabled: Boolean(repo.disabled),
141 private: Boolean(repo.private),
142 },
143 metrics: {
144 stars: repo.stargazers_count || 0,
145 watchers: repo.watchers_count || 0,
146 forks: repo.forks_count || 0,
147 openIssues: repo.open_issues_count || 0,
148 subscribers: repo.subscribers_count || 0,
149 sizeKb: repo.size || 0,
150 },
151 leadSignals: lead,
152 api: apiMeta,
153 };
154}
155
156async function fetchReadmePreview(parsed, timeoutSeconds) {
157 const readmeUrl = `${parsed.apiUrl}/readme`;
158 const result = await fetchJson(readmeUrl, timeoutSeconds);
159 if (!result.ok || !result.json?.content) {
160 return { available: false, status: result.status, error: result.error || result.json?.message || null };
161 }
162 const raw = Buffer.from(String(result.json.content).replace(/\s/g, ''), 'base64').toString('utf8');
163 return {
164 available: true,
165 status: result.status,
166 name: result.json.name || 'README',
167 path: result.json.path || null,
168 preview: raw.replace(/\s+/g, ' ').slice(0, 500),
169 };
170}
171
172export async function enrichRepos(input) {
173 const repoUrls = Array.isArray(input.repoUrls) ? input.repoUrls : [];
174 if (repoUrls.length === 0) throw new Error('repoUrls must contain at least one GitHub repository URL');
175 if (repoUrls.length > MAX_REPOS) throw new Error(`A maximum of ${MAX_REPOS} repositories can be enriched per run`);
176
177 const timeoutSeconds = clampTimeout(input);
178 const includeReadme = Boolean(input.includeReadme);
179 const seen = new Set();
180 const outputs = [];
181
182 for (const rawUrl of repoUrls) {
183 let parsed;
184 try {
185 parsed = parseGitHubRepoUrl(rawUrl);
186 } catch (error) {
187 outputs.push({ inputUrl: rawUrl, ok: false, error: error.message });
188 continue;
189 }
190
191 if (seen.has(parsed.fullName.toLowerCase())) continue;
192 seen.add(parsed.fullName.toLowerCase());
193
194 const result = await fetchJson(parsed.apiUrl, timeoutSeconds);
195 if (!result.ok) {
196 outputs.push({
197 inputUrl: parsed.htmlUrl,
198 fullName: parsed.fullName,
199 ok: false,
200 status: result.status,
201 error: result.error || result.json?.message || 'GitHub API request failed',
202 api: {
203 rateLimitRemaining: result.rateLimitRemaining,
204 rateLimitReset: result.rateLimitReset,
205 },
206 });
207 continue;
208 }
209
210 const item = compactRepoResult(parsed, result.json, {
211 status: result.status,
212 rateLimitRemaining: result.rateLimitRemaining,
213 rateLimitReset: result.rateLimitReset,
214 });
215 item.ok = true;
216 item.checkedAt = new Date().toISOString();
217 if (includeReadme) item.readme = await fetchReadmePreview(parsed, timeoutSeconds);
218 outputs.push(item);
219 }
220
221 return outputs;
222}
223
224if (process.env.NODE_ENV !== 'test') {
225 await Actor.init();
226 try {
227 const input = await Actor.getInput();
228 const results = await enrichRepos(input || {});
229 for (const result of results) {
230 await Actor.pushData(result);
231 }
232 await Actor.setValue('OUTPUT', { count: results.length, results });
233 Actor.log.info('GitHub enrichment complete', { count: results.length });
234 } finally {
235 await Actor.exit();
236 }
237}