c64bridge 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/prompts/README.md +6 -0
- package/.github/prompts/assembly-routine-session.prompt.md +13 -0
- package/.github/prompts/basic-program-session.prompt.md +13 -0
- package/.github/prompts/drive-management-session.prompt.md +13 -0
- package/.github/prompts/petscii-art-session.prompt.md +13 -0
- package/.github/prompts/play-classic-game.prompt.md +13 -0
- package/.github/prompts/sid-music-session.prompt.md +13 -0
- package/AGENTS.md +173 -0
- package/LICENSE +339 -0
- package/README.md +582 -0
- package/data/README.md +16 -0
- package/data/api/basic-api-spec.md +154 -0
- package/data/api/kernal-api-spec.md +68 -0
- package/data/assembly/assembly-spec.md +196 -0
- package/data/assembly/examples/effects/sinewave.asm +5 -0
- package/data/assembly/examples/graphics/sprite/mover.asm +15 -0
- package/data/assembly/examples/sid-file-structure.md +8 -0
- package/data/assembly/examples/sid-overview.md +11 -0
- package/data/audio/examples/Biosphere.sid +0 -0
- package/data/audio/sid-file-structure.md +91 -0
- package/data/audio/sid-programming-best-practices.md +198 -0
- package/data/audio/sid-spec.md +166 -0
- package/data/audio/sidwave.md +131 -0
- package/data/audio/sidwave.schema.json +64 -0
- package/data/basic/basic-spec.md +230 -0
- package/data/basic/examples/audio/alle-meine-entchen-expressive.bas +41 -0
- package/data/basic/examples/audio/c-major-scale-pleasant.bas +29 -0
- package/data/basic/examples/io/joystick.bas +5 -0
- package/data/basic/examples/video/bounce.bas +8 -0
- package/data/basic/examples/video/entchen-petscii.bas +16 -0
- package/data/basic/examples/video/hello-world.bas +4 -0
- package/data/basic/examples/video/wave.bas +7 -0
- package/data/context/bootstrap.md +148 -0
- package/data/context/chat.md +36 -0
- package/data/drive/drive-spec.md +215 -0
- package/data/embeddings_asm.json +1 -0
- package/data/embeddings_basic.json +1 -0
- package/data/embeddings_hardware.json +1 -0
- package/data/embeddings_mixed.json +1 -0
- package/data/embeddings_other.json +1 -0
- package/data/io/cia-spec.md +115 -0
- package/data/io/io-spec.md +124 -0
- package/data/memory/kernal-memory-map.md +227 -0
- package/data/memory/low-memory-map.md +133 -0
- package/data/memory/memory-map.md +186 -0
- package/data/printer/printer-commodore-bitmap.md +31 -0
- package/data/printer/printer-commodore.md +28 -0
- package/data/printer/printer-epson-bitmap.md +29 -0
- package/data/printer/printer-epson.md +30 -0
- package/data/printer/printer-prompts.md +29 -0
- package/data/printer/printer-spec.md +180 -0
- package/data/video/character-set.csv +257 -0
- package/data/video/vic-spec.md +276 -0
- package/dist/assemblyConverter.js +1299 -0
- package/dist/audio/record_and_analyze_audio.js +292 -0
- package/dist/basicConverter.js +193 -0
- package/dist/bootstrap/stdio-logger.js +32 -0
- package/dist/c64Client.js +1052 -0
- package/dist/chargen.js +197 -0
- package/dist/config.js +142 -0
- package/dist/context.js +157 -0
- package/dist/device.js +413 -0
- package/dist/index.js +7 -0
- package/dist/knowledge.js +454 -0
- package/dist/logger.js +182 -0
- package/dist/loggingHttpClient.js +88 -0
- package/dist/mcp-server.js +426 -0
- package/dist/petscii.js +135 -0
- package/dist/petsciiArt.js +356 -0
- package/dist/platform.js +82 -0
- package/dist/prompts/registry.js +560 -0
- package/dist/rag/discover.config.json +41 -0
- package/dist/rag/discover.js +401 -0
- package/dist/rag/embeddings.js +60 -0
- package/dist/rag/externalFetcher.js +934 -0
- package/dist/rag/indexer.js +770 -0
- package/dist/rag/init.js +139 -0
- package/dist/rag/knowledgeIndex.js +361 -0
- package/dist/rag/loggingRetriever.js +46 -0
- package/dist/rag/rateLimiter.js +127 -0
- package/dist/rag/retriever.js +74 -0
- package/dist/rag/sources.csv +31 -0
- package/dist/rag/types.js +5 -0
- package/dist/rag/urlUtils.js +38 -0
- package/dist/sidplayRunner.js +240 -0
- package/dist/sidwave.js +101 -0
- package/dist/sidwaveCompiler.js +329 -0
- package/dist/tools/audio.js +944 -0
- package/dist/tools/developer.js +515 -0
- package/dist/tools/errors.js +59 -0
- package/dist/tools/graphics.js +469 -0
- package/dist/tools/machineControl.js +267 -0
- package/dist/tools/memory.js +224 -0
- package/dist/tools/printer.js +482 -0
- package/dist/tools/programRunners.js +315 -0
- package/dist/tools/rag.js +153 -0
- package/dist/tools/registry.js +58 -0
- package/dist/tools/responses.js +36 -0
- package/dist/tools/schema.js +304 -0
- package/dist/tools/storage.js +740 -0
- package/dist/tools/streaming.js +132 -0
- package/dist/tools/types.js +99 -0
- package/dist/viceRunner.js +239 -0
- package/doc/MCP_SETUP.md +153 -0
- package/doc/README.md +7 -0
- package/doc/developer.md +312 -0
- package/doc/img/logo.png +0 -0
- package/doc/img/prompts/duck_petscii.png +0 -0
- package/doc/img/prompts/duck_song.png +0 -0
- package/doc/img/vscode/vscode-copilot-c64-chat-mode.png +0 -0
- package/doc/img/vscode/vscode-copilot-hello-world.png +0 -0
- package/doc/prompts/README.md +96 -0
- package/doc/prompts/user-prompt-ideas.md +147 -0
- package/doc/rest/c64-openapi.yaml +979 -0
- package/doc/rest/c64-rest-api.md +106 -0
- package/doc/troubleshooting-mcp.md +177 -0
- package/generated/c64/index.js +857 -0
- package/mcp.json +106 -0
- package/package.json +79 -0
- package/scripts/c64-cli.mjs +142 -0
- package/scripts/c64-tool.sh +83 -0
- package/scripts/check-package.mjs +89 -0
- package/scripts/cli.js +61 -0
- package/scripts/fetch-external-sources.mjs +67 -0
- package/scripts/generate-changelog.mjs +131 -0
- package/scripts/postbuild.mjs +101 -0
- package/scripts/prepare-release.mjs +58 -0
- package/scripts/rag-rebuild.mjs +53 -0
- package/scripts/register-ts-node.mjs +6 -0
- package/scripts/run-mcp-check.sh +104 -0
- package/scripts/run-tests.mjs +195 -0
- package/scripts/sid2wav.sh +94 -0
- package/scripts/smoke-run.sh +65 -0
- package/scripts/start-mock-c64.mjs +51 -0
- package/scripts/start-mock.mjs +31 -0
- package/scripts/start.mjs +72 -0
- package/scripts/update-readme.ts +164 -0
- package/scripts/verify-package.mjs +147 -0
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
/*
|
|
2
|
+
C64 Bridge - External source fetcher (CSV-driven)
|
|
3
|
+
GPL-2.0-only
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { URL } from 'node:url';
|
|
9
|
+
import https from 'node:https';
|
|
10
|
+
import http from 'node:http';
|
|
11
|
+
import { pipeline } from 'node:stream/promises';
|
|
12
|
+
import { exec } from 'node:child_process';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { Buffer } from 'node:buffer';
|
|
16
|
+
import { RealTimeSource, AdaptiveRateLimiter } from './rateLimiter.js';
|
|
17
|
+
import { parseUrlSafe, sameRegisteredDomain, getRegisteredDomain } from './urlUtils.js';
|
|
18
|
+
const execAsync = promisify(exec);
|
|
19
|
+
export async function readCsv(pathCsv) {
|
|
20
|
+
const raw = await fs.readFile(pathCsv, 'utf8');
|
|
21
|
+
const lines = raw
|
|
22
|
+
.split(/\r?\n/)
|
|
23
|
+
.map((l) => l.trim())
|
|
24
|
+
.filter((l) => l.length > 0 && !l.startsWith('#'));
|
|
25
|
+
const header = lines.shift();
|
|
26
|
+
if (!header)
|
|
27
|
+
return [];
|
|
28
|
+
const cols = header.split(',').map((c) => c.trim());
|
|
29
|
+
const fieldIdx = {};
|
|
30
|
+
cols.forEach((c, i) => (fieldIdx[c] = i));
|
|
31
|
+
const rows = [];
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
const parts = splitCsvLine(line);
|
|
34
|
+
const link = parts[fieldIdx['link']];
|
|
35
|
+
if (!link)
|
|
36
|
+
continue;
|
|
37
|
+
const depthStr = fieldIdx['depth'] !== undefined ? parts[fieldIdx['depth']] : undefined;
|
|
38
|
+
const row = {
|
|
39
|
+
type: fieldIdx['type'] !== undefined ? parts[fieldIdx['type']] : undefined,
|
|
40
|
+
description: fieldIdx['description'] !== undefined ? parts[fieldIdx['description']] : undefined,
|
|
41
|
+
link,
|
|
42
|
+
depth: depthStr ? Number(depthStr) : undefined,
|
|
43
|
+
};
|
|
44
|
+
rows.push(row);
|
|
45
|
+
}
|
|
46
|
+
return rows;
|
|
47
|
+
}
|
|
48
|
+
function splitCsvLine(line) {
|
|
49
|
+
const result = [];
|
|
50
|
+
let current = '';
|
|
51
|
+
let inQuotes = false;
|
|
52
|
+
for (let i = 0; i < line.length; i++) {
|
|
53
|
+
const ch = line[i];
|
|
54
|
+
if (ch === '"') {
|
|
55
|
+
if (inQuotes && line[i + 1] === '"') {
|
|
56
|
+
current += '"';
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
inQuotes = !inQuotes;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else if (ch === ',' && !inQuotes) {
|
|
64
|
+
result.push(current);
|
|
65
|
+
current = '';
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
current += ch;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
result.push(current);
|
|
72
|
+
return result.map((s) => s.trim());
|
|
73
|
+
}
|
|
74
|
+
// Recognised code-like extensions (deduplicated and sorted alphabetically)
|
|
75
|
+
const CODE_EXTS = new Set([
|
|
76
|
+
'.a',
|
|
77
|
+
'.a65',
|
|
78
|
+
'.acme',
|
|
79
|
+
'.asm',
|
|
80
|
+
'.bas',
|
|
81
|
+
'.dasm',
|
|
82
|
+
'.inc',
|
|
83
|
+
'.kick',
|
|
84
|
+
'.lst',
|
|
85
|
+
'.mac',
|
|
86
|
+
'.pal',
|
|
87
|
+
'.s',
|
|
88
|
+
'.seq',
|
|
89
|
+
'.src',
|
|
90
|
+
'.txt',
|
|
91
|
+
'.x65',
|
|
92
|
+
]);
|
|
93
|
+
const GITHUB_HOSTS = new Set([
|
|
94
|
+
'github.com',
|
|
95
|
+
'raw.githubusercontent.com',
|
|
96
|
+
'githubusercontent.com',
|
|
97
|
+
'raw.github.com',
|
|
98
|
+
'codeload.github.com',
|
|
99
|
+
]);
|
|
100
|
+
const NON_C64_MACHINE_PATTERN = /(?:^|[_\-.])(c128|c65|c16|plus4|vic20|vic-20|pet|cbm2|ted)(?:[_\-.]|$)/i;
|
|
101
|
+
const MACHINE_SUFFIXES = ['c64', 'c128', 'c65', 'c16', 'plus4', 'vic20', 'vic-20', 'pet', 'cbm2', 'ted'];
|
|
102
|
+
const COUNTRY_CODE_PATTERN = /^(.*?)(?:[_-]([a-z]{2}))$/i;
|
|
103
|
+
const LICENSE_FILENAMES = new Set(['LICENSE', 'LICENSE.TXT', 'COPYING', 'COPYING.TXT', 'COPYING.MD', 'LICENSE.MD']);
|
|
104
|
+
function normalizeGithubUrl(original) {
|
|
105
|
+
if (original.hostname !== 'github.com') {
|
|
106
|
+
return { url: original, raw: original.hostname === 'raw.githubusercontent.com', changed: false };
|
|
107
|
+
}
|
|
108
|
+
const segments = original.pathname.split('/').filter(Boolean);
|
|
109
|
+
const blobIdx = segments.indexOf('blob');
|
|
110
|
+
if (blobIdx > -1 && segments.length > blobIdx + 2) {
|
|
111
|
+
const [owner, repo] = segments;
|
|
112
|
+
const branch = segments[blobIdx + 1];
|
|
113
|
+
const pathParts = segments.slice(blobIdx + 2);
|
|
114
|
+
const rawUrl = new URL(`https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${pathParts.join('/')}`);
|
|
115
|
+
return { url: rawUrl, raw: true, changed: true };
|
|
116
|
+
}
|
|
117
|
+
return { url: original, raw: false, changed: false };
|
|
118
|
+
}
|
|
119
|
+
function isGithubFamily(hostname) {
|
|
120
|
+
return GITHUB_HOSTS.has(hostname.toLowerCase());
|
|
121
|
+
}
|
|
122
|
+
function isAllowedDomain(seed, candidate) {
|
|
123
|
+
const seedHost = seed.hostname.toLowerCase();
|
|
124
|
+
const candidateHost = candidate.hostname.toLowerCase();
|
|
125
|
+
if (seedHost === candidateHost) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
const seedGithub = isGithubFamily(seedHost);
|
|
129
|
+
const candidateGithub = isGithubFamily(candidateHost);
|
|
130
|
+
if (seedGithub && candidateGithub) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
if (!seedGithub && !candidateGithub && sameRegisteredDomain(seed.toString(), candidate.toString())) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
function shouldFollowLink(seed, current, next) {
|
|
139
|
+
if (!isAllowedDomain(seed, next)) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
if (isGithubFamily(next.hostname)) {
|
|
143
|
+
const pathname = next.pathname.toLowerCase();
|
|
144
|
+
if (pathname.includes('/blob/')) {
|
|
145
|
+
const ext = path.extname(pathname);
|
|
146
|
+
return CODE_EXTS.has(ext);
|
|
147
|
+
}
|
|
148
|
+
if (pathname.includes('/tree/')) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
return CODE_EXTS.has(path.extname(pathname));
|
|
152
|
+
}
|
|
153
|
+
const ext = path.extname(next.pathname.toLowerCase());
|
|
154
|
+
if (CODE_EXTS.has(ext)) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
if (ext === '.html' || ext === '.htm' || ext === '') {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
return next.pathname.endsWith('/');
|
|
161
|
+
}
|
|
162
|
+
function isGithubSingleFileUrl(url) {
|
|
163
|
+
const hostname = url.hostname.toLowerCase();
|
|
164
|
+
const segments = url.pathname.split('/').filter((s) => s.length > 0);
|
|
165
|
+
if (segments.length === 0) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
const last = segments[segments.length - 1];
|
|
169
|
+
const hasExtension = path.extname(last) !== '';
|
|
170
|
+
if (hostname === 'github.com') {
|
|
171
|
+
if (!hasExtension) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
if (segments.length >= 3) {
|
|
175
|
+
const marker = segments[2];
|
|
176
|
+
if (marker === 'blob' || marker === 'raw') {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
if (hostname === 'raw.githubusercontent.com' || hostname === 'raw.github.com' || hostname.endsWith('githubusercontent.com')) {
|
|
183
|
+
return hasExtension;
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
function isCodeLikeUrl(url) {
|
|
188
|
+
const ext = path.extname(url.pathname).toLowerCase();
|
|
189
|
+
if (!ext)
|
|
190
|
+
return false;
|
|
191
|
+
return CODE_EXTS.has(ext);
|
|
192
|
+
}
|
|
193
|
+
function isBinaryContentType(ct) {
|
|
194
|
+
if (!ct)
|
|
195
|
+
return false;
|
|
196
|
+
const t = ct.toLowerCase();
|
|
197
|
+
if (t.includes('text/'))
|
|
198
|
+
return false;
|
|
199
|
+
if (t.includes('json'))
|
|
200
|
+
return false;
|
|
201
|
+
if (t.includes('xml'))
|
|
202
|
+
return false;
|
|
203
|
+
if (t.includes('html'))
|
|
204
|
+
return false; // we will parse links from HTML
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
function looksLikeHtml(buffer) {
|
|
208
|
+
const text = buffer.toString('utf8');
|
|
209
|
+
const lines = text.split(/\r?\n/);
|
|
210
|
+
let inspected = 0;
|
|
211
|
+
for (const line of lines) {
|
|
212
|
+
const trimmed = line.trim();
|
|
213
|
+
if (trimmed.length === 0)
|
|
214
|
+
continue;
|
|
215
|
+
inspected += 1;
|
|
216
|
+
const lower = trimmed.toLowerCase();
|
|
217
|
+
if (lower.startsWith('<!doctype html') || lower.startsWith('<html') || lower.includes('<html')) {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
if (inspected >= 10)
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
function looksLikePdf(buffer) {
|
|
226
|
+
if (buffer.length < 5)
|
|
227
|
+
return false;
|
|
228
|
+
const header = buffer.subarray(0, 5).toString('ascii');
|
|
229
|
+
if (header === '%PDF-')
|
|
230
|
+
return true;
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
async function pruneNonSourceFiles(root, allowedExtensions) {
|
|
234
|
+
let kept = 0;
|
|
235
|
+
let removed = 0;
|
|
236
|
+
async function walk(dir) {
|
|
237
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
238
|
+
let hasKept = false;
|
|
239
|
+
for (const entry of entries) {
|
|
240
|
+
const full = path.join(dir, entry.name);
|
|
241
|
+
if (entry.isDirectory()) {
|
|
242
|
+
const childHasKept = await walk(full);
|
|
243
|
+
if (!childHasKept) {
|
|
244
|
+
await fs.rm(full, { recursive: true, force: true });
|
|
245
|
+
removed += 1;
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
hasKept = true;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
const upperName = entry.name.toUpperCase();
|
|
253
|
+
if (LICENSE_FILENAMES.has(upperName) || entry.name === '_metadata.json') {
|
|
254
|
+
hasKept = true;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
258
|
+
if (allowedExtensions.has(ext)) {
|
|
259
|
+
kept += 1;
|
|
260
|
+
hasKept = true;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
await fs.rm(full, { force: true });
|
|
264
|
+
removed += 1;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return hasKept;
|
|
269
|
+
}
|
|
270
|
+
await walk(root);
|
|
271
|
+
return { kept, removed };
|
|
272
|
+
}
|
|
273
|
+
function parseFileEntry(fullPath) {
|
|
274
|
+
const dir = path.dirname(fullPath);
|
|
275
|
+
const name = path.basename(fullPath);
|
|
276
|
+
const parsed = path.parse(name);
|
|
277
|
+
return {
|
|
278
|
+
path: fullPath,
|
|
279
|
+
dir,
|
|
280
|
+
name,
|
|
281
|
+
base: parsed.name,
|
|
282
|
+
ext: parsed.ext.toLowerCase(),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
async function collectFileEntries(root) {
|
|
286
|
+
const result = [];
|
|
287
|
+
async function walk(current) {
|
|
288
|
+
const entries = await fs.readdir(current, { withFileTypes: true });
|
|
289
|
+
for (const entry of entries) {
|
|
290
|
+
const full = path.join(current, entry.name);
|
|
291
|
+
if (entry.isDirectory()) {
|
|
292
|
+
await walk(full);
|
|
293
|
+
}
|
|
294
|
+
else if (entry.isFile()) {
|
|
295
|
+
result.push(parseFileEntry(full));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
await walk(root);
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
function detectMachineSuffix(base) {
|
|
303
|
+
const lower = base.toLowerCase();
|
|
304
|
+
for (const token of MACHINE_SUFFIXES) {
|
|
305
|
+
const suffixLen = token.length;
|
|
306
|
+
if (lower.endsWith(`_${token}`)) {
|
|
307
|
+
return { root: base.slice(0, base.length - suffixLen - 1), suffix: token };
|
|
308
|
+
}
|
|
309
|
+
if (lower.endsWith(`-${token}`)) {
|
|
310
|
+
return { root: base.slice(0, base.length - suffixLen - 1), suffix: token };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
async function cleanupC64SpecificFiles(root, log) {
|
|
316
|
+
const removedPaths = new Set();
|
|
317
|
+
const removeFile = async (entry, reason, extra) => {
|
|
318
|
+
if (removedPaths.has(entry.path))
|
|
319
|
+
return;
|
|
320
|
+
await fs.rm(entry.path, { force: true });
|
|
321
|
+
removedPaths.add(entry.path);
|
|
322
|
+
log({ level: 'info', event: reason, data: { path: entry.path, ...extra } });
|
|
323
|
+
};
|
|
324
|
+
let entries = await collectFileEntries(root);
|
|
325
|
+
let removedNonC64 = 0;
|
|
326
|
+
for (const entry of entries) {
|
|
327
|
+
if (removedPaths.has(entry.path))
|
|
328
|
+
continue;
|
|
329
|
+
const lowerName = entry.name.toLowerCase();
|
|
330
|
+
if (NON_C64_MACHINE_PATTERN.test(lowerName) && !/\bc64\b/.test(lowerName)) {
|
|
331
|
+
await removeFile(entry, 'cleanup_remove_non_c64');
|
|
332
|
+
removedNonC64++;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
entries = entries.filter((entry) => !removedPaths.has(entry.path));
|
|
336
|
+
let removedLocaleVariants = 0;
|
|
337
|
+
const localeGroups = new Map();
|
|
338
|
+
for (const entry of entries) {
|
|
339
|
+
const match = entry.base.match(COUNTRY_CODE_PATTERN);
|
|
340
|
+
if (!match)
|
|
341
|
+
continue;
|
|
342
|
+
const rootBase = match[1];
|
|
343
|
+
const locale = match[2].toLowerCase();
|
|
344
|
+
if (!/^[a-z]{2}$/.test(locale))
|
|
345
|
+
continue;
|
|
346
|
+
const key = `${entry.dir}::${rootBase}`;
|
|
347
|
+
if (!localeGroups.has(key)) {
|
|
348
|
+
localeGroups.set(key, []);
|
|
349
|
+
}
|
|
350
|
+
localeGroups.get(key).push({ entry, locale });
|
|
351
|
+
}
|
|
352
|
+
for (const group of localeGroups.values()) {
|
|
353
|
+
const hasEnglish = group.some((item) => item.locale === 'en');
|
|
354
|
+
if (!hasEnglish)
|
|
355
|
+
continue;
|
|
356
|
+
for (const item of group) {
|
|
357
|
+
if (item.locale !== 'en') {
|
|
358
|
+
await removeFile(item.entry, 'cleanup_remove_locale_variant', { locale: item.locale });
|
|
359
|
+
removedLocaleVariants++;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
entries = entries.filter((entry) => !removedPaths.has(entry.path));
|
|
364
|
+
let removedAltMachineSuffix = 0;
|
|
365
|
+
const suffixGroups = new Map();
|
|
366
|
+
for (const entry of entries) {
|
|
367
|
+
const detected = detectMachineSuffix(entry.base);
|
|
368
|
+
if (!detected)
|
|
369
|
+
continue;
|
|
370
|
+
const key = `${entry.dir}::${detected.root}`;
|
|
371
|
+
if (!suffixGroups.has(key)) {
|
|
372
|
+
suffixGroups.set(key, []);
|
|
373
|
+
}
|
|
374
|
+
suffixGroups.get(key).push({ entry, suffix: detected.suffix.toLowerCase() });
|
|
375
|
+
}
|
|
376
|
+
for (const group of suffixGroups.values()) {
|
|
377
|
+
const hasC64 = group.some((item) => item.suffix === 'c64');
|
|
378
|
+
if (!hasC64)
|
|
379
|
+
continue;
|
|
380
|
+
for (const item of group) {
|
|
381
|
+
if (item.suffix !== 'c64') {
|
|
382
|
+
await removeFile(item.entry, 'cleanup_remove_machine_variant', { suffix: item.suffix });
|
|
383
|
+
removedAltMachineSuffix++;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const remainingEntries = (await collectFileEntries(root)).filter((entry) => !removedPaths.has(entry.path));
|
|
388
|
+
return {
|
|
389
|
+
removedNonC64,
|
|
390
|
+
removedLocaleVariants,
|
|
391
|
+
removedAltMachineSuffix,
|
|
392
|
+
remaining: remainingEntries.length,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
async function hashFile(filePath) {
|
|
396
|
+
const hash = createHash('sha256');
|
|
397
|
+
return new Promise((resolve, reject) => {
|
|
398
|
+
const stream = createReadStream(filePath);
|
|
399
|
+
stream.on('error', reject);
|
|
400
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
401
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
async function removeDuplicateFiles(root, log) {
|
|
405
|
+
const entries = await collectFileEntries(root);
|
|
406
|
+
const bySize = new Map();
|
|
407
|
+
for (const entry of entries) {
|
|
408
|
+
if (LICENSE_FILENAMES.has(entry.name.toUpperCase()) || entry.name === '_metadata.json') {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const stat = await fs.stat(entry.path).catch(() => null);
|
|
412
|
+
if (!stat || !stat.isFile())
|
|
413
|
+
continue;
|
|
414
|
+
const size = stat.size;
|
|
415
|
+
if (!bySize.has(size)) {
|
|
416
|
+
bySize.set(size, []);
|
|
417
|
+
}
|
|
418
|
+
bySize.get(size).push({ ...entry, size });
|
|
419
|
+
}
|
|
420
|
+
let removedCount = 0;
|
|
421
|
+
for (const group of bySize.values()) {
|
|
422
|
+
if (group.length < 2)
|
|
423
|
+
continue;
|
|
424
|
+
const byHash = new Map();
|
|
425
|
+
for (const entry of group) {
|
|
426
|
+
const digest = await hashFile(entry.path);
|
|
427
|
+
if (!byHash.has(digest)) {
|
|
428
|
+
byHash.set(digest, []);
|
|
429
|
+
}
|
|
430
|
+
byHash.get(digest).push(entry);
|
|
431
|
+
}
|
|
432
|
+
for (const [digest, dupes] of byHash.entries()) {
|
|
433
|
+
if (dupes.length < 2)
|
|
434
|
+
continue;
|
|
435
|
+
const sorted = dupes.sort((a, b) => a.path.localeCompare(b.path));
|
|
436
|
+
const keeper = sorted[0];
|
|
437
|
+
for (const duplicate of sorted.slice(1)) {
|
|
438
|
+
await fs.rm(duplicate.path, { force: true });
|
|
439
|
+
removedCount++;
|
|
440
|
+
log({
|
|
441
|
+
level: 'info',
|
|
442
|
+
event: 'duplicate_removed',
|
|
443
|
+
data: { kept: keeper.path, removed: duplicate.path, hash: digest },
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
log({ level: 'info', event: 'duplicate_cleanup_done', data: { removed: removedCount } });
|
|
449
|
+
}
|
|
450
|
+
async function downloadGithubRepoZip(seedUrl, outDir, userAgent, log) {
|
|
451
|
+
const info = parseGithubRepoInfo(seedUrl);
|
|
452
|
+
if (!info) {
|
|
453
|
+
throw new Error('Not a GitHub repository URL');
|
|
454
|
+
}
|
|
455
|
+
const repoBase = sanitizeForFs(`${info.owner}_${info.repo}`);
|
|
456
|
+
const repoDir = path.join(outDir, repoBase);
|
|
457
|
+
await fs.rm(repoDir, { recursive: true, force: true });
|
|
458
|
+
await fs.mkdir(repoDir, { recursive: true });
|
|
459
|
+
const branches = ['main', 'master'];
|
|
460
|
+
const zipPath = path.join(repoDir, 'repo.zip');
|
|
461
|
+
let downloaded = false;
|
|
462
|
+
let branchUsed = branches[0];
|
|
463
|
+
for (const branch of branches) {
|
|
464
|
+
const zipUrl = `https://github.com/${info.owner}/${info.repo}/archive/refs/heads/${branch}.zip`;
|
|
465
|
+
log({ level: 'info', event: 'github_repo_zip_download', data: { url: zipUrl } });
|
|
466
|
+
const response = await fetch(zipUrl, {
|
|
467
|
+
headers: {
|
|
468
|
+
'User-Agent': userAgent,
|
|
469
|
+
'Accept': 'application/octet-stream',
|
|
470
|
+
},
|
|
471
|
+
}).catch((err) => {
|
|
472
|
+
log({ level: 'error', event: 'github_repo_error', data: { seed: seedUrl.toString(), message: err.message } });
|
|
473
|
+
return { ok: false };
|
|
474
|
+
});
|
|
475
|
+
if (response && response.ok && response.body) {
|
|
476
|
+
await pipeline(response.body, createWriteStream(zipPath));
|
|
477
|
+
downloaded = true;
|
|
478
|
+
branchUsed = branch;
|
|
479
|
+
log({ level: 'info', event: 'github_repo_zip_saved', data: { path: zipPath } });
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (!downloaded) {
|
|
484
|
+
throw new Error('Unable to download repository zip (branches main/master)');
|
|
485
|
+
}
|
|
486
|
+
await execAsync(`unzip -q -o "${zipPath}" -d "${repoDir}"`);
|
|
487
|
+
await fs.rm(zipPath, { force: true });
|
|
488
|
+
const entries = await fs.readdir(repoDir, { withFileTypes: true });
|
|
489
|
+
const innerDir = entries.find((entry) => entry.isDirectory());
|
|
490
|
+
if (innerDir) {
|
|
491
|
+
const innerPath = path.join(repoDir, innerDir.name);
|
|
492
|
+
const innerEntries = await fs.readdir(innerPath, { withFileTypes: true });
|
|
493
|
+
for (const entry of innerEntries) {
|
|
494
|
+
const from = path.join(innerPath, entry.name);
|
|
495
|
+
const to = path.join(repoDir, entry.name);
|
|
496
|
+
await fs.rename(from, to);
|
|
497
|
+
}
|
|
498
|
+
await fs.rm(innerPath, { recursive: true, force: true });
|
|
499
|
+
}
|
|
500
|
+
return { repoDir, branch: branchUsed };
|
|
501
|
+
}
|
|
502
|
+
async function defaultGithubRepoFetcher(args) {
|
|
503
|
+
const { seedUrl, outDir, log, allowedExtensions } = args;
|
|
504
|
+
const info = parseGithubRepoInfo(seedUrl);
|
|
505
|
+
if (!info) {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
509
|
+
let repoDir;
|
|
510
|
+
let branchUsed = 'main';
|
|
511
|
+
try {
|
|
512
|
+
const result = await downloadGithubRepoZip(seedUrl, outDir, args.userAgent, log);
|
|
513
|
+
repoDir = result.repoDir;
|
|
514
|
+
branchUsed = result.branch;
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
log({ level: 'error', event: 'github_repo_error', data: { seed: seedUrl.toString(), message: err.message } });
|
|
518
|
+
return { seed: seedUrl.toString(), visited: 1, downloaded: 0, skipped: 0, errors: 1 };
|
|
519
|
+
}
|
|
520
|
+
const { kept, removed } = await pruneNonSourceFiles(repoDir, allowedExtensions);
|
|
521
|
+
const cleanupStats = await cleanupC64SpecificFiles(repoDir, log);
|
|
522
|
+
const totalRemoved = removed + cleanupStats.removedNonC64 + cleanupStats.removedLocaleVariants + cleanupStats.removedAltMachineSuffix;
|
|
523
|
+
const downloaded = cleanupStats.remaining;
|
|
524
|
+
const licenseFetcher = args.licenseFetcher ?? fetchGithubLicense;
|
|
525
|
+
let licenseInfo = null;
|
|
526
|
+
try {
|
|
527
|
+
licenseInfo = await licenseFetcher({ owner: info.owner, repo: info.repo, userAgent: args.userAgent, log });
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
log({ level: 'warn', event: 'github_repo_license_error', data: { seed: seedUrl.toString(), message: err instanceof Error ? err.message : String(err) } });
|
|
531
|
+
}
|
|
532
|
+
if (licenseInfo?.licenseText) {
|
|
533
|
+
try {
|
|
534
|
+
const licensePath = path.join(repoDir, 'LICENSE');
|
|
535
|
+
await fs.writeFile(licensePath, licenseInfo.licenseText, 'utf8');
|
|
536
|
+
log({ level: 'info', event: 'github_repo_license_written', data: { path: licensePath } });
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
log({ level: 'warn', event: 'github_repo_license_write_failed', data: { message: err instanceof Error ? err.message : String(err) } });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
await writeGithubRepoMetadata(repoDir, {
|
|
544
|
+
type: 'github',
|
|
545
|
+
owner: info.owner,
|
|
546
|
+
repo: info.repo,
|
|
547
|
+
branch: branchUsed,
|
|
548
|
+
repoUrl: `https://github.com/${info.owner}/${info.repo}`,
|
|
549
|
+
license: licenseInfo,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
log({ level: 'warn', event: 'github_repo_metadata_write_failed', data: { message: err instanceof Error ? err.message : String(err) } });
|
|
554
|
+
}
|
|
555
|
+
log({
|
|
556
|
+
level: 'info',
|
|
557
|
+
event: 'github_repo_cloned',
|
|
558
|
+
data: {
|
|
559
|
+
seed: seedUrl.toString(),
|
|
560
|
+
repoDir,
|
|
561
|
+
branch: branchUsed,
|
|
562
|
+
downloaded,
|
|
563
|
+
removed: totalRemoved,
|
|
564
|
+
removedNonC64: cleanupStats.removedNonC64,
|
|
565
|
+
removedLocaleVariants: cleanupStats.removedLocaleVariants,
|
|
566
|
+
removedMachineVariants: cleanupStats.removedAltMachineSuffix,
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
return { seed: seedUrl.toString(), visited: 1, downloaded, skipped: totalRemoved, errors: 0 };
|
|
570
|
+
}
|
|
571
|
+
async function writeGithubRepoMetadata(repoDir, payload) {
|
|
572
|
+
const now = new Date().toISOString();
|
|
573
|
+
const data = {
|
|
574
|
+
type: payload.type,
|
|
575
|
+
owner: payload.owner,
|
|
576
|
+
repo: payload.repo,
|
|
577
|
+
branch: payload.branch,
|
|
578
|
+
repoUrl: payload.repoUrl,
|
|
579
|
+
generatedAt: now,
|
|
580
|
+
};
|
|
581
|
+
if (payload.license) {
|
|
582
|
+
data.license = {
|
|
583
|
+
spdxId: payload.license.licenseId ?? null,
|
|
584
|
+
name: payload.license.licenseName ?? null,
|
|
585
|
+
url: payload.license.licenseUrl ?? null,
|
|
586
|
+
attribution: payload.license.attribution ?? null,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const metadataPath = path.join(repoDir, '_metadata.json');
|
|
590
|
+
await fs.writeFile(metadataPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
591
|
+
}
|
|
592
|
+
function buildLicenseAttribution(owner, repo, licenseId, licenseName) {
|
|
593
|
+
const repoRef = `${owner}/${repo}`;
|
|
594
|
+
const normalizedId = licenseId && licenseId !== 'NOASSERTION' ? licenseId : null;
|
|
595
|
+
const label = normalizedId ?? (licenseName ?? null);
|
|
596
|
+
const suffix = label ? ` (${label})` : ' (License unknown)';
|
|
597
|
+
return `Source: ${repoRef}${suffix}`;
|
|
598
|
+
}
|
|
599
|
+
async function fetchGithubLicense(args) {
|
|
600
|
+
const apiUrl = `https://api.github.com/repos/${args.owner}/${args.repo}/license`;
|
|
601
|
+
const headers = {
|
|
602
|
+
'User-Agent': args.userAgent,
|
|
603
|
+
Accept: 'application/vnd.github+json',
|
|
604
|
+
};
|
|
605
|
+
try {
|
|
606
|
+
const response = await fetch(apiUrl, { headers });
|
|
607
|
+
if (response.ok) {
|
|
608
|
+
const json = await response.json();
|
|
609
|
+
const licenseId = json?.license?.spdx_id ?? null;
|
|
610
|
+
const licenseName = json?.license?.name ?? null;
|
|
611
|
+
const licenseUrl = json?.html_url ?? null;
|
|
612
|
+
let licenseText;
|
|
613
|
+
if (typeof json?.content === 'string' && (json?.encoding ?? '').toLowerCase() === 'base64') {
|
|
614
|
+
try {
|
|
615
|
+
licenseText = Buffer.from(json.content, 'base64').toString('utf8');
|
|
616
|
+
}
|
|
617
|
+
catch (err) {
|
|
618
|
+
args.log({ level: 'warn', event: 'github_repo_license_decode_failed', data: { message: err instanceof Error ? err.message : String(err) } });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
else if (json?.download_url) {
|
|
622
|
+
try {
|
|
623
|
+
const textRes = await fetch(json.download_url, { headers: { 'User-Agent': args.userAgent } });
|
|
624
|
+
if (textRes.ok) {
|
|
625
|
+
licenseText = await textRes.text();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
catch (err) {
|
|
629
|
+
args.log({ level: 'warn', event: 'github_repo_license_download_failed', data: { message: err instanceof Error ? err.message : String(err) } });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return {
|
|
633
|
+
licenseId,
|
|
634
|
+
licenseName,
|
|
635
|
+
licenseUrl,
|
|
636
|
+
licenseText,
|
|
637
|
+
attribution: buildLicenseAttribution(args.owner, args.repo, licenseId, licenseName),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
if (response.status !== 404) {
|
|
641
|
+
args.log({ level: 'warn', event: 'github_repo_license_http_error', data: { status: response.status, url: apiUrl } });
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
catch (err) {
|
|
645
|
+
args.log({ level: 'warn', event: 'github_repo_license_fetch_error', data: { message: err instanceof Error ? err.message : String(err), url: apiUrl } });
|
|
646
|
+
}
|
|
647
|
+
const candidates = ['LICENSE', 'LICENSE.txt', 'LICENSE.md', 'COPYING', 'COPYING.txt', 'COPYING.md'];
|
|
648
|
+
for (const candidate of candidates) {
|
|
649
|
+
const rawUrl = `https://raw.githubusercontent.com/${args.owner}/${args.repo}/HEAD/${candidate}`;
|
|
650
|
+
try {
|
|
651
|
+
const res = await fetch(rawUrl, { headers: { 'User-Agent': args.userAgent } });
|
|
652
|
+
if (res.ok) {
|
|
653
|
+
const text = await res.text();
|
|
654
|
+
const licenseUrl = `https://github.com/${args.owner}/${args.repo}/blob/HEAD/${candidate}`;
|
|
655
|
+
return {
|
|
656
|
+
licenseId: null,
|
|
657
|
+
licenseName: null,
|
|
658
|
+
licenseUrl,
|
|
659
|
+
licenseText: text,
|
|
660
|
+
attribution: buildLicenseAttribution(args.owner, args.repo, null, null),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
args.log({ level: 'warn', event: 'github_repo_license_raw_error', data: { message: err instanceof Error ? err.message : String(err), url: rawUrl } });
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
licenseId: null,
|
|
670
|
+
licenseName: null,
|
|
671
|
+
licenseUrl: null,
|
|
672
|
+
licenseText: undefined,
|
|
673
|
+
attribution: buildLicenseAttribution(args.owner, args.repo, null, null),
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
function parseGithubRepoInfo(url) {
|
|
677
|
+
if (!isGithubFamily(url.hostname) || url.hostname.toLowerCase() !== 'github.com') {
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
const segments = url.pathname.split('/').filter((s) => s.length > 0);
|
|
681
|
+
if (segments.length < 2) {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
const [owner, repo] = segments;
|
|
685
|
+
if (!owner || !repo) {
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
if (isGithubSingleFileUrl(url)) {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
const repoName = repo.endsWith('.git') ? repo.slice(0, -4) : repo;
|
|
692
|
+
return { owner, repo: repoName };
|
|
693
|
+
}
|
|
694
|
+
export async function fetchFromCsv(opts) {
|
|
695
|
+
const rows = await readCsv(opts.csvPath);
|
|
696
|
+
const outDir = path.resolve(opts.outDir);
|
|
697
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
698
|
+
const defaultDepth = opts.defaultDepth ?? 5;
|
|
699
|
+
const perDomainRps = opts.perDomainRps ?? 5;
|
|
700
|
+
const maxReq = opts.maxRequestsPerSeed ?? 500;
|
|
701
|
+
const concurrency = Math.max(1, opts.concurrency ?? 6);
|
|
702
|
+
const maxBytes = opts.maxContentBytes ?? 2 * 1024 * 1024;
|
|
703
|
+
const userAgent = opts.userAgent ?? 'c64bridge-fetcher/0.1';
|
|
704
|
+
const log = opts.log ?? (() => { });
|
|
705
|
+
const maxRetries = Math.max(0, opts.maxRetries ?? 3);
|
|
706
|
+
const throttleBackoffFactor = opts.throttleBackoffFactor ?? 0.5;
|
|
707
|
+
const githubRepoFetcher = opts.githubRepoFetcher ?? defaultGithubRepoFetcher;
|
|
708
|
+
const limiter = new AdaptiveRateLimiter(perDomainRps, opts.timeSource ?? new RealTimeSource(), {
|
|
709
|
+
increaseIntervalMs: opts.recoveryIntervalMs ?? 15000,
|
|
710
|
+
increaseStep: opts.recoveryStep ?? 1,
|
|
711
|
+
minRps: 1,
|
|
712
|
+
});
|
|
713
|
+
const summaries = [];
|
|
714
|
+
for (const row of rows) {
|
|
715
|
+
const seed = row.link;
|
|
716
|
+
const maybeSeedUrl = parseUrlSafe(seed);
|
|
717
|
+
if (!maybeSeedUrl) {
|
|
718
|
+
log({ level: 'warn', event: 'seed_invalid_url', data: { seed } });
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const seedUrl = maybeSeedUrl;
|
|
722
|
+
if (!isGithubSingleFileUrl(seedUrl)) {
|
|
723
|
+
const repoInfo = parseGithubRepoInfo(seedUrl);
|
|
724
|
+
if (repoInfo) {
|
|
725
|
+
const repoOutDir = path.join(outDir, sanitizeForFs(seedUrl.hostname.toLowerCase()));
|
|
726
|
+
const summary = await githubRepoFetcher({
|
|
727
|
+
seedUrl,
|
|
728
|
+
outDir: repoOutDir,
|
|
729
|
+
log,
|
|
730
|
+
userAgent,
|
|
731
|
+
allowedExtensions: CODE_EXTS,
|
|
732
|
+
licenseFetcher: opts.githubLicenseFetcher,
|
|
733
|
+
});
|
|
734
|
+
if (summary) {
|
|
735
|
+
summaries.push(summary);
|
|
736
|
+
}
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
const depth = Number.isFinite(row.depth ?? NaN) ? row.depth : defaultDepth;
|
|
741
|
+
const registeredDomain = getRegisteredDomain(seedUrl.hostname);
|
|
742
|
+
const domainKey = registeredDomain;
|
|
743
|
+
const queue = [{ url: seedUrl, depth: 0 }];
|
|
744
|
+
const seen = new Set();
|
|
745
|
+
let visited = 0;
|
|
746
|
+
let downloaded = 0;
|
|
747
|
+
let skipped = 0;
|
|
748
|
+
let errors = 0;
|
|
749
|
+
const perSeedOut = path.join(outDir, sanitizeForFs(registeredDomain));
|
|
750
|
+
await fs.mkdir(perSeedOut, { recursive: true });
|
|
751
|
+
log({ level: 'info', event: 'seed_start', data: { seed, depth, registeredDomain, maxReq, perDomainRps } });
|
|
752
|
+
async function worker() {
|
|
753
|
+
// eslint-disable-next-line no-constant-condition
|
|
754
|
+
while (true) {
|
|
755
|
+
if (visited >= maxReq)
|
|
756
|
+
return;
|
|
757
|
+
const item = queue.shift();
|
|
758
|
+
if (!item)
|
|
759
|
+
return;
|
|
760
|
+
try {
|
|
761
|
+
const { url, depth: d } = item;
|
|
762
|
+
const originalHref = url.toString();
|
|
763
|
+
const { url: targetUrl, raw, changed } = normalizeGithubUrl(url);
|
|
764
|
+
const targetHref = targetUrl.toString();
|
|
765
|
+
if (seen.has(targetHref))
|
|
766
|
+
continue;
|
|
767
|
+
seen.add(targetHref);
|
|
768
|
+
if (!isAllowedDomain(seedUrl, targetUrl)) {
|
|
769
|
+
skipped++;
|
|
770
|
+
log({ level: 'info', event: 'skip_out_of_domain', data: { seed, url: targetHref, original: originalHref } });
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (changed) {
|
|
774
|
+
log({ level: 'info', event: 'normalize_github', data: { from: originalHref, to: targetHref } });
|
|
775
|
+
}
|
|
776
|
+
visited++;
|
|
777
|
+
const domainKeyForRequest = getRegisteredDomain(targetUrl.hostname);
|
|
778
|
+
await limiter.consume(domainKeyForRequest);
|
|
779
|
+
log({ level: 'info', event: 'request_start', data: { url: targetHref, depth: d, visited, remaining: Math.max(0, maxReq - visited) } });
|
|
780
|
+
const requester = opts.request ?? httpGet;
|
|
781
|
+
const res = await withRetries(requester, targetUrl, userAgent, maxRetries, async (statusCode) => {
|
|
782
|
+
if (statusCode === 429 || (statusCode && statusCode >= 500 && statusCode < 600)) {
|
|
783
|
+
limiter.notifyThrottle(domainKeyForRequest, throttleBackoffFactor);
|
|
784
|
+
}
|
|
785
|
+
}).catch((err) => ({ error: err }));
|
|
786
|
+
if ('error' in res) {
|
|
787
|
+
errors++;
|
|
788
|
+
log({ level: 'warn', event: 'request_error', data: { url: targetHref, message: res.error.message } });
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
const { statusCode, headers, body } = res;
|
|
792
|
+
const ct = headers['content-type']?.toString();
|
|
793
|
+
const contentLength = Number(headers['content-length'] ?? 0);
|
|
794
|
+
log({ level: 'info', event: 'request_ok', data: { url: targetHref, statusCode, depth: d, discovered: 0, visited, remaining: Math.max(0, maxReq - visited), raw } });
|
|
795
|
+
if (statusCode && (statusCode < 200 || statusCode >= 400)) {
|
|
796
|
+
log({ level: 'error', event: 'http_error', data: { url: targetHref, statusCode } });
|
|
797
|
+
}
|
|
798
|
+
if (statusCode && statusCode >= 300 && statusCode < 400 && headers.location) {
|
|
799
|
+
const loc = headers.location.toString();
|
|
800
|
+
let redirected = null;
|
|
801
|
+
try {
|
|
802
|
+
redirected = new URL(loc, targetUrl);
|
|
803
|
+
}
|
|
804
|
+
catch { }
|
|
805
|
+
if (redirected) {
|
|
806
|
+
const redirectUrl = redirected;
|
|
807
|
+
if (isAllowedDomain(seedUrl, redirectUrl)) {
|
|
808
|
+
log({ level: 'info', event: 'redirect', data: { from: targetHref, to: redirectUrl.toString() } });
|
|
809
|
+
queue.unshift({ url: redirectUrl, depth: d });
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
skipped++;
|
|
813
|
+
log({ level: 'info', event: 'skip_redirect_out_of_domain', data: { from: targetHref, to: redirectUrl.toString() } });
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
const isHtml = ct?.toLowerCase().includes('html');
|
|
819
|
+
if (contentLength > maxBytes || body.length > maxBytes || isBinaryContentType(ct) || (!isHtml && (looksLikeHtml(body) || looksLikePdf(body)))) {
|
|
820
|
+
skipped++;
|
|
821
|
+
log({ level: 'info', event: 'skip_binary_or_oversize', data: { url: targetHref, contentLength, contentType: ct } });
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
if (isHtml) {
|
|
825
|
+
const links = extractLinks(targetUrl, body.toString('utf8'));
|
|
826
|
+
const nextDepth = d + 1;
|
|
827
|
+
if (nextDepth <= depth) {
|
|
828
|
+
for (const next of links) {
|
|
829
|
+
const parsed = parseUrlSafe(next);
|
|
830
|
+
if (!parsed)
|
|
831
|
+
continue;
|
|
832
|
+
const linkUrl = parsed;
|
|
833
|
+
if (!shouldFollowLink(seedUrl, targetUrl, linkUrl)) {
|
|
834
|
+
skipped++;
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
queue.push({ url: linkUrl, depth: nextDepth });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
log({ level: 'info', event: 'depth_exceeded', data: { url: targetHref, depth: d, maxDepth: depth } });
|
|
842
|
+
}
|
|
843
|
+
log({ level: 'info', event: 'discovered_links', data: { from: targetHref, count: links.length, depth: nextDepth } });
|
|
844
|
+
}
|
|
845
|
+
if (!isHtml && isCodeLikeUrl(targetUrl)) {
|
|
846
|
+
const rel = path.join(perSeedOut, sanitizeForFs(targetUrl.pathname));
|
|
847
|
+
await fs.mkdir(path.dirname(rel), { recursive: true });
|
|
848
|
+
await fs.writeFile(rel, body);
|
|
849
|
+
downloaded++;
|
|
850
|
+
log({ level: 'info', event: 'download_success', data: { url: targetHref, path: rel, bytes: body.length } });
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
catch (err) {
|
|
854
|
+
errors++;
|
|
855
|
+
log({ level: 'error', event: 'worker_exception', data: { message: err instanceof Error ? err.message : String(err) } });
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const workers = Array.from({ length: concurrency }, () => worker());
|
|
860
|
+
await Promise.all(workers);
|
|
861
|
+
log({ level: 'info', event: 'seed_done', data: { seed, visited, downloaded, skipped, errors, maxReq } });
|
|
862
|
+
summaries.push({ seed, visited, downloaded, skipped, errors });
|
|
863
|
+
}
|
|
864
|
+
try {
|
|
865
|
+
await removeDuplicateFiles(outDir, log);
|
|
866
|
+
}
|
|
867
|
+
catch (err) {
|
|
868
|
+
log({ level: 'error', event: 'duplicate_cleanup_error', data: { message: err instanceof Error ? err.message : String(err) } });
|
|
869
|
+
}
|
|
870
|
+
return summaries;
|
|
871
|
+
}
|
|
872
|
+
function extractLinks(base, html) {
|
|
873
|
+
const results = [];
|
|
874
|
+
const hrefRe = /\b(?:href|src)\s*=\s*"([^"]+)"/gi;
|
|
875
|
+
let m;
|
|
876
|
+
while ((m = hrefRe.exec(html)) !== null) {
|
|
877
|
+
const raw = m[1];
|
|
878
|
+
try {
|
|
879
|
+
const u = new URL(raw, base);
|
|
880
|
+
results.push(u.toString());
|
|
881
|
+
}
|
|
882
|
+
catch {
|
|
883
|
+
// ignore
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return results;
|
|
887
|
+
}
|
|
888
|
+
async function httpGet(url, userAgent) {
|
|
889
|
+
const lib = url.protocol === 'https:' ? https : http;
|
|
890
|
+
return new Promise((resolve, reject) => {
|
|
891
|
+
const req = lib.get(url, { headers: { 'user-agent': userAgent } }, (res) => {
|
|
892
|
+
const chunks = [];
|
|
893
|
+
res.on('data', (c) => chunks.push(Buffer.from(c)));
|
|
894
|
+
res.on('end', () => {
|
|
895
|
+
resolve({ statusCode: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks) });
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
req.on('error', reject);
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
function sanitizeForFs(input) {
|
|
902
|
+
return input
|
|
903
|
+
.replace(/[^a-zA-Z0-9_.\-\/]/g, '_')
|
|
904
|
+
.replace(/^\/+/, '')
|
|
905
|
+
.replace(/^_+/, '')
|
|
906
|
+
.replace(/_+$/, '');
|
|
907
|
+
}
|
|
908
|
+
async function withRetries(requester, url, userAgent, maxRetries, onStatus) {
|
|
909
|
+
let attempt = 0;
|
|
910
|
+
let delay = 50; // small base for tests; real backoff controlled by caller env if needed
|
|
911
|
+
while (true) {
|
|
912
|
+
try {
|
|
913
|
+
const res = await requester(url, userAgent);
|
|
914
|
+
if (onStatus)
|
|
915
|
+
await onStatus(res.statusCode);
|
|
916
|
+
if (res.statusCode && (res.statusCode >= 500 || res.statusCode === 429)) {
|
|
917
|
+
if (attempt >= maxRetries)
|
|
918
|
+
return res; // give up returning last response
|
|
919
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
920
|
+
attempt++;
|
|
921
|
+
delay = Math.min(delay * 2, 1000);
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
return res;
|
|
925
|
+
}
|
|
926
|
+
catch (err) {
|
|
927
|
+
if (attempt >= maxRetries)
|
|
928
|
+
throw err;
|
|
929
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
930
|
+
attempt++;
|
|
931
|
+
delay = Math.min(delay * 2, 1000);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|