Harden crawler for issue #6
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
jmservera committed
May 18, 2026 at 12:04 UTC
c66b9240555110d23bf228ec803d2980ec156e6e
3 files changed
+471
-125
.squad/agents/bender/history.md
+2
@@ -17,3 +17,5 @@
17
- **2026-05-18T10:06:38.734+02:00:** GitHub Actions can run the standalone `copilot` CLI (`@github/copilot`) in programmatic mode with `copilot -p ...`. The safest documented CI auth flow is a fine-grained PAT with the **Copilot Requests** account permission passed as `COPILOT_GITHUB_TOKEN`; `gh auth token` only exposes an existing `gh` token and `gh-copilot` is deprecated in favor of the standalone CLI. GitHub Models (`models: read`) is the clean fallback if direct Copilot CLI automation proves brittle.
18
- **2026-05-18T10:11:20Z:** Team decided Phase 0 PRD decomposition is final; 24 GitHub issues created (4 investigation + 20 implementation). PRD decomposition captures all decisions. MCP tools can crawl beyond GitHub with remote call allowlist. Ready for issue creation.
19
- **2026-05-18T10:27:35.339+02:00:** The crawler now uses `GET /search/repositories` for both `created:>{last_week_date} stars:>50` and `pushed:>{last_week_date} stars:>50`, comparing current stars against the most recent prior `data/raw/*.json` snapshot when available to estimate weekly star gains. It authenticates with `GITHUB_TOKEN`, paginates up to the GitHub Search API's 1,000-result ceiling, caches README checks in-process, applies exponential backoff with jitter for rate limits, and skips repos whose README lookup is blocked by org SAML enforcement.
20
+- **2026-05-18T10:59:10.800+02:00:** Hardening issue #6 required persistent `data/cache/` response caching, dedicated `data/snapshots/YYYY-WNN-stars.json` star maps, stronger low-signal heuristics (fork/template/tutorial/homework filters), and bounded README retries so a real `GITHUB_TOKEN=$(gh auth token) python3 scripts/crawl.py` run can complete while preserving partial data and logging rate-limit state.
21
+- **2026-05-18T10:59:10Z:** Issue #5 complete. Commit fb14275 (209 new repos, 215 trending in data/raw/2026-W21.json). Ready for Issue #6+. User directive: all future work follows branch → PR → Review → Merge workflow (no direct commits to main).
.squad/decisions/inbox/bender-crawler-hardening.md
new
+6
@@ -0,0 +1,6 @@
1
+# Bender crawler hardening note
2
+
3
+- **Date:** 2026-05-18T10:59:10.800+02:00
4
+- **Context:** Issue #6 crawler hardening
5
+- **Decision to review:** Treat README lookups as a degradable signal instead of a hard-stop path. The crawler now caches API responses, saves weekly star snapshots under `data/snapshots/`, logs rate-limit state, and caps README retry delays so partial failures are recorded in metadata instead of blocking the full weekly crawl.
6
+- **Why it matters:** Search queries are cheap, but hundreds of README checks can trigger secondary throttling. Bounded retries plus persistent cache keep Phase 1 crawls finishable and give Farnsworth usable JSON even when GitHub responses are partial.
scripts/crawl.py
+463
-125
@@ -4,12 +4,16 @@
4
from __future__ import annotations
5
6
import argparse
7
+import hashlib
8
import json
9
import os
10
import random
11
+import re
12
+import socket
13
import sys
14
import time
15
from collections import Counter
16
+from dataclasses import dataclass
17
from datetime import UTC, datetime, timedelta
18
from pathlib import Path
19
from typing import Any, Iterable
@@ -17,43 +21,69 @@ from urllib import error, parse, request
21
22
API_ROOT = "https://api.github.com"
23
SEARCH_REPOSITORIES = f"{API_ROOT}/search/repositories"
20
-README_KEYWORDS = {
21
- "homework",
24
+CACHE_ROOT = Path("data/cache")
25
+RAW_ROOT = Path("data/raw")
26
+SNAPSHOT_ROOT = Path("data/snapshots")
27
+RETRYABLE_STATUSES = {403, 429, 500, 502, 503, 504}
28
+LOW_SIGNAL_TOPICS = {
29
"assignment",
23
- "tutorial",
24
- "course",
30
+ "assignments",
31
"bootcamp",
26
- "workshop",
32
+ "course",
33
+ "courses",
34
+ "example",
35
+ "examples",
36
"exercise",
37
+ "homework",
38
"lab",
29
- "lesson",
30
- "leetcode",
31
- "kata",
32
- "template",
39
+ "learning",
40
+ "practice",
41
"starter",
34
- "example",
35
- "cheatsheet",
36
- "guide",
37
- "demo",
38
- "sample",
39
-}
40
-EXCLUDED_TOPICS = {
42
+ "starter-template",
43
+ "template",
44
+ "templates",
45
"tutorial",
46
"tutorials",
43
- "homework",
47
+ "workshop",
48
+ "workshops",
49
+}
50
+LOW_SIGNAL_TOKENS = {
51
"assignment",
52
"assignments",
53
+ "bootcamp",
54
+ "cheatsheet",
55
"course",
56
"courses",
48
- "bootcamp",
49
- "workshop",
50
- "workshops",
57
+ "demo",
58
"example",
59
"examples",
53
- "template",
54
- "templates",
60
+ "exercise",
61
+ "exercises",
62
+ "homework",
63
+ "kata",
64
+ "lab",
65
+ "labs",
66
+ "leetcode",
67
+ "lesson",
68
+ "lessons",
69
+ "practice",
70
+ "sample",
71
"starter",
56
- "starter-template",
72
+ "template",
73
+ "tutorial",
74
+ "tutorials",
75
+ "walkthrough",
76
+ "workshop",
77
+}
78
+LOW_SIGNAL_PHRASES = {
79
+ "course project",
80
+ "for beginners",
81
+ "getting started tutorial",
82
+ "my solution",
83
+ "starter template",
84
+ "step by step",
85
+ "step-by-step",
86
+ "study notes",
87
}
88
DEFAULT_HEADERS = {
89
"Accept": "application/vnd.github+json, application/vnd.github.mercy-preview+json",
@@ -62,46 +92,157 @@ DEFAULT_HEADERS = {
92
}
93
94
95
+def log(message: str) -> None:
96
+ print(f"[crawl {iso_timestamp(utc_now())}] {message}", file=sys.stderr)
97
+
98
+
99
+@dataclass(slots=True)
100
+class CacheEntry:
101
+ status: int
102
+ payload: Any
103
+ headers: dict[str, str]
104
+ fetched_at: datetime
105
+ stale: bool = False
106
+
107
+
108
+class ResponseCache:
109
+ def __init__(self, root: Path) -> None:
110
+ self.root = root
111
+ self.root.mkdir(parents=True, exist_ok=True)
112
+
113
+ def load(self, key: str, ttl_seconds: int) -> CacheEntry | None:
114
+ path = self._path_for(key)
115
+ if not path.exists():
116
+ return None
117
+ try:
118
+ payload = json.loads(path.read_text(encoding="utf-8"))
119
+ fetched_at = datetime.fromisoformat(payload["fetched_at"].replace("Z", "+00:00"))
120
+ headers = payload.get("headers") or {}
121
+ age = (utc_now() - fetched_at).total_seconds()
122
+ return CacheEntry(
123
+ status=int(payload["status"]),
124
+ payload=payload.get("payload"),
125
+ headers={str(name): str(value) for name, value in headers.items()},
126
+ fetched_at=fetched_at,
127
+ stale=age > ttl_seconds,
128
+ )
129
+ except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError):
130
+ return None
131
+
132
+ def store(self, key: str, *, status: int, payload: Any, headers: dict[str, str]) -> None:
133
+ path = self._path_for(key)
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+ cache_payload = {
136
+ "status": status,
137
+ "fetched_at": iso_timestamp(utc_now()),
138
+ "headers": headers,
139
+ "payload": payload,
140
+ }
141
+ path.write_text(json.dumps(cache_payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
142
+
143
+ def _path_for(self, key: str) -> Path:
144
+ parsed = parse.urlparse(key)
145
+ label = parsed.path.strip("/").replace("/", "-") or "root"
146
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
147
+ return self.root / f"{label}-{digest}.json"
148
+
149
+
150
class GitHubClient:
66
- def __init__(self, token: str, *, timeout: int = 30, max_retries: int = 6) -> None:
151
+ def __init__(self, token: str, *, cache_dir: Path = CACHE_ROOT, timeout: int = 30, max_retries: int = 6) -> None:
152
self.token = token
153
self.timeout = timeout
154
self.max_retries = max_retries
155
self.api_calls_used = 0
156
+ self.cache_hits = 0
157
+ self.stale_cache_hits = 0
158
+ self.rate_limit_limit: int | None = None
159
self.rate_limit_remaining: int | None = None
160
self.rate_limit_reset: int | None = None
161
+ self.rate_limit_resource: str | None = None
162
+ self._last_request_at = 0.0
163
self._readme_cache: dict[str, bool] = {}
164
+ self._cache = ResponseCache(cache_dir)
165
+ self.errors: list[str] = []
166
167
def _headers(self) -> dict[str, str]:
168
headers = dict(DEFAULT_HEADERS)
169
headers["Authorization"] = f"Bearer {self.token}"
170
return headers
171
80
- def get_json(self, url: str, params: dict[str, Any] | None = None) -> Any:
172
+ def get_json(
173
+ self,
174
+ url: str,
175
+ params: dict[str, Any] | None = None,
176
+ *,
177
+ acceptable_statuses: set[int] | None = None,
178
+ ttl_seconds: int | None = None,
179
+ allow_stale: bool = True,
180
+ max_retries: int | None = None,
181
+ max_delay_seconds: float = 300.0,
182
+ ) -> CacheEntry:
183
query = f"{url}?{parse.urlencode(params)}" if params else url
184
+ accepted = acceptable_statuses or set()
185
+ retry_limit = self.max_retries if max_retries is None else max_retries
186
+ ttl = ttl_seconds if ttl_seconds is not None else self._cache_ttl(url)
187
+ cached = self._cache.load(query, ttl)
188
+ if cached and not cached.stale and (cached.status == 200 or cached.status in accepted):
189
+ self.cache_hits += 1
190
+ return cached
191
+
192
+ stale_fallback = None
193
+ if cached and allow_stale and (cached.status == 200 or cached.status in accepted):
194
+ stale_fallback = CacheEntry(
195
+ status=cached.status,
196
+ payload=cached.payload,
197
+ headers=cached.headers,
198
+ fetched_at=cached.fetched_at,
199
+ stale=True,
200
+ )
201
+
202
attempt = 0
203
while True:
204
+ self._pause_for_rate_limit(query)
205
+ self._respect_min_interval(url)
206
req = request.Request(query, headers=self._headers())
207
try:
208
with request.urlopen(req, timeout=self.timeout) as response:
209
self.api_calls_used += 1
88
- self._update_rate_limit(response.headers)
89
- payload = response.read().decode("utf-8")
90
- return json.loads(payload)
210
+ headers = {name: value for name, value in response.headers.items()}
211
+ self._update_rate_limit(headers)
212
+ payload = json.loads(response.read().decode("utf-8", errors="replace"))
213
+ self._last_request_at = time.monotonic()
214
+ self._cache.store(query, status=response.status, payload=payload, headers=self._cache_headers(headers))
215
+ self._log_rate_limit(query)
216
+ return CacheEntry(response.status, payload, headers, utc_now())
217
except error.HTTPError as exc:
218
self.api_calls_used += 1
93
- self._update_rate_limit(exc.headers)
219
+ headers = {name: value for name, value in (exc.headers.items() if exc.headers else [])}
220
+ self._update_rate_limit(headers)
221
body = exc.read().decode("utf-8", errors="replace")
95
- if attempt >= self.max_retries or not self._should_retry(exc.code, body):
222
+ self._last_request_at = time.monotonic()
223
+ payload = decode_json_body(body)
224
+ if exc.code in accepted:
225
+ self._cache.store(query, status=exc.code, payload=payload, headers=self._cache_headers(headers))
226
+ self._log_rate_limit(query)
227
+ return CacheEntry(exc.code, payload, headers, utc_now())
228
+ if attempt >= retry_limit or not self._should_retry(exc.code, body):
229
+ if stale_fallback is not None:
230
+ self.stale_cache_hits += 1
231
+ log(f"Using stale cache for {query} after HTTP {exc.code}.")
232
+ return stale_fallback
233
raise RuntimeError(
234
f"GitHub API request failed with status {exc.code}: {body.strip() or exc.reason}"
235
) from exc
99
- self._sleep_before_retry(attempt, exc.headers, body)
236
+ self._sleep_before_retry(attempt, headers, body, query, retry_limit, max_delay_seconds)
237
attempt += 1
101
- except error.URLError as exc:
102
- if attempt >= self.max_retries:
238
+ except (error.URLError, TimeoutError, socket.timeout, json.JSONDecodeError) as exc:
239
+ if attempt >= retry_limit:
240
+ if stale_fallback is not None:
241
+ self.stale_cache_hits += 1
242
+ log(f"Using stale cache for {query} after network error: {exc}")
243
+ return stale_fallback
244
raise RuntimeError(f"GitHub API request failed: {exc}") from exc
104
- self._sleep_before_retry(attempt, None, str(exc))
245
+ self._sleep_before_retry(attempt, None, str(exc), query, retry_limit, max_delay_seconds)
246
attempt += 1
247
248
def search_repositories(self, query: str, *, max_results: int = 1000) -> list[dict[str, Any]]:
@@ -109,21 +250,31 @@ class GitHubClient:
250
per_page = 100
251
max_pages = min((max_results + per_page - 1) // per_page, 10)
252
for page in range(1, max_pages + 1):
112
- payload = self.get_json(
113
- SEARCH_REPOSITORIES,
114
- params={
115
- "q": query,
116
- "sort": "stars",
117
- "order": "desc",
118
- "per_page": per_page,
119
- "page": page,
120
- },
121
- )
122
- items = payload.get("items", [])
123
- if not items:
253
+ try:
254
+ response = self.get_json(
255
+ SEARCH_REPOSITORIES,
256
+ params={
257
+ "q": query,
258
+ "sort": "stars",
259
+ "order": "desc",
260
+ "per_page": per_page,
261
+ "page": page,
262
+ },
263
+ ttl_seconds=6 * 60 * 60,
264
+ )
265
+ except RuntimeError as exc:
266
+ self.record_error(f"Search failed for '{query}' page {page}: {exc}")
267
break
125
- results.extend(items)
126
- if len(items) < per_page or len(results) >= min(payload.get("total_count", 0), max_results, 1000):
268
+ payload = response.payload if isinstance(response.payload, dict) else {}
269
+ items = payload.get("items")
270
+ if not isinstance(items, list):
271
+ self.record_error(f"Malformed search payload for '{query}' page {page}: missing items list")
272
+ break
273
+ if payload.get("incomplete_results"):
274
+ self.record_error(f"GitHub marked search results incomplete for '{query}' page {page}")
275
+ results.extend(item for item in items if isinstance(item, dict))
276
+ total_count = payload.get("total_count")
277
+ if len(items) < per_page or len(results) >= min(int(total_count or 0), max_results, 1000):
278
break
279
return results[:max_results]
280
@@ -132,41 +283,114 @@ class GitHubClient:
283
return self._readme_cache[full_name]
284
url = f"{API_ROOT}/repos/{full_name}/readme"
285
try:
135
- self.get_json(url)
136
- self._readme_cache[full_name] = True
286
+ response = self.get_json(
287
+ url,
288
+ acceptable_statuses={404},
289
+ ttl_seconds=24 * 60 * 60,
290
+ max_retries=2,
291
+ max_delay_seconds=60.0,
292
+ )
293
except RuntimeError as exc:
294
message = str(exc)
139
- if "status 404" in message or "SAML enforcement" in message:
295
+ if "SAML enforcement" in message:
296
self._readme_cache[full_name] = False
141
- else:
142
- raise
143
- return self._readme_cache[full_name]
297
+ return False
298
+ raise
299
+ has_readme = response.status != 404
300
+ self._readme_cache[full_name] = has_readme
301
+ return has_readme
302
+
303
+ def record_error(self, message: str) -> None:
304
+ self.errors.append(message)
305
+ log(message)
306
+
307
+ def _cache_ttl(self, url: str) -> int:
308
+ if "/search/" in url:
309
+ return 6 * 60 * 60
310
+ if url.endswith("/readme"):
311
+ return 24 * 60 * 60
312
+ return 12 * 60 * 60
313
314
def _should_retry(self, status: int, body: str) -> bool:
315
lowered = body.lower()
147
- return status in {403, 429, 500, 502, 503, 504} or "secondary rate limit" in lowered
148
-
149
- def _sleep_before_retry(self, attempt: int, headers: Any, body: str) -> None:
150
- reset_at = None
151
- if headers is not None:
152
- remaining = headers.get("X-RateLimit-Remaining")
153
- reset = headers.get("X-RateLimit-Reset")
154
- if remaining == "0" and reset:
155
- try:
156
- reset_at = max(int(reset) - int(time.time()), 1)
157
- except ValueError:
158
- reset_at = None
159
- if reset_at is not None:
160
- delay = reset_at + random.uniform(0.0, 1.0)
161
- else:
162
- delay = min(2**attempt, 60) + random.uniform(0.0, 1.0)
163
- if "secondary rate limit" in body.lower():
164
- delay = max(delay, 5.0 + random.uniform(0.0, 3.0))
316
+ return status in RETRYABLE_STATUSES or "secondary rate limit" in lowered
317
+
318
+ def _sleep_before_retry(
319
+ self,
320
+ attempt: int,
321
+ headers: dict[str, str] | None,
322
+ body: str,
323
+ query: str,
324
+ retry_limit: int,
325
+ max_delay_seconds: float,
326
+ ) -> None:
327
+ reset_delay = self._reset_delay(headers)
328
+ retry_after = None
329
+ if headers and headers.get("Retry-After"):
330
+ try:
331
+ retry_after = max(float(headers["Retry-After"]), 1.0)
332
+ except ValueError:
333
+ retry_after = None
334
+ base_delay = min(2**attempt, 60)
335
+ jitter = random.uniform(0.3, 1.7)
336
+ delay = retry_after or reset_delay or (base_delay + jitter)
337
+ if "secondary rate limit" in body.lower():
338
+ delay = max(delay, 8.0 + random.uniform(0.0, 5.0))
339
+ delay = min(delay, max_delay_seconds)
340
+ log(f"Retrying {query} in {delay:.1f}s (attempt {attempt + 1}/{retry_limit}).")
341
+ time.sleep(delay)
342
+
343
+ def _respect_min_interval(self, url: str) -> None:
344
+ minimum_interval = 0.35 if url.endswith("/readme") else 0.0
345
+ if minimum_interval <= 0:
346
+ return
347
+ elapsed = time.monotonic() - self._last_request_at
348
+ if elapsed < minimum_interval:
349
+ time.sleep(minimum_interval - elapsed)
350
+
351
+ def _pause_for_rate_limit(self, query: str) -> None:
352
+ if self.rate_limit_remaining is None or self.rate_limit_limit is None:
353
+ return
354
+ low_threshold = max(5, min(25, int(self.rate_limit_limit * 0.1)))
355
+ critical_threshold = max(3, min(10, int(self.rate_limit_limit * 0.03)))
356
+ if self.rate_limit_remaining > low_threshold:
357
+ return
358
+ reset_delay = self._reset_delay({"X-RateLimit-Reset": str(self.rate_limit_reset)} if self.rate_limit_reset else None)
359
+ if reset_delay is None:
360
+ return
361
+ if self.rate_limit_remaining <= critical_threshold:
362
+ delay = min(reset_delay + random.uniform(0.3, 1.5), 300.0)
363
+ log(f"Rate limit nearly exhausted before {query}; pausing {delay:.1f}s until reset window.")
364
+ time.sleep(delay)
365
+ return
366
+ delay = min(max(reset_delay / 10, 1.0), 30.0)
367
+ log(
368
+ f"Rate limit low ({self.rate_limit_remaining}/{self.rate_limit_limit} {self.rate_limit_resource or 'requests'}); "
369
+ f"cooling down {delay:.1f}s before {query}."
370
+ )
371
time.sleep(delay)
372
167
- def _update_rate_limit(self, headers: Any) -> None:
373
+ def _reset_delay(self, headers: dict[str, str] | None) -> float | None:
374
+ if not headers:
375
+ return None
376
+ reset = headers.get("X-RateLimit-Reset")
377
+ if not reset:
378
+ return None
379
+ try:
380
+ return max(int(reset) - int(time.time()), 1)
381
+ except ValueError:
382
+ return None
383
+
384
+ def _update_rate_limit(self, headers: dict[str, str] | None) -> None:
385
+ limit = headers.get("X-RateLimit-Limit") if headers else None
386
remaining = headers.get("X-RateLimit-Remaining") if headers else None
387
reset = headers.get("X-RateLimit-Reset") if headers else None
388
+ resource = headers.get("X-RateLimit-Resource") if headers else None
389
+ if limit is not None:
390
+ try:
391
+ self.rate_limit_limit = int(limit)
392
+ except ValueError:
393
+ self.rate_limit_limit = None
394
if remaining is not None:
395
try:
396
self.rate_limit_remaining = int(remaining)
@@ -177,6 +401,23 @@ class GitHubClient:
401
self.rate_limit_reset = int(reset)
402
except ValueError:
403
self.rate_limit_reset = None
404
+ if resource is not None:
405
+ self.rate_limit_resource = resource
406
+
407
+ def _log_rate_limit(self, query: str) -> None:
408
+ if self.rate_limit_remaining is None:
409
+ return
410
+ reset_text = rate_limit_reset_text(self.rate_limit_reset)
411
+ limit_text = self.rate_limit_limit if self.rate_limit_limit is not None else "?"
412
+ resource_text = self.rate_limit_resource or "unknown"
413
+ log(
414
+ f"Rate limit after {query}: remaining={self.rate_limit_remaining}/{limit_text} "
415
+ f"({resource_text}), resets={reset_text}."
416
+ )
417
+
418
+ def _cache_headers(self, headers: dict[str, str]) -> dict[str, str]:
419
+ names = {"Date", "Retry-After", "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset"}
420
+ return {name: value for name, value in headers.items() if name in names}
421
422
423
def parse_args() -> argparse.Namespace:
@@ -207,7 +448,7 @@ def utc_now() -> datetime:
448
449
450
def iso_timestamp(value: datetime) -> str:
210
- return value.isoformat().replace("+00:00", "Z")
451
+ return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
452
453
454
def week_slug(value: datetime) -> str:
@@ -215,40 +456,81 @@ def week_slug(value: datetime) -> str:
456
return f"{year}-W{week:02d}"
457
458
218
-def previous_snapshot(data_dir: Path, current_week: str) -> dict[str, int]:
219
- snapshots = sorted(data_dir.glob("*.json"), reverse=True)
220
- for snapshot in snapshots:
221
- try:
222
- payload = json.loads(snapshot.read_text(encoding="utf-8"))
223
- except (OSError, json.JSONDecodeError):
224
- continue
225
- if payload.get("week") == current_week:
226
- continue
227
- mapping: dict[str, int] = {}
228
- for section in ("new_repos", "trending_repos"):
229
- for repo in payload.get(section, []):
230
- full_name = repo.get("full_name") or ""
231
- stars = repo.get("stars")
232
- if full_name and isinstance(stars, int):
233
- mapping[full_name] = stars
459
+def rate_limit_reset_text(reset_timestamp: int | None) -> str | None:
460
+ if reset_timestamp is None:
461
+ return None
462
+ return iso_timestamp(datetime.fromtimestamp(reset_timestamp, tz=UTC))
463
+
464
+
465
+def decode_json_body(body: str) -> Any:
466
+ try:
467
+ return json.loads(body)
468
+ except json.JSONDecodeError:
469
+ return {"message": body.strip()}
470
+
471
+
472
+def load_previous_star_snapshot(snapshot_dir: Path, raw_dir: Path, current_week: str) -> dict[str, int]:
473
+ for snapshot in sorted(snapshot_dir.glob("*-stars.json"), reverse=True):
474
+ stars = load_star_mapping(snapshot, current_week)
475
+ if stars:
476
+ return stars
477
+ for snapshot in sorted(raw_dir.glob("*.json"), reverse=True):
478
+ stars = load_star_mapping(snapshot, current_week)
479
+ if stars:
480
+ return stars
481
+ return {}
482
+
483
+
484
+def load_star_mapping(path: Path, current_week: str) -> dict[str, int]:
485
+ try:
486
+ payload = json.loads(path.read_text(encoding="utf-8"))
487
+ except (OSError, json.JSONDecodeError):
488
+ return {}
489
+ if payload.get("week") == current_week:
490
+ return {}
491
+ stars = payload.get("stars")
492
+ if isinstance(stars, dict):
493
+ mapping = {name: int(value) for name, value in stars.items() if isinstance(value, int)}
494
if mapping:
495
return mapping
236
- return {}
496
+ mapping: dict[str, int] = {}
497
+ for section in ("new_repos", "trending_repos"):
498
+ for repo in payload.get(section, []):
499
+ full_name = repo.get("full_name") or ""
500
+ stars_value = repo.get("stars")
501
+ if full_name and isinstance(stars_value, int):
502
+ mapping[full_name] = stars_value
503
+ return mapping
504
505
239
-def looks_insignificant(repo: dict[str, Any]) -> bool:
506
+def tokenize(text: str) -> set[str]:
507
+ return set(re.findall(r"[a-z0-9]+", text.lower()))
508
+
509
+
510
+def significance_skip_reason(repo: dict[str, Any]) -> str | None:
511
if repo.get("fork"):
241
- return True
512
+ return "fork"
513
+ if repo.get("is_template"):
514
+ return "template_repo"
515
description = (repo.get("description") or "").strip()
516
if not description:
244
- return True
245
- lowered_description = description.lower()
246
- lowered_name = str(repo.get("name") or "").lower()
247
- topics = {topic.lower() for topic in repo.get("topics") or []}
248
- combined = " ".join([lowered_name, lowered_description, " ".join(sorted(topics))])
249
- if topics & EXCLUDED_TOPICS:
250
- return True
251
- return any(keyword in combined for keyword in README_KEYWORDS)
517
+ return "missing_description"
518
+ topics = {str(topic).lower() for topic in repo.get("topics") or []}
519
+ if topics & LOW_SIGNAL_TOPICS:
520
+ return "low_signal_topic"
521
+ combined_text = " ".join(
522
+ [
523
+ str(repo.get("name") or ""),
524
+ description,
525
+ " ".join(sorted(topics)),
526
+ ]
527
+ ).lower()
528
+ combined_tokens = tokenize(combined_text)
529
+ if combined_tokens & LOW_SIGNAL_TOKENS:
530
+ return "low_signal_keyword"
531
+ if any(phrase in combined_text for phrase in LOW_SIGNAL_PHRASES):
532
+ return "low_signal_phrase"
533
+ return None
534
535
536
def to_repo_record(repo: dict[str, Any], *, stars_gained: int | None = None) -> dict[str, Any]:
@@ -262,7 +544,7 @@ def to_repo_record(repo: dict[str, Any], *, stars_gained: int | None = None) ->
544
"stars": repo.get("stargazers_count"),
545
"forks": repo.get("forks_count"),
546
"created_at": repo.get("created_at"),
265
- "topics": sorted(repo.get("topics") or []),
547
+ "topics": sorted(str(topic).lower() for topic in (repo.get("topics") or [])),
548
"license": license_info.get("spdx_id") or license_info.get("name"),
549
"url": repo.get("html_url"),
550
}
@@ -277,17 +559,32 @@ def collect_repositories(
559
*,
560
previous_stars: dict[str, int] | None = None,
561
trending_cutoff: datetime | None = None,
280
-) -> list[dict[str, Any]]:
562
+) -> tuple[list[dict[str, Any]], dict[str, int]]:
563
collected: list[dict[str, Any]] = []
564
seen: set[str] = set()
565
+ filter_stats: Counter[str] = Counter()
566
has_prior_snapshot = bool(previous_stars)
567
for repo in repositories:
568
full_name = repo.get("full_name")
286
- if not full_name or full_name in seen or looks_insignificant(repo):
569
+ if not full_name:
570
+ filter_stats["missing_full_name"] += 1
571
continue
288
- if not client.has_readme(full_name):
572
+ if full_name in seen:
573
+ filter_stats["duplicate"] += 1
574
continue
575
seen.add(full_name)
576
+ skip_reason = significance_skip_reason(repo)
577
+ if skip_reason:
578
+ filter_stats[skip_reason] += 1
579
+ continue
580
+ try:
581
+ if not client.has_readme(full_name):
582
+ filter_stats["missing_readme"] += 1
583
+ continue
584
+ except RuntimeError as exc:
585
+ filter_stats["readme_lookup_failed"] += 1
586
+ client.record_error(f"README lookup failed for {full_name}: {exc}")
587
+ continue
588
stars_gained: int | None = None
589
if previous_stars is not None:
590
previous_value = previous_stars.get(full_name)
@@ -305,21 +602,34 @@ def collect_repositories(
602
collected.sort(key=lambda item: (item.get("stars_gained", -1), item["stars"]), reverse=True)
603
else:
604
collected.sort(key=lambda item: item["stars"], reverse=True)
308
- return collected
605
+ return collected, dict(filter_stats)
606
607
608
def build_signals(*repo_groups: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
609
topic_counter: Counter[str] = Counter()
610
+ merged: dict[str, dict[str, Any]] = {}
611
for group in repo_groups:
612
for repo in group:
315
- topic_counter.update(topic.lower() for topic in repo.get("topics") or [])
316
- top_topics = [
317
- {"topic": topic, "count": count}
318
- for topic, count in topic_counter.most_common(15)
319
- ]
613
+ full_name = repo.get("full_name")
614
+ if full_name:
615
+ merged[full_name] = repo
616
+ for repo in merged.values():
617
+ topic_counter.update(topic.lower() for topic in repo.get("topics") or [])
618
+ top_topics = [{"topic": topic, "count": count} for topic, count in topic_counter.most_common(15)]
619
return {"top_topics": top_topics}
620
621
622
+def build_star_snapshot(*repo_groups: Iterable[dict[str, Any]]) -> dict[str, int]:
623
+ stars: dict[str, int] = {}
624
+ for group in repo_groups:
625
+ for repo in group:
626
+ full_name = repo.get("full_name")
627
+ value = repo.get("stargazers_count")
628
+ if full_name and isinstance(value, int):
629
+ stars[full_name] = value
630
+ return dict(sorted(stars.items()))
631
+
632
+
633
def validate_payload(payload: dict[str, Any]) -> None:
634
required_top_level = {"week", "crawled_at", "new_repos", "trending_repos", "signals", "metadata"}
635
missing = required_top_level - payload.keys()
@@ -350,8 +660,15 @@ def validate_payload(payload: dict[str, Any]) -> None:
660
metadata = payload["metadata"]
661
if not isinstance(metadata.get("api_calls_used"), int):
662
raise ValueError("metadata.api_calls_used must be an integer")
663
+ if not isinstance(metadata.get("cache_hits"), int):
664
+ raise ValueError("metadata.cache_hits must be an integer")
665
if metadata.get("rate_limit_remaining") is not None and not isinstance(metadata.get("rate_limit_remaining"), int):
666
raise ValueError("metadata.rate_limit_remaining must be an integer or null")
667
+ if metadata.get("rate_limit_limit") is not None and not isinstance(metadata.get("rate_limit_limit"), int):
668
+ raise ValueError("metadata.rate_limit_limit must be an integer or null")
669
+ partial_failures = metadata.get("partial_failures")
670
+ if partial_failures is not None and not isinstance(partial_failures, list):
671
+ raise ValueError("metadata.partial_failures must be a list when present")
672
673
674
def write_payload(path: Path, payload: dict[str, Any]) -> None:
@@ -367,32 +684,38 @@ def main() -> int:
684
return 1
685
686
crawled_at = utc_now()
370
- window_end = (
371
- datetime.strptime(args.as_of, "%Y-%m-%d").replace(tzinfo=UTC)
372
- if args.as_of
373
- else crawled_at
374
- )
687
+ window_end = datetime.strptime(args.as_of, "%Y-%m-%d").replace(tzinfo=UTC) if args.as_of else crawled_at
688
since = datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=UTC) if args.since else window_end - timedelta(days=7)
689
week = week_slug(window_end)
377
- output_path = Path(args.output) if args.output else Path("data/raw") / f"{week}.json"
690
+ output_path = Path(args.output) if args.output else RAW_ROOT / f"{week}.json"
691
+ snapshot_path = SNAPSHOT_ROOT / f"{week}-stars.json"
692
client = GitHubClient(github_token)
693
max_results = max(1, min(args.max_results, 1000))
694
381
- new_query = f"created:>{since.date().isoformat()} stars:>50"
382
- trending_query = f"pushed:>{since.date().isoformat()} stars:>50"
695
+ date_range = f"{since.date().isoformat()}..{window_end.date().isoformat()}"
696
+ new_query = f"created:{date_range} stars:>50"
697
+ trending_query = f"pushed:{date_range} stars:>50"
698
699
new_candidates = client.search_repositories(new_query, max_results=max_results)
385
- previous_stars = previous_snapshot(output_path.parent, week)
700
+ previous_stars = load_previous_star_snapshot(SNAPSHOT_ROOT, output_path.parent, week)
701
trending_candidates = client.search_repositories(trending_query, max_results=max_results)
702
388
- new_repos = collect_repositories(client, new_candidates)
389
- trending_repos = collect_repositories(
703
+ new_repos, new_filters = collect_repositories(client, new_candidates)
704
+ trending_repos, trending_filters = collect_repositories(
705
client,
706
trending_candidates,
707
previous_stars=previous_stars,
708
trending_cutoff=since,
709
)
710
711
+ star_snapshot = build_star_snapshot(new_candidates, trending_candidates)
712
+ snapshot_payload = {
713
+ "week": week,
714
+ "captured_at": iso_timestamp(crawled_at),
715
+ "repository_count": len(star_snapshot),
716
+ "stars": star_snapshot,
717
+ }
718
+
719
payload = {
720
"week": week,
721
"crawled_at": iso_timestamp(crawled_at),
@@ -401,17 +724,32 @@ def main() -> int:
724
"signals": build_signals(new_repos, trending_repos),
725
"metadata": {
726
"api_calls_used": client.api_calls_used,
727
+ "cache_hits": client.cache_hits,
728
+ "stale_cache_hits": client.stale_cache_hits,
729
+ "rate_limit_limit": client.rate_limit_limit,
730
"rate_limit_remaining": client.rate_limit_remaining,
731
+ "rate_limit_reset": rate_limit_reset_text(client.rate_limit_reset),
732
+ "rate_limit_resource": client.rate_limit_resource,
733
+ "partial_failures": client.errors,
734
+ "filter_summary": {
735
+ "new_repos": new_filters,
736
+ "trending_repos": trending_filters,
737
+ },
738
+ "snapshot_path": snapshot_path.as_posix(),
739
},
740
}
741
validate_payload(payload)
742
write_payload(output_path, payload)
743
+ write_payload(snapshot_path, snapshot_payload)
744
+
745
+ if client.errors:
746
+ log(f"Completed with {len(client.errors)} partial failure(s).")
747
748
print(
411
- f"Wrote {output_path} with {len(new_repos)} new repos and {len(trending_repos)} trending repos "
412
- f"using {client.api_calls_used} API calls."
749
+ f"Wrote {output_path} with {len(new_repos)} new repos and {len(trending_repos)} trending repos, "
750
+ f"saved {snapshot_path}, used {client.api_calls_used} API calls, and served {client.cache_hits} cache hits."
751
)
414
- return 0
752
+ return 0 if (new_candidates or trending_candidates or not client.errors) else 1
753
754
755
if __name__ == "__main__":