ants-move 0.0.1 → 0.0.2

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ants-move
2
2
 
3
- `ants-move` is an open-source TypeScript CLI for moving data between systems. Its commands are small workers: collectors bring data in, and future automation commands can move that data into forms and other systems. The first release collects data from 36Kr, Toutiao, and Hacker News.
3
+ `ants-move` is an open-source TypeScript CLI for moving data between systems. Its commands are small workers: collectors bring data in, and future automation commands can move that data into forms and other systems. The current collectors read 36Kr, Toutiao, Hacker News, and GitHub Trending data.
4
4
 
5
5
  ## Installation and requirements
6
6
 
@@ -86,11 +86,31 @@ ants hn search <query> [--limit 1..100] [--sort relevance|date] [--format json|t
86
86
 
87
87
  Hacker News uses at most eight concurrent detail requests. ID-list and search responses are limited to 5 MB, while each Firebase story detail is limited to 256 KB.
88
88
 
89
+ ## GitHub Trending commands
90
+
91
+ Fetch every repository shown on GitHub's all-language Trending page. The period defaults to `daily`:
92
+
93
+ ```bash
94
+ ants github trending
95
+ ants github trending --since daily
96
+ ants github trending --since weekly
97
+ ants github trending --since monthly
98
+ ```
99
+
100
+ JSON is the default output. Use `--format table` or `-t` for a terminal table:
101
+
102
+ ```bash
103
+ ants github trending --since weekly --format table
104
+ ants github trending --since monthly -t
105
+ ```
106
+
107
+ The collector reads the official server-rendered HTML with one GitHub request and does not use a third-party Trending API or Playwright. The request has a 30-second timeout and a 5 MB HTML limit. Parsing accepts at most 100 repositories and retains at most ten displayed contributors per repository. The command has no language filter, result limit, automatic retry, or cache; a successful zero-row parse is rejected as a page-structure error instead of returning a misleading empty ranking.
108
+
89
109
  ## High-concurrency usage warning
90
110
 
91
- Resource use is bounded within one process, but identical commands running in separate processes are not globally deduplicated or rate limited. At `Q` concurrent invocations, upstream work can approach `20Q` requests for a 36Kr list, `201Q` requests for a Hacker News list, or `105Q` browser navigations plus page subresources for a Toutiao author collection.
111
+ Resource use is bounded within one process, but identical commands running in separate processes are not globally deduplicated or rate limited. At `Q` concurrent invocations, upstream work can approach `20Q` requests for a 36Kr list, `201Q` requests for a Hacker News list, `105Q` browser navigations plus page subresources for a Toutiao author collection, or `Q` requests for GitHub Trending.
92
112
 
93
- Retained collector data is also bounded per invocation: 5 MB for a 36Kr list, approximately 50 MB across 200 Hacker News candidate details before result filtering, and 10 MB for Toutiao author article results. Toutiao may additionally hold up to five active 5 MB feed buffers inside one browser session; browser process overhead and required page subresources are separate from these payload limits.
113
+ Retained collector data is also bounded per invocation: 5 MB for a 36Kr list, approximately 50 MB across 200 Hacker News candidate details before result filtering, 10 MB for Toutiao author article results, and one 5 MB GitHub HTML response before Cheerio parsing. Toutiao may additionally hold up to five active 5 MB feed buffers inside one browser session; browser process overhead, required page subresources, and HTML parser object overhead are separate from these payload limits.
94
114
 
95
115
  Do not place the CLI directly on a high-QPS request path. Online services must use an external bounded queue and a shared rate limiter to apply backpressure across processes and instances. This project has no distributed lock, shared cache, retry queue, or multi-instance single-flight mechanism.
96
116
 
package/dist/index.d.ts CHANGED
@@ -124,6 +124,36 @@ interface ToutiaoRuntime {
124
124
  withSession: <T>(operation: (session: ToutiaoSession) => Promise<T>) => Promise<T>;
125
125
  }
126
126
 
127
+ type GitHubTrendingPeriod = 'daily' | 'weekly' | 'monthly';
128
+ interface GitHubTrendingContributor {
129
+ avatarUrl: string;
130
+ url: string;
131
+ username: string;
132
+ }
133
+ interface GitHubTrendingRepository {
134
+ builtBy?: GitHubTrendingContributor[];
135
+ description?: string;
136
+ forks: number;
137
+ fullName: string;
138
+ language?: string;
139
+ name: string;
140
+ owner: string;
141
+ rank: number;
142
+ stars: number;
143
+ starsInPeriod: number;
144
+ url: string;
145
+ }
146
+ interface GitHubTrendingRuntimeResult {
147
+ items: GitHubTrendingRepository[];
148
+ period: GitHubTrendingPeriod;
149
+ sourceUrl: string;
150
+ }
151
+ interface GitHubRuntime {
152
+ fetchTrending: (options: {
153
+ period: GitHubTrendingPeriod;
154
+ }) => Promise<GitHubTrendingRuntimeResult>;
155
+ }
156
+
127
157
  interface CliIo {
128
158
  stderr: (value: string) => void;
129
159
  stdout: (value: string) => void;
@@ -132,6 +162,7 @@ interface CliRunner {
132
162
  run: (args: string[]) => Promise<number>;
133
163
  }
134
164
  interface CreateCliOptions extends Partial<CliIo> {
165
+ githubRuntime?: GitHubRuntime;
135
166
  hackerNewsRuntime?: HackerNewsRuntime;
136
167
  kr36Runtime?: Kr36Runtime;
137
168
  toutiaoRuntime?: ToutiaoRuntime;
package/dist/index.js CHANGED
@@ -2119,6 +2119,440 @@ async function loadPlaywright() {
2119
2119
  return await import("playwright");
2120
2120
  }
2121
2121
 
2122
+ // src/github/command.ts
2123
+ import { z as z4 } from "zod";
2124
+
2125
+ // src/github/output.ts
2126
+ function renderGitHubTrendingAsJson(result) {
2127
+ return JSON.stringify(
2128
+ {
2129
+ ok: true,
2130
+ data: result
2131
+ },
2132
+ null,
2133
+ 2
2134
+ );
2135
+ }
2136
+ function renderGitHubTrendingAsTable(result) {
2137
+ return renderSafeTable([
2138
+ [
2139
+ "rank",
2140
+ "repository",
2141
+ "language",
2142
+ "stars",
2143
+ "forks",
2144
+ "period stars",
2145
+ "description",
2146
+ "url"
2147
+ ],
2148
+ ...result.items.map((item) => [
2149
+ String(item.rank),
2150
+ item.fullName,
2151
+ item.language ?? "",
2152
+ String(item.stars),
2153
+ String(item.forks),
2154
+ String(item.starsInPeriod),
2155
+ item.description ?? "",
2156
+ item.url
2157
+ ])
2158
+ ]);
2159
+ }
2160
+ function renderGitHubCommandErrorAsJson(code, message, details) {
2161
+ return JSON.stringify(
2162
+ {
2163
+ ok: false,
2164
+ error: {
2165
+ code,
2166
+ message,
2167
+ ...details === void 0 ? {} : { details }
2168
+ }
2169
+ },
2170
+ null,
2171
+ 2
2172
+ );
2173
+ }
2174
+
2175
+ // src/github/types.ts
2176
+ var GitHubCommandError = class extends Error {
2177
+ constructor(code, message, exitCode, details) {
2178
+ super(message);
2179
+ this.code = code;
2180
+ this.exitCode = exitCode;
2181
+ this.details = details;
2182
+ this.name = "GitHubCommandError";
2183
+ }
2184
+ };
2185
+
2186
+ // src/github/service.ts
2187
+ var SUPPORTED_PERIODS = ["daily", "weekly", "monthly"];
2188
+ var SUPPORTED_PERIOD_SET = new Set(SUPPORTED_PERIODS);
2189
+ var GitHubService = class {
2190
+ constructor(dependencies) {
2191
+ this.dependencies = dependencies;
2192
+ }
2193
+ async trending(options) {
2194
+ const period = parsePeriod(options.period);
2195
+ const result = await this.dependencies.runtime.fetchTrending({ period });
2196
+ return {
2197
+ ...result,
2198
+ meta: {
2199
+ totalItems: result.items.length
2200
+ }
2201
+ };
2202
+ }
2203
+ };
2204
+ function parsePeriod(period) {
2205
+ const value = period ?? "daily";
2206
+ if (SUPPORTED_PERIOD_SET.has(value)) {
2207
+ return value;
2208
+ }
2209
+ throw new GitHubCommandError(
2210
+ "GITHUB_INVALID_PERIOD",
2211
+ "GitHub Trending period must be daily, weekly, or monthly.",
2212
+ 2,
2213
+ {
2214
+ period: value,
2215
+ supportedPeriods: [...SUPPORTED_PERIODS]
2216
+ }
2217
+ );
2218
+ }
2219
+
2220
+ // src/github/command.ts
2221
+ var trendingOptionsSchema = z4.object({
2222
+ format: z4.enum(["json", "table"]).default("json"),
2223
+ since: z4.string().default("daily"),
2224
+ table: z4.boolean().optional()
2225
+ });
2226
+ function registerGitHubCommands(program, dependencies) {
2227
+ const github = program.command("github").description("Fetch GitHub Trending repositories.");
2228
+ const service = new GitHubService({ runtime: dependencies.runtime });
2229
+ github.command("trending").description("Fetch the all-language GitHub Trending page.").option(
2230
+ "--since <period>",
2231
+ "trending period: daily, weekly, or monthly",
2232
+ "daily"
2233
+ ).option("--format <format>", "output format", "json").option("-t, --table", "render as a table").action(async (options) => {
2234
+ const parsed = trendingOptionsSchema.parse(applyTableShortcut4(options));
2235
+ const result = await service.trending({ period: parsed.since });
2236
+ dependencies.stdout(
2237
+ parsed.format === "table" ? `${renderGitHubTrendingAsTable(result)}
2238
+ ` : `${renderGitHubTrendingAsJson(result)}
2239
+ `
2240
+ );
2241
+ });
2242
+ }
2243
+ function handleGitHubCommandError(error, dependencies) {
2244
+ if (error instanceof GitHubCommandError) {
2245
+ dependencies.stderr(
2246
+ `${renderGitHubCommandErrorAsJson(error.code, error.message, error.details)}
2247
+ `
2248
+ );
2249
+ return error.exitCode;
2250
+ }
2251
+ return void 0;
2252
+ }
2253
+ function applyTableShortcut4(options) {
2254
+ if (options.table) {
2255
+ return {
2256
+ ...options,
2257
+ format: "table"
2258
+ };
2259
+ }
2260
+ return {
2261
+ ...options,
2262
+ format: options.format
2263
+ };
2264
+ }
2265
+
2266
+ // src/github/parser.ts
2267
+ import { load } from "cheerio";
2268
+ var GITHUB_ORIGIN = "https://github.com";
2269
+ var PERIOD_LABEL = {
2270
+ daily: "today",
2271
+ monthly: "this month",
2272
+ weekly: "this week"
2273
+ };
2274
+ function parseGitHubTrendingHtml(html, options) {
2275
+ const $ = load(html);
2276
+ const items = [];
2277
+ $("article.Box-row").each((_, element) => {
2278
+ const row = $(element);
2279
+ const repository = parseRepositoryLink(
2280
+ row.find("h2 a[href]").first().attr("href")
2281
+ );
2282
+ if (!repository) {
2283
+ return;
2284
+ }
2285
+ if (items.length >= options.maxItems) {
2286
+ throw new GitHubCommandError(
2287
+ "GITHUB_PARSE_FAILED",
2288
+ "GitHub Trending returned more repositories than the configured limit.",
2289
+ 2,
2290
+ {
2291
+ maxItems: options.maxItems,
2292
+ sourceUrl: options.sourceUrl
2293
+ }
2294
+ );
2295
+ }
2296
+ const description = normalizeText(row.find("p.col-9").first().text());
2297
+ const language = normalizeText(
2298
+ row.find('[itemprop="programmingLanguage"]').first().text()
2299
+ );
2300
+ const builtBy = parseContributors(
2301
+ $,
2302
+ row.find('a[data-hovercard-type="user"][href]'),
2303
+ options.maxBuiltBy
2304
+ );
2305
+ items.push({
2306
+ forks: parseCount(findRepositoryLinkText(
2307
+ $,
2308
+ row.find("a[href]"),
2309
+ `${repository.path}/forks`
2310
+ )),
2311
+ fullName: `${repository.owner}/${repository.name}`,
2312
+ name: repository.name,
2313
+ owner: repository.owner,
2314
+ rank: items.length + 1,
2315
+ stars: parseCount(findRepositoryLinkText(
2316
+ $,
2317
+ row.find("a[href]"),
2318
+ `${repository.path}/stargazers`
2319
+ )),
2320
+ starsInPeriod: parsePeriodStars(
2321
+ row.find("span.float-sm-right").first().text(),
2322
+ options.period
2323
+ ),
2324
+ url: repository.url,
2325
+ ...builtBy.length === 0 ? {} : { builtBy },
2326
+ ...description.length === 0 ? {} : { description },
2327
+ ...language.length === 0 ? {} : { language }
2328
+ });
2329
+ });
2330
+ if (items.length === 0) {
2331
+ throw new GitHubCommandError(
2332
+ "GITHUB_PARSE_FAILED",
2333
+ "Failed to parse GitHub Trending repositories.",
2334
+ 2,
2335
+ {
2336
+ sourceUrl: options.sourceUrl
2337
+ }
2338
+ );
2339
+ }
2340
+ return {
2341
+ items,
2342
+ period: options.period,
2343
+ sourceUrl: options.sourceUrl
2344
+ };
2345
+ }
2346
+ function parseRepositoryLink(href) {
2347
+ const url = parseGitHubUrl(href);
2348
+ if (!url) return void 0;
2349
+ const segments = url.pathname.split("/").filter(Boolean);
2350
+ if (segments.length !== 2) return void 0;
2351
+ const [owner, name] = segments;
2352
+ const path = `/${owner}/${name}`;
2353
+ return {
2354
+ name,
2355
+ owner,
2356
+ path,
2357
+ url: `${GITHUB_ORIGIN}${path}`
2358
+ };
2359
+ }
2360
+ function parseContributors($, links, maxBuiltBy) {
2361
+ const contributors = [];
2362
+ links.each((_, element) => {
2363
+ if (contributors.length >= maxBuiltBy) return false;
2364
+ const link = $(element);
2365
+ const user = parseGitHubUser(link.attr("href"));
2366
+ const avatarUrl = parseHttpUrl(link.find("img").first().attr("src"));
2367
+ if (!user || !avatarUrl) return;
2368
+ contributors.push({
2369
+ avatarUrl,
2370
+ url: `${GITHUB_ORIGIN}/${user}`,
2371
+ username: user
2372
+ });
2373
+ });
2374
+ return contributors;
2375
+ }
2376
+ function parseGitHubUser(href) {
2377
+ const url = parseGitHubUrl(href);
2378
+ if (!url) return void 0;
2379
+ const segments = url.pathname.split("/").filter(Boolean);
2380
+ return segments.length === 1 ? segments[0] : void 0;
2381
+ }
2382
+ function parseGitHubUrl(value) {
2383
+ if (!value) return void 0;
2384
+ try {
2385
+ const url = new URL(value, GITHUB_ORIGIN);
2386
+ return url.origin === GITHUB_ORIGIN ? url : void 0;
2387
+ } catch {
2388
+ return void 0;
2389
+ }
2390
+ }
2391
+ function parseHttpUrl(value) {
2392
+ if (!value) return void 0;
2393
+ try {
2394
+ const url = new URL(value, GITHUB_ORIGIN);
2395
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : void 0;
2396
+ } catch {
2397
+ return void 0;
2398
+ }
2399
+ }
2400
+ function findRepositoryLinkText($, links, expectedPath) {
2401
+ let text = "";
2402
+ links.each((_, element) => {
2403
+ const link = $(element);
2404
+ const url = parseGitHubUrl(link.attr("href"));
2405
+ if (url?.pathname === expectedPath) {
2406
+ text = link.text();
2407
+ return false;
2408
+ }
2409
+ });
2410
+ return text;
2411
+ }
2412
+ function parsePeriodStars(value, period) {
2413
+ const suffix = new RegExp(`\\s+stars?\\s+${PERIOD_LABEL[period]}\\s*$`, "i");
2414
+ if (!suffix.test(value)) return 0;
2415
+ return parseCount(value.replace(suffix, ""));
2416
+ }
2417
+ function parseCount(value) {
2418
+ const digits = value.replace(/[,\s]/g, "");
2419
+ if (!/^\d+$/.test(digits)) return 0;
2420
+ const count = Number(digits);
2421
+ return Number.isSafeInteger(count) ? count : 0;
2422
+ }
2423
+ function normalizeText(value) {
2424
+ return value.replace(/\s+/g, " ").trim();
2425
+ }
2426
+
2427
+ // src/github/runtime.ts
2428
+ var DEFAULT_MAX_RESPONSE_BYTES2 = 5 * 1024 * 1024;
2429
+ var DEFAULT_REQUEST_TIMEOUT_MS2 = 3e4;
2430
+ var MAX_BUILT_BY = 10;
2431
+ var MAX_ITEMS = 100;
2432
+ function createDefaultGitHubRuntime(options = {}) {
2433
+ const dependencies = {
2434
+ fetch: options.fetch ?? fetch,
2435
+ maxResponseBytes: options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES2,
2436
+ requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS2
2437
+ };
2438
+ return {
2439
+ fetchTrending: async ({ period }) => fetchTrending(dependencies, period)
2440
+ };
2441
+ }
2442
+ async function fetchTrending(dependencies, period) {
2443
+ const sourceUrl = createSourceUrl(period);
2444
+ const controller = new AbortController();
2445
+ const timeout = setTimeout(
2446
+ () => controller.abort(),
2447
+ dependencies.requestTimeoutMs
2448
+ );
2449
+ try {
2450
+ let response;
2451
+ try {
2452
+ response = await dependencies.fetch(sourceUrl, {
2453
+ headers: {
2454
+ accept: "text/html,application/xhtml+xml",
2455
+ "user-agent": "ants-move GitHub collector"
2456
+ },
2457
+ redirect: "error",
2458
+ signal: controller.signal
2459
+ });
2460
+ } catch {
2461
+ throw fetchFailed(sourceUrl);
2462
+ }
2463
+ if (!response.ok) {
2464
+ await cancelResponseBody2(response);
2465
+ throw new GitHubCommandError(
2466
+ "GITHUB_FETCH_FAILED",
2467
+ `Failed to fetch GitHub Trending data. HTTP ${response.status}.`,
2468
+ 2,
2469
+ {
2470
+ status: response.status,
2471
+ url: sourceUrl
2472
+ }
2473
+ );
2474
+ }
2475
+ let body;
2476
+ try {
2477
+ body = await readBoundedBody2(
2478
+ response,
2479
+ dependencies.maxResponseBytes,
2480
+ sourceUrl
2481
+ );
2482
+ } catch (error) {
2483
+ if (error instanceof GitHubCommandError) throw error;
2484
+ throw fetchFailed(sourceUrl);
2485
+ }
2486
+ return parseGitHubTrendingHtml(new TextDecoder().decode(body), {
2487
+ maxBuiltBy: MAX_BUILT_BY,
2488
+ maxItems: MAX_ITEMS,
2489
+ period,
2490
+ sourceUrl
2491
+ });
2492
+ } finally {
2493
+ clearTimeout(timeout);
2494
+ }
2495
+ }
2496
+ function createSourceUrl(period) {
2497
+ const url = new URL("/trending", "https://github.com");
2498
+ url.searchParams.set("since", period);
2499
+ return url.toString();
2500
+ }
2501
+ async function readBoundedBody2(response, maxBytes, url) {
2502
+ const declaredLength = Number(response.headers.get("content-length"));
2503
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
2504
+ await cancelResponseBody2(response);
2505
+ throw responseTooLarge2(url, maxBytes);
2506
+ }
2507
+ const reader = response.body?.getReader();
2508
+ if (!reader) {
2509
+ return new Uint8Array();
2510
+ }
2511
+ const chunks = [];
2512
+ let totalBytes = 0;
2513
+ while (true) {
2514
+ const { done, value } = await reader.read();
2515
+ if (done) break;
2516
+ totalBytes += value.byteLength;
2517
+ if (totalBytes > maxBytes) {
2518
+ await reader.cancel().catch(() => void 0);
2519
+ throw responseTooLarge2(url, maxBytes);
2520
+ }
2521
+ chunks.push(value);
2522
+ }
2523
+ const body = new Uint8Array(totalBytes);
2524
+ let offset = 0;
2525
+ for (const chunk of chunks) {
2526
+ body.set(chunk, offset);
2527
+ offset += chunk.byteLength;
2528
+ }
2529
+ return body;
2530
+ }
2531
+ async function cancelResponseBody2(response) {
2532
+ await response.body?.cancel().catch(() => void 0);
2533
+ }
2534
+ function fetchFailed(url) {
2535
+ return new GitHubCommandError(
2536
+ "GITHUB_FETCH_FAILED",
2537
+ "Failed to fetch GitHub Trending data.",
2538
+ 2,
2539
+ {
2540
+ url
2541
+ }
2542
+ );
2543
+ }
2544
+ function responseTooLarge2(url, maxBytes) {
2545
+ return new GitHubCommandError(
2546
+ "GITHUB_RESPONSE_TOO_LARGE",
2547
+ "GitHub Trending response exceeded the configured size limit.",
2548
+ 2,
2549
+ {
2550
+ maxBytes,
2551
+ url
2552
+ }
2553
+ );
2554
+ }
2555
+
2122
2556
  // src/cli.ts
2123
2557
  function createCli(options = {}) {
2124
2558
  const io = {
@@ -2141,6 +2575,11 @@ function createCli(options = {}) {
2141
2575
  stderr: io.stderr,
2142
2576
  stdout: io.stdout
2143
2577
  });
2578
+ registerGitHubCommands(program, {
2579
+ runtime: options.githubRuntime ?? createDefaultGitHubRuntime(),
2580
+ stderr: io.stderr,
2581
+ stdout: io.stdout
2582
+ });
2144
2583
  registerToutiaoCommands(program, {
2145
2584
  runtime: options.toutiaoRuntime ?? createDefaultToutiaoRuntime(),
2146
2585
  stderr: io.stderr,
@@ -2155,6 +2594,10 @@ function createCli(options = {}) {
2155
2594
  if (error instanceof CommanderError && error.exitCode === 0) {
2156
2595
  return 0;
2157
2596
  }
2597
+ const githubExitCode = handleGitHubCommandError(error, io);
2598
+ if (githubExitCode !== void 0) {
2599
+ return githubExitCode;
2600
+ }
2158
2601
  const toutiaoExitCode = handleToutiaoCommandError(error, io);
2159
2602
  if (toutiaoExitCode !== void 0) {
2160
2603
  return toutiaoExitCode;