Reuse same-day source artifacts safely on reruns (#282)

* fix: reuse same-day crawl artifacts Add safe same-day reuse for GitHub raw crawl artifacts and per-source external news artifacts with date, window, config, schema, and checksum guards. Preserve recrawl-by-default behavior when provenance is missing or stale, and expose reuse provenance in publish manifests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: preserve structured same-day reuse provenance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add github reuse source id Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 6, 2026 at 12:29 UTC b8ccc9732acdf855027e0d249a3e5cd60fc1c318
7 files changed +879 -13
.github/workflows/crawl-and-publish.yml
+20
@@ -90,6 +90,26 @@ jobs:
90 name: crawl-cache
91 path: data/cache/
92
93 + - name: Restore previous raw data for safe same-day reuse
94 + if: steps.previous-cache-run.outputs.run_id != ''
95 + continue-on-error: true
96 + uses: actions/download-artifact@v4
97 + with:
98 + github-token: ${{ secrets.GITHUB_TOKEN }}
99 + run-id: ${{ steps.previous-cache-run.outputs.run_id }}
100 + name: raw-data
101 + path: data/raw/
102 +
103 + - name: Restore previous snapshots for safe same-day reuse
104 + if: steps.previous-cache-run.outputs.run_id != ''
105 + continue-on-error: true
106 + uses: actions/download-artifact@v4
107 + with:
108 + github-token: ${{ secrets.GITHUB_TOKEN }}
109 + run-id: ${{ steps.previous-cache-run.outputs.run_id }}
110 + name: crawl-snapshots
111 + path: data/snapshots/
112 +
113 - name: Set up Python
114 uses: actions/setup-python@v5
115 with:
scripts/crawl.py
+134
@@ -90,6 +90,7 @@ DEFAULT_HEADERS = {
90 "X-GitHub-Api-Version": "2022-11-28",
91 "User-Agent": "SquadScope-Crawler/1.0",
92 }
93 +GITHUB_SOURCE_ID = "github-search"
94
95
96 def log(message: str) -> None:
@@ -490,6 +491,11 @@ def parse_args() -> argparse.Namespace:
491 help="Path to a topic YAML config file (e.g. squadscope.topic.yml). "
492 "When provided, queries are read from the config instead of using hardcoded defaults.",
493 )
494 + parser.add_argument(
495 + "--force-refresh",
496 + action="store_true",
497 + help="Refresh GitHub data even when a same-day raw artifact is reusable.",
498 + )
499 return parser.parse_args()
500
501
@@ -552,6 +558,110 @@ def decode_json_body(body: str) -> Any:
558 return {"message": body.strip()}
559
560
561 +def sha256_text(value: str) -> str:
562 + return hashlib.sha256(value.encode("utf-8")).hexdigest()
563 +
564 +
565 +def sha256_file(path: Path) -> str | None:
566 + if not path.exists() or not path.is_file():
567 + return None
568 + digest = hashlib.sha256()
569 + with path.open("rb") as handle:
570 + for chunk in iter(lambda: handle.read(1024 * 1024), b""):
571 + digest.update(chunk)
572 + return digest.hexdigest()
573 +
574 +
575 +def github_schema_checksum() -> str:
576 + contract = {
577 + "schema": "github_raw_v1",
578 + "top_level": ["week", "crawled_at", "new_repos", "trending_repos", "signals", "metadata"],
579 + "metadata": ["crawl_window", "crawl_config_checksum", "schema_checksum", "artifact_checksum", "same_day_reuse"],
580 + }
581 + return sha256_text(json.dumps(contract, sort_keys=True, separators=(",", ":")))
582 +
583 +
584 +def github_crawl_config_checksum(args: argparse.Namespace, since: datetime, window_end: datetime, max_results: int) -> str:
585 + config_digest = sha256_file(Path(args.config)) if args.config else None
586 + payload = {
587 + "since": since.date().isoformat(),
588 + "until": window_end.date().isoformat(),
589 + "as_of": args.as_of,
590 + "max_results": max_results,
591 + "topic": args.topic,
592 + "config": args.config,
593 + "config_sha256": config_digest,
594 + }
595 + return sha256_text(json.dumps(payload, sort_keys=True, separators=(",", ":")))
596 +
597 +
598 +def github_artifact_checksum(payload: dict[str, Any]) -> str:
599 + candidate = dict(payload)
600 + candidate.pop("crawled_at", None)
601 + metadata = dict(candidate.get("metadata", {}))
602 + metadata.pop("artifact_checksum", None)
603 + metadata.pop("same_day_reuse", None)
604 + candidate["metadata"] = metadata
605 + return sha256_text(json.dumps(candidate, sort_keys=True, separators=(",", ":"), ensure_ascii=False))
606 +
607 +
608 +def load_reusable_github_payload(
609 + path: Path,
610 + *,
611 + week: str,
612 + crawled_at: datetime,
613 + since: datetime,
614 + window_end: datetime,
615 + config_checksum: str,
616 +) -> dict[str, Any] | None:
617 + try:
618 + payload = json.loads(path.read_text(encoding="utf-8"))
619 + except (OSError, json.JSONDecodeError):
620 + return None
621 + if not isinstance(payload, dict):
622 + return None
623 + try:
624 + validate_payload(payload)
625 + except ValueError:
626 + return None
627 + metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
628 + parsed = None
629 + raw_crawled_at = payload.get("crawled_at")
630 + if isinstance(raw_crawled_at, str):
631 + try:
632 + parsed = datetime.fromisoformat(raw_crawled_at.replace("Z", "+00:00"))
633 + except ValueError:
634 + parsed = None
635 + window = metadata.get("crawl_window")
636 + if (
637 + payload.get("week") != week
638 + or parsed is None
639 + or parsed.astimezone(UTC).date() != crawled_at.astimezone(UTC).date()
640 + or not isinstance(window, dict)
641 + or window.get("since") != since.date().isoformat()
642 + or window.get("until") != window_end.date().isoformat()
643 + or metadata.get("crawl_config_checksum") != config_checksum
644 + or metadata.get("schema_checksum") != github_schema_checksum()
645 + or metadata.get("artifact_checksum") != github_artifact_checksum(payload)
646 + ):
647 + return None
648 + metadata["same_day_reuse"] = {
649 + "status": "reused",
650 + "source": "github",
651 + "source_id": GITHUB_SOURCE_ID,
652 + "original_run_id": metadata.get("run_id", ""),
653 + "original_crawled_at": payload.get("crawled_at"),
654 + "reused_at": iso_timestamp(crawled_at),
655 + "week": week,
656 + "crawl_window": window,
657 + "crawl_config_checksum": config_checksum,
658 + "schema_checksum": github_schema_checksum(),
659 + "content_checksum": metadata.get("artifact_checksum"),
660 + }
661 + payload["metadata"] = metadata
662 + return payload
663 +
664 +
665 def load_previous_star_snapshot(snapshot_dir: Path, current_week: str, *raw_dirs: Path) -> dict[str, int]:
666 for snapshot in sorted(snapshot_dir.glob("*-stars.json"), reverse=True):
667 stars, reason = load_star_mapping_details(snapshot, current_week)
@@ -803,6 +913,21 @@ def main() -> int:
913 snapshot_path = topic_snapshots / f"{week}-stars.json"
914 client = GitHubClient(github_token, cache_dir=topic_cache)
915 max_results = max(1, min(args.max_results, 1000))
916 + config_checksum = github_crawl_config_checksum(args, since, window_end, max_results)
917 +
918 + if not getattr(args, "force_refresh", False):
919 + reusable = load_reusable_github_payload(
920 + output_path,
921 + week=week,
922 + crawled_at=crawled_at,
923 + since=since,
924 + window_end=window_end,
925 + config_checksum=config_checksum,
926 + )
927 + if reusable is not None:
928 + write_payload(output_path, reusable)
929 + print(f"Reused same-day GitHub raw artifact {output_path}; used 0 API calls.")
930 + return 0
931
932 if args.config:
933 template_vars = {
@@ -866,6 +991,14 @@ def main() -> int:
991 "rate_limit_reset": client.rate_limit_reset,
992 "rate_limit_resource": client.rate_limit_resource,
993 "partial_failures": client.errors,
994 + "run_id": os.environ.get("GITHUB_RUN_ID", "local"),
995 + "crawl_window": {
996 + "since": since.date().isoformat(),
997 + "until": window_end.date().isoformat(),
998 + },
999 + "crawl_config_checksum": config_checksum,
1000 + "schema_checksum": github_schema_checksum(),
1001 + "same_day_reuse": {"status": "not_reused", "source": "github", "source_id": GITHUB_SOURCE_ID},
1002 "filter_summary": {
1003 "new_repos": new_filters,
1004 "trending_repos": trending_filters,
@@ -873,6 +1006,7 @@ def main() -> int:
1006 "snapshot_path": snapshot_path.as_posix(),
1007 },
1008 }
1009 + payload["metadata"]["artifact_checksum"] = github_artifact_checksum(payload)
1010 validate_payload(payload)
1011 write_payload(output_path, payload)
1012 write_payload(snapshot_path, snapshot_payload)
scripts/publish_manifest.py
+10
@@ -78,6 +78,8 @@ def same_day_reuse_status(payload: dict[str, Any] | None) -> dict[str, Any]:
78 if not isinstance(metadata, dict):
79 metadata = {}
80 explicit = metadata.get("same_day_reuse") or metadata.get("same_day_reuse_status")
81 + if isinstance(explicit, dict):
82 + return dict(explicit)
83 if explicit:
84 return {"status": str(explicit), "source": "artifact-metadata"}
85 return {
@@ -130,6 +132,14 @@ def artifact_entry(role: str, path: Path, week: str) -> dict[str, Any]:
132 }
133 if "source_status" in metadata:
134 entry["source_status"] = metadata["source_status"]
135 + if "source_reuse_summary" in metadata:
136 + entry["source_reuse_summary"] = metadata["source_reuse_summary"]
137 + if "source_artifact_provenance" in metadata:
138 + entry["source_artifact_provenance"] = metadata["source_artifact_provenance"]
139 + if "source_config_checksum" in metadata:
140 + entry["source_config_checksum"] = metadata["source_config_checksum"]
141 + if "schema_checksum" in metadata:
142 + entry["schema_checksum"] = metadata["schema_checksum"]
143 if "sources_requested" in metadata:
144 entry["sources_requested"] = metadata["sources_requested"]
145 entry["sources_succeeded"] = metadata.get("sources_succeeded", [])
scripts/techcrunch_crawler.py
+297 -11
@@ -15,6 +15,7 @@ import argparse
15 import hashlib
16 import ipaddress
17 import json
18 +import os
19 import re
20 import sys
21 import time
@@ -166,6 +167,223 @@ def source_config_checksum(sources: list[NewsSourceConfig]) -> str:
167 return hashlib.sha256(payload.encode("utf-8")).hexdigest()
168
169
170 +def schema_checksum() -> str:
171 + """Return a stable checksum for the external-news artifact contract."""
172 + schema_contract = {
173 + "schema_version": CANONICAL_SCHEMA_VERSION,
174 + "top_level": ["schema_version", "week", "source", "crawled_at", "crawl_window", "articles", "metadata"],
175 + "metadata": [
176 + "source_config_checksum",
177 + "schema_checksum",
178 + "sources_requested",
179 + "sources_succeeded",
180 + "sources_failed",
181 + "source_status",
182 + "source_reuse_summary",
183 + "source_artifact_provenance",
184 + "total_articles",
185 + "relevant_articles",
186 + "dedupe_count",
187 + "errors",
188 + "artifact_checksum",
189 + ],
190 + }
191 + payload = json.dumps(schema_contract, sort_keys=True, separators=(",", ":"))
192 + return hashlib.sha256(payload.encode("utf-8")).hexdigest()
193 +
194 +
195 +def source_content_checksum(source_id: str, articles: list[dict[str, Any]]) -> str:
196 + """Return a stable checksum for one source's article payload."""
197 + source_articles = [
198 + article for article in articles
199 + if article.get("source") == source_id or source_id in article.get("sources", [])
200 + ]
201 + payload = json.dumps(source_articles, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
202 + return hashlib.sha256(payload.encode("utf-8")).hexdigest()
203 +
204 +
205 +def parse_iso_datetime(value: Any) -> datetime | None:
206 + if not isinstance(value, str) or not value.strip():
207 + return None
208 + candidate = value.strip()
209 + if candidate.endswith("Z"):
210 + candidate = f"{candidate[:-1]}+00:00"
211 + try:
212 + parsed = datetime.fromisoformat(candidate)
213 + except ValueError:
214 + return None
215 + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
216 +
217 +
218 +def _load_json_object(path: Path) -> dict[str, Any] | None:
219 + try:
220 + payload = json.loads(path.read_text(encoding="utf-8"))
221 + except (OSError, json.JSONDecodeError):
222 + return None
223 + return payload if isinstance(payload, dict) else None
224 +
225 +
226 +def _same_window(payload: dict[str, Any], since: datetime, until: datetime) -> bool:
227 + window = payload.get("crawl_window")
228 + return isinstance(window, dict) and window.get("since") == iso_timestamp(since) and window.get("until") == iso_timestamp(until)
229 +
230 +
231 +def plan_source_reuse(
232 + previous_path: Path,
233 + sources: list[NewsSourceConfig],
234 + *,
235 + now: datetime,
236 + since: datetime,
237 + until: datetime,
238 + config_checksum: str,
239 + forced_sources: set[str] | None = None,
240 +) -> tuple[list[dict[str, Any]], list[NewsSourceConfig], list[dict[str, Any]], list[dict[str, Any]], list[dict[str, str]]]:
241 + """Load eligible same-day source artifacts and return reused articles plus sources to crawl."""
242 + forced = forced_sources or set()
243 + requested = {source.name for source in sources}
244 + pending: list[NewsSourceConfig] = []
245 + reused_articles: list[dict[str, Any]] = []
246 + summary: list[dict[str, Any]] = []
247 + provenance: list[dict[str, Any]] = []
248 + stale_reasons: list[str] = []
249 + previous = _load_json_object(previous_path) if previous_path.exists() else None
250 + expected_schema_checksum = schema_checksum()
251 +
252 + if previous is None:
253 + stale_reasons = ["missing previous artifact" if not previous_path.exists() else "previous artifact is not valid JSON"]
254 + else:
255 + crawled_at = parse_iso_datetime(previous.get("crawled_at"))
256 + metadata = previous.get("metadata") if isinstance(previous.get("metadata"), dict) else {}
257 + try:
258 + validate_canonical_output(previous)
259 + except ValueError as exc:
260 + stale_reasons.append(str(exc))
261 + if previous.get("week") != week_slug(now):
262 + stale_reasons.append(f"week mismatch: expected {week_slug(now)}, found {previous.get('week')!r}")
263 + if crawled_at is None or crawled_at.astimezone(UTC).date() != now.astimezone(UTC).date():
264 + stale_reasons.append("crawled_at is not from the current UTC day")
265 + if not _same_window(previous, since, until):
266 + stale_reasons.append("crawl_window mismatch")
267 + if metadata.get("source_config_checksum") != config_checksum:
268 + stale_reasons.append("source_config_checksum mismatch")
269 + if metadata.get("schema_checksum") != expected_schema_checksum:
270 + stale_reasons.append("schema_checksum mismatch")
271 +
272 + previous_metadata = previous.get("metadata", {}) if isinstance(previous, dict) and isinstance(previous.get("metadata"), dict) else {}
273 + previous_statuses = {
274 + str(status.get("source")): status
275 + for status in previous_metadata.get("source_status", [])
276 + if isinstance(status, dict) and status.get("source") in requested
277 + }
278 + previous_articles = [
279 + article for article in (previous.get("articles", []) if isinstance(previous, dict) else [])
280 + if isinstance(article, dict)
281 + ]
282 + previous_run_id = str(previous_metadata.get("run_id") or "")
283 + previous_checksum = previous_metadata.get("artifact_checksum")
284 +
285 + for source in sources:
286 + source_id = source.name
287 + status = previous_statuses.get(source_id)
288 + reasons = list(stale_reasons)
289 + action = "missing"
290 + if source_id in forced:
291 + action = "forced"
292 + reasons.append("source explicitly refreshed")
293 + elif stale_reasons:
294 + action = "missing" if stale_reasons == ["missing previous artifact"] else "stale"
295 + elif status is None:
296 + action = "missing"
297 + reasons.append("source missing from previous artifact")
298 + elif not status.get("success"):
299 + action = "failed"
300 + reasons.append("previous source crawl failed")
301 + else:
302 + action = "reused"
303 +
304 + matching_articles = [article for article in previous_articles if article.get("source") == source_id]
305 + summary.append({
306 + "source": source_id,
307 + "action": action,
308 + "reused": action == "reused",
309 + "refreshed": action != "reused",
310 + "reasons": reasons,
311 + })
312 + provenance.append({
313 + "source_id": source_id,
314 + "action": action,
315 + "artifact_path": previous_path.as_posix(),
316 + "original_run_id": previous_run_id,
317 + "original_crawled_at": previous.get("crawled_at") if isinstance(previous, dict) else None,
318 + "evaluated_at": iso_timestamp(now),
319 + "date": now.astimezone(UTC).date().isoformat(),
320 + "week": week_slug(now),
321 + "crawl_window": {"since": iso_timestamp(since), "until": iso_timestamp(until)},
322 + "source_config_checksum": config_checksum,
323 + "schema_checksum": expected_schema_checksum,
324 + "artifact_checksum": previous_checksum,
325 + "content_checksum": source_content_checksum(source_id, matching_articles),
326 + "reasons": reasons,
327 + })
328 + if action == "reused":
329 + reused_articles.extend(matching_articles)
330 + else:
331 + pending.append(source)
332 +
333 + return reused_articles, pending, summary, provenance, []
334 +
335 +
336 +def merge_reuse_results(
337 + initial_summary: list[dict[str, Any]],
338 + initial_provenance: list[dict[str, Any]],
339 + refreshed_statuses: list[dict[str, Any]],
340 + refreshed_articles: list[dict[str, Any]],
341 + *,
342 + now: datetime,
343 + since: datetime,
344 + until: datetime,
345 + config_checksum: str,
346 + output_path: Path,
347 + run_id: str,
348 +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
349 + """Update reuse plan entries with the result of sources crawled in this run."""
350 + summary_by_source = {entry["source"]: dict(entry) for entry in initial_summary}
351 + provenance_by_source = {entry["source_id"]: dict(entry) for entry in initial_provenance}
352 + for status in refreshed_statuses:
353 + source_id = str(status.get("source"))
354 + if not source_id:
355 + continue
356 + action = "refreshed" if status.get("success") else "failed"
357 + reasons = [] if status.get("success") else [status.get("error_message") or "source crawl failed"]
358 + summary_by_source[source_id] = {
359 + "source": source_id,
360 + "action": action,
361 + "reused": False,
362 + "refreshed": True,
363 + "reasons": reasons,
364 + }
365 + provenance_by_source[source_id] = {
366 + "source_id": source_id,
367 + "action": action,
368 + "artifact_path": output_path.as_posix(),
369 + "original_run_id": run_id,
370 + "original_crawled_at": iso_timestamp(now),
371 + "evaluated_at": iso_timestamp(now),
372 + "date": now.astimezone(UTC).date().isoformat(),
373 + "week": week_slug(now),
374 + "crawl_window": {"since": iso_timestamp(since), "until": iso_timestamp(until)},
375 + "source_config_checksum": config_checksum,
376 + "schema_checksum": schema_checksum(),
377 + "artifact_checksum": None,
378 + "content_checksum": source_content_checksum(source_id, refreshed_articles),
379 + "reasons": reasons,
380 + }
381 + return (
382 + [summary_by_source[source] for source in sorted(summary_by_source)],
383 + [provenance_by_source[source] for source in sorted(provenance_by_source)],
384 + )
385 +
386 +
387 def iso_timestamp(value: datetime) -> str:
388 """Format datetime as ISO 8601 UTC string."""
389 return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
@@ -534,6 +752,9 @@ def _checksum_payload(output: dict[str, Any]) -> dict[str, Any]:
752 """Return the deterministic subset covered by artifact_checksum."""
753 metadata = dict(output.get("metadata", {}))
754 metadata.pop("artifact_checksum", None)
755 + metadata.pop("run_id", None)
756 + metadata.pop("source_reuse_summary", None)
757 + metadata.pop("source_artifact_provenance", None)
758 payload = dict(output)
759 payload["metadata"] = metadata
760 payload.pop("crawled_at", None)
@@ -562,6 +783,9 @@ def build_output(
783 requested_sources: list[str] | None = None,
784 source_statuses: list[dict[str, Any]] | None = None,
785 errors: list[dict[str, str]] | None = None,
786 + source_reuse_summary: list[dict[str, Any]] | None = None,
787 + source_artifact_provenance: list[dict[str, Any]] | None = None,
788 + run_id: str | None = None,
789 ) -> dict[str, Any]:
790 """Build the final output structure with metadata."""
791 articles, dedupe_count = dedupe_articles(articles)
@@ -593,12 +817,16 @@ def build_output(
817 "crawl_window": crawl_window or {},
818 "articles": articles,
819 "metadata": {
820 + "run_id": run_id or "local",
821 "source_count": source_count,
822 "source_config_checksum": source_config_checksum_value or "",
823 + "schema_checksum": schema_checksum(),
824 "sources_requested": sorted(requested),
825 "sources_succeeded": sorted(succeeded or requested),
826 "sources_failed": sorted(failed),
827 "source_status": sorted(statuses, key=lambda status: status["source"]),
828 + "source_reuse_summary": sorted(source_reuse_summary or [], key=lambda item: item["source"]),
829 + "source_artifact_provenance": sorted(source_artifact_provenance or [], key=lambda item: item["source_id"]),
830 "sources_with_articles": dict(sorted(by_source.items())),
831 "total_articles": len(articles),
832 "relevant_articles": len(relevant),
@@ -608,6 +836,9 @@ def build_output(
836 },
837 }
838 output["metadata"]["artifact_checksum"] = artifact_checksum(output)
839 + for entry in output["metadata"]["source_artifact_provenance"]:
840 + if not entry.get("artifact_checksum"):
841 + entry["artifact_checksum"] = output["metadata"]["artifact_checksum"]
842 validate_canonical_output(output)
843 return output
844
@@ -623,10 +854,13 @@ def validate_canonical_output(output: dict[str, Any]) -> None:
854 raise ValueError("External news artifact requires metadata")
855 required = {
856 "source_config_checksum",
857 + "schema_checksum",
858 "sources_requested",
859 "sources_succeeded",
860 "sources_failed",
861 "source_status",
862 + "source_reuse_summary",
863 + "source_artifact_provenance",
864 "total_articles",
865 "relevant_articles",
866 "dedupe_count",
@@ -639,6 +873,8 @@ def validate_canonical_output(output: dict[str, Any]) -> None:
873 expected_checksum = artifact_checksum(output)
874 if metadata.get("artifact_checksum") != expected_checksum:
875 raise ValueError("External news artifact checksum mismatch")
876 + if metadata.get("schema_checksum") != schema_checksum():
877 + raise ValueError("External news artifact schema checksum mismatch")
878
879
880 def main(argv: list[str] | None = None) -> int:
@@ -672,6 +908,17 @@ def main(argv: list[str] | None = None) -> int:
908 default=None,
909 help="Maximum parallel RSS fetches (default: one per source, capped at 8)",
910 )
911 + parser.add_argument(
912 + "--force-refresh",
913 + action="store_true",
914 + help="Refresh all sources even when same-day artifacts are reusable.",
915 + )
916 + parser.add_argument(
917 + "--force-refresh-source",
918 + action="append",
919 + default=[],
920 + help="Refresh one source by id even when its same-day artifact is reusable. Can be repeated.",
921 + )
922 args = parser.parse_args(argv)
923
924 now = datetime.now(UTC)
@@ -687,9 +934,49 @@ def main(argv: list[str] | None = None) -> int:
934 )
935
936 source_configs = load_source_configs(Path(args.sources))
690 - articles, errors, statuses = crawl_sources_parallel(
691 - source_configs, since=since, until=until, max_workers=args.max_workers
937 + if args.output:
938 + out_path = Path(args.output)
939 + else:
940 + out_dir = raw_dir(args.topic)
941 + out_dir.mkdir(parents=True, exist_ok=True)
942 + out_path = out_dir / f"{week_slug(now)}-external-news.json"
943 +
944 + config_checksum = source_config_checksum(source_configs)
945 + force_sources = {source.name for source in source_configs} if args.force_refresh else set(args.force_refresh_source or [])
946 + reused_articles, sources_to_crawl, reuse_summary, provenance, _ = plan_source_reuse(
947 + out_path,
948 + source_configs,
949 + now=now,
950 + since=since,
951 + until=until,
952 + config_checksum=config_checksum,
953 + forced_sources=force_sources,
954 )
955 + refreshed_articles, errors, refreshed_statuses = crawl_sources_parallel(
956 + sources_to_crawl, since=since, until=until, max_workers=args.max_workers
957 + )
958 + articles = [*reused_articles, *refreshed_articles]
959 + reuse_summary, provenance = merge_reuse_results(
960 + reuse_summary,
961 + provenance,
962 + refreshed_statuses,
963 + refreshed_articles,
964 + now=now,
965 + since=since,
966 + until=until,
967 + config_checksum=config_checksum,
968 + output_path=out_path,
969 + run_id=os.environ.get("GITHUB_RUN_ID", "local"),
970 + )
971 + reused_sources = {entry["source_id"] for entry in provenance if entry.get("action") == "reused"}
972 + previous = _load_json_object(out_path) if out_path.exists() else None
973 + previous_metadata = previous.get("metadata", {}) if isinstance(previous, dict) and isinstance(previous.get("metadata"), dict) else {}
974 + previous_statuses = [
975 + {**status, "reused": True}
976 + for status in previous_metadata.get("source_status", [])
977 + if isinstance(status, dict) and status.get("source") in reused_sources
978 + ]
979 + statuses = [*previous_statuses, *refreshed_statuses]
980 output = build_output(
981 articles,
982 crawled_at=now,
@@ -699,27 +986,26 @@ def main(argv: list[str] | None = None) -> int:
986 "since": iso_timestamp(since),
987 "until": iso_timestamp(until),
988 },
702 - source_config_checksum_value=source_config_checksum(source_configs),
989 + source_config_checksum_value=config_checksum,
990 requested_sources=[source.name for source in source_configs],
991 source_statuses=statuses,
992 errors=errors,
993 + source_reuse_summary=reuse_summary,
994 + source_artifact_provenance=provenance,
995 + run_id=os.environ.get("GITHUB_RUN_ID", "local"),
996 )
997
708 - if args.output:
709 - out_path = Path(args.output)
710 - else:
711 - out_dir = raw_dir(args.topic)
712 - out_dir.mkdir(parents=True, exist_ok=True)
713 - out_path = out_dir / f"{week_slug(now)}-external-news.json"
714 -
998 out_path.parent.mkdir(parents=True, exist_ok=True)
999 with open(out_path, "w", encoding="utf-8") as f:
1000 json.dump(output, f, indent=2, ensure_ascii=False)
1001
1002 + reused_count = sum(1 for item in output["metadata"]["source_reuse_summary"] if item["action"] == "reused")
1003 + refreshed_count = sum(1 for item in output["metadata"]["source_reuse_summary"] if item["action"] != "reused")
1004 print(f"Crawled {output['metadata']['total_articles']} articles "
1005 f"from {output['metadata']['source_count']} sources "
1006 f"({output['metadata']['relevant_articles']} relevant, "
722 - f"{output['metadata']['dedupe_count']} deduped) → {out_path}")
1007 + f"{output['metadata']['dedupe_count']} deduped, "
1008 + f"{reused_count} reused, {refreshed_count} refreshed) → {out_path}")
1009 return 0
1010
1011
tests/test_crawl.py
+140
@@ -1,6 +1,8 @@
1 +import json
2 import tempfile
3 import unittest
4 from argparse import Namespace
5 +from datetime import datetime
6 from pathlib import Path
7 from unittest import mock
8
@@ -168,6 +170,144 @@ class CrawlTests(unittest.TestCase):
170 ],
171 )
172
173 + def test_load_reusable_github_payload_accepts_same_day_matching_artifact(self) -> None:
174 + tests_root = Path(__file__).resolve().parent
175 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
176 + base = Path(tmpdir)
177 + output = base / "data/raw/2026-W21.json"
178 + args = Namespace(since="2026-05-12", as_of="2026-05-19", max_results=25, output=str(output), topic=None, config=None)
179 + since = datetime(2026, 5, 12, tzinfo=crawl.UTC)
180 + window_end = datetime(2026, 5, 19, tzinfo=crawl.UTC)
181 + crawled_at = datetime(2026, 5, 19, 8, 0, tzinfo=crawl.UTC)
182 + checksum = crawl.github_crawl_config_checksum(args, since, window_end, 25)
183 + payload = {
184 + "week": "2026-W21",
185 + "crawled_at": crawl.iso_timestamp(crawled_at),
186 + "new_repos": [],
187 + "trending_repos": [],
188 + "signals": {"top_topics": []},
189 + "metadata": {
190 + "api_calls_used": 1,
191 + "cache_hits": 0,
192 + "stale_cache_hits": 0,
193 + "rate_limit_limit": None,
194 + "rate_limit_remaining": None,
195 + "rate_limit_reset": None,
196 + "rate_limit_resource": None,
197 + "partial_failures": [],
198 + "snapshot_path": "data/snapshots/2026-W21-stars.json",
199 + "crawl_window": {"since": "2026-05-12", "until": "2026-05-19"},
200 + "crawl_config_checksum": checksum,
201 + "schema_checksum": crawl.github_schema_checksum(),
202 + "same_day_reuse": {"status": "not_reused", "source": "github"},
203 + },
204 + }
205 + payload["metadata"]["artifact_checksum"] = crawl.github_artifact_checksum(payload)
206 + crawl.write_payload(output, payload)
207 +
208 + reused = crawl.load_reusable_github_payload(
209 + output,
210 + week="2026-W21",
211 + crawled_at=datetime(2026, 5, 19, 10, 0, tzinfo=crawl.UTC),
212 + since=since,
213 + window_end=window_end,
214 + config_checksum=checksum,
215 + )
216 +
217 + self.assertIsNotNone(reused)
218 + self.assertEqual(reused["metadata"]["same_day_reuse"]["status"], "reused")
219 + self.assertEqual(reused["metadata"]["same_day_reuse"]["source_id"], "github-search")
220 +
221 + def test_main_emits_github_source_id_in_same_day_reuse_metadata(self) -> None:
222 + class FakeClient:
223 + def __init__(self, token: str, **kwargs) -> None:
224 + self.token = token
225 + self.api_calls_used = 0
226 + self.cache_hits = 0
227 + self.stale_cache_hits = 0
228 + self.rate_limit_limit = None
229 + self.rate_limit_remaining = None
230 + self.rate_limit_reset = None
231 + self.rate_limit_resource = None
232 + self.errors = []
233 +
234 + def search_repositories(self, query: str, *, max_results: int = 1000):
235 + return []
236 +
237 + def has_readme(self, full_name: str) -> bool:
238 + return True
239 +
240 + tests_root = Path(__file__).resolve().parent
241 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
242 + output = Path(tmpdir) / "data/raw/2026-W21.json"
243 + args = Namespace(
244 + since="2026-05-12",
245 + as_of="2026-05-19",
246 + max_results=25,
247 + output=str(output),
248 + topic=None,
249 + config=None,
250 + force_refresh=True,
251 + )
252 +
253 + with mock.patch.object(crawl, "parse_args", return_value=args), mock.patch.dict(
254 + "os.environ", {"GITHUB_TOKEN": "token"}, clear=False
255 + ), mock.patch.object(crawl, "GitHubClient", FakeClient), mock.patch.object(
256 + crawl, "load_previous_star_snapshot", return_value={}
257 + ), mock.patch.object(
258 + crawl, "snapshots_dir", return_value=Path(tmpdir) / "data/snapshots"
259 + ), mock.patch.object(
260 + crawl, "utc_now", return_value=datetime(2026, 5, 19, 10, 0, tzinfo=crawl.UTC)
261 + ):
262 + exit_code = crawl.main()
263 +
264 + self.assertEqual(exit_code, 0)
265 + payload = json.loads(output.read_text(encoding="utf-8"))
266 + self.assertEqual(payload["metadata"]["same_day_reuse"]["status"], "not_reused")
267 + self.assertEqual(payload["metadata"]["same_day_reuse"]["source"], "github")
268 + self.assertEqual(payload["metadata"]["same_day_reuse"]["source_id"], "github-search")
269 +
270 + def test_load_reusable_github_payload_rejects_config_mismatch(self) -> None:
271 + tests_root = Path(__file__).resolve().parent
272 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
273 + base = Path(tmpdir)
274 + output = base / "data/raw/2026-W21.json"
275 + payload = {
276 + "week": "2026-W21",
277 + "crawled_at": "2026-05-19T08:00:00Z",
278 + "new_repos": [],
279 + "trending_repos": [],
280 + "signals": {"top_topics": []},
281 + "metadata": {
282 + "api_calls_used": 1,
283 + "cache_hits": 0,
284 + "stale_cache_hits": 0,
285 + "rate_limit_limit": None,
286 + "rate_limit_remaining": None,
287 + "rate_limit_reset": None,
288 + "rate_limit_resource": None,
289 + "partial_failures": [],
290 + "snapshot_path": "data/snapshots/2026-W21-stars.json",
291 + "crawl_window": {"since": "2026-05-12", "until": "2026-05-19"},
292 + "crawl_config_checksum": "old",
293 + "schema_checksum": crawl.github_schema_checksum(),
294 + "same_day_reuse": {"status": "not_reused", "source": "github"},
295 + },
296 + }
297 + payload["metadata"]["artifact_checksum"] = crawl.github_artifact_checksum(payload)
298 + crawl.write_payload(output, payload)
299 +
300 + reused = crawl.load_reusable_github_payload(
301 + output,
302 + week="2026-W21",
303 + crawled_at=datetime(2026, 5, 19, 10, 0, tzinfo=crawl.UTC),
304 + since=datetime(2026, 5, 12, tzinfo=crawl.UTC),
305 + window_end=datetime(2026, 5, 19, tzinfo=crawl.UTC),
306 + config_checksum="new",
307 + )
308 +
309 + self.assertIsNone(reused)
310 +
311
312 if __name__ == "__main__":
313 unittest.main()
tests/test_publish_manifest.py
+157 -2
@@ -1,8 +1,11 @@
1 import json
2 import tempfile
3 import unittest
4 +from argparse import Namespace
5 +from datetime import datetime
6 from pathlib import Path
7
8 +import scripts.crawl as crawl
9 import scripts.publish_manifest as publish_manifest
10
11
@@ -11,7 +14,13 @@ CURRENT_DATETIME = "2026-05-18T08:00:00Z"
14 WEEK = "2026-W21"
15
16
14 -def write_raw(path: Path, *, week: str = WEEK, crawled_at: str = CURRENT_DATETIME) -> None:
17 +def write_raw(
18 + path: Path,
19 + *,
20 + week: str = WEEK,
21 + crawled_at: str = CURRENT_DATETIME,
22 + metadata: dict | None = None,
23 +) -> None:
24 path.parent.mkdir(parents=True, exist_ok=True)
25 path.write_text(
26 json.dumps(
@@ -20,7 +29,7 @@ def write_raw(path: Path, *, week: str = WEEK, crawled_at: str = CURRENT_DATETIM
29 "crawled_at": crawled_at,
30 "new_repos": [],
31 "trending_repos": [],
23 - "metadata": {"same_day_reuse": "not_reused"},
32 + "metadata": metadata or {"same_day_reuse": "not_reused"},
33 }
34 ),
35 encoding="utf-8",
@@ -162,6 +171,152 @@ class PublishManifestTests(unittest.TestCase):
171 self.assertEqual(payload["source_artifacts"][0]["freshness"]["status"], "stale")
172 self.assertTrue(any("timestamp week mismatch" in reason for reason in payload["promotion"]["reasons"]))
173
174 + def test_structured_same_day_reuse_metadata_remains_machine_readable(self) -> None:
175 + tests_root = Path(__file__).resolve().parent
176 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
177 + base = Path(tmpdir)
178 + raw = base / "data/raw/2026-W21.json"
179 + summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
180 + manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
181 + reuse_metadata = {
182 + "same_day_reuse": {
183 + "status": "reused",
184 + "source": "github",
185 + "source_id": "github-search",
186 + "original_run_id": "111111",
187 + "original_crawled_at": "2026-05-18T06:00:00Z",
188 + "reused_at": CURRENT_DATETIME,
189 + "week": WEEK,
190 + "crawl_window": {"since": "2026-05-11", "until": "2026-05-18"},
191 + "crawl_config_checksum": "config-sha",
192 + "schema_checksum": "schema-sha",
193 + "content_checksum": "content-sha",
194 + },
195 + "artifact_checksum": "artifact-sha",
196 + }
197 + write_raw(raw, metadata=reuse_metadata)
198 + write_summary(summary)
199 +
200 + publish_manifest.main(
201 + [
202 + "create",
203 + "--week",
204 + WEEK,
205 + "--run-id",
206 + RUN_ID,
207 + "--current-datetime",
208 + CURRENT_DATETIME,
209 + "--summary",
210 + str(summary),
211 + "--published-summary",
212 + str(base / "data/analyzed/2026-W21-summary.md"),
213 + "--raw-json",
214 + str(raw),
215 + "--analysis-source",
216 + "copilot-cli",
217 + "--analysis-model",
218 + "copilot-default",
219 + "--validation-status",
220 + "passed",
221 + "--output",
222 + str(manifest),
223 + ]
224 + )
225 +
226 + reuse = json.loads(manifest.read_text(encoding="utf-8"))["source_artifacts"][0]["same_day_reuse"]
227 + self.assertIsInstance(reuse, dict)
228 + self.assertEqual(reuse["status"], "reused")
229 + self.assertEqual(reuse["source"], "github")
230 + self.assertEqual(reuse["source_id"], "github-search")
231 + self.assertEqual(reuse["original_run_id"], "111111")
232 + self.assertEqual(reuse["original_crawled_at"], "2026-05-18T06:00:00Z")
233 + self.assertEqual(reuse["reused_at"], CURRENT_DATETIME)
234 + self.assertEqual(reuse["crawl_window"]["since"], "2026-05-11")
235 + self.assertEqual(reuse["crawl_config_checksum"], "config-sha")
236 + self.assertEqual(reuse["schema_checksum"], "schema-sha")
237 + self.assertEqual(reuse["content_checksum"], "content-sha")
238 + self.assertNotEqual(reuse["status"], str(dict(reuse)))
239 +
240 + def test_manifest_preserves_source_id_from_crawl_reuse_metadata(self) -> None:
241 + tests_root = Path(__file__).resolve().parent
242 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
243 + base = Path(tmpdir)
244 + raw = base / "data/raw/2026-W21.json"
245 + summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
246 + manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
247 + since = datetime(2026, 5, 12, tzinfo=crawl.UTC)
248 + window_end = datetime(2026, 5, 19, tzinfo=crawl.UTC)
249 + original_crawled_at = datetime(2026, 5, 19, 8, 0, tzinfo=crawl.UTC)
250 + reused_at = datetime(2026, 5, 19, 10, 0, tzinfo=crawl.UTC)
251 + args = Namespace(since="2026-05-12", as_of="2026-05-19", max_results=25, output=str(raw), topic=None, config=None)
252 + checksum = crawl.github_crawl_config_checksum(args, since, window_end, 25)
253 + payload = {
254 + "week": WEEK,
255 + "crawled_at": crawl.iso_timestamp(original_crawled_at),
256 + "new_repos": [],
257 + "trending_repos": [],
258 + "signals": {"top_topics": []},
259 + "metadata": {
260 + "api_calls_used": 1,
261 + "cache_hits": 0,
262 + "stale_cache_hits": 0,
263 + "rate_limit_limit": None,
264 + "rate_limit_remaining": None,
265 + "rate_limit_reset": None,
266 + "rate_limit_resource": None,
267 + "partial_failures": [],
268 + "run_id": "111111",
269 + "snapshot_path": "data/snapshots/2026-W21-stars.json",
270 + "crawl_window": {"since": "2026-05-12", "until": "2026-05-19"},
271 + "crawl_config_checksum": checksum,
272 + "schema_checksum": crawl.github_schema_checksum(),
273 + "same_day_reuse": {"status": "not_reused", "source": "github", "source_id": crawl.GITHUB_SOURCE_ID},
274 + },
275 + }
276 + payload["metadata"]["artifact_checksum"] = crawl.github_artifact_checksum(payload)
277 + crawl.write_payload(raw, payload)
278 + reused = crawl.load_reusable_github_payload(
279 + raw,
280 + week=WEEK,
281 + crawled_at=reused_at,
282 + since=since,
283 + window_end=window_end,
284 + config_checksum=checksum,
285 + )
286 + self.assertIsNotNone(reused)
287 + crawl.write_payload(raw, reused)
288 + write_summary(summary)
289 +
290 + publish_manifest.main(
291 + [
292 + "create",
293 + "--week",
294 + WEEK,
295 + "--run-id",
296 + RUN_ID,
297 + "--current-datetime",
298 + CURRENT_DATETIME,
299 + "--summary",
300 + str(summary),
301 + "--published-summary",
302 + str(base / "data/analyzed/2026-W21-summary.md"),
303 + "--raw-json",
304 + str(raw),
305 + "--analysis-source",
306 + "copilot-cli",
307 + "--analysis-model",
308 + "copilot-default",
309 + "--validation-status",
310 + "passed",
311 + "--output",
312 + str(manifest),
313 + ]
314 + )
315 +
316 + reuse = json.loads(manifest.read_text(encoding="utf-8"))["source_artifacts"][0]["same_day_reuse"]
317 + self.assertEqual(reuse["status"], "reused")
318 + self.assertEqual(reuse["source_id"], "github-search")
319 +
320
321 if __name__ == "__main__":
322 unittest.main()
tests/test_techcrunch_crawler.py
+121
@@ -550,3 +550,124 @@ class TestExternalNewsSources:
550
551 assert source.last_attempts == DEFAULT_FETCH_RETRIES + 1
552 assert source.last_timeout_seconds == DEFAULT_FETCH_TIMEOUT_SECONDS
553 +
554 +class TestSameDaySourceReuse:
555 + def _sources(self):
556 + return [
557 + NewsSourceConfig("alpha", "https://techcrunch.com/feed/"),
558 + NewsSourceConfig("beta", "https://github.blog/feed/"),
559 + ]
560 +
561 + def _article(self, source, title, url):
562 + return {
563 + "source": source,
564 + "title": title,
565 + "url": url,
566 + "published_at": "2026-05-19T10:00:00Z",
567 + "categories": ["AI"],
568 + "summary": "open source AI framework",
569 + "github_links": [],
570 + "entities": [],
571 + "relevance_score": 0.6,
572 + }
573 +
574 + def _write_previous(self, path, *, crawled_at, statuses=None, articles=None, sources=None):
575 + from scripts.techcrunch_crawler import source_config_checksum
576 + sources = sources or self._sources()
577 + output = build_output(
578 + articles or [self._article("alpha", "Alpha", "https://example.com/alpha")],
579 + crawled_at=crawled_at,
580 + source="external_news",
581 + source_count=len(sources),
582 + crawl_window={
583 + "since": "2026-05-12T00:00:00Z",
584 + "until": "2026-05-19T00:00:00Z",
585 + },
586 + source_config_checksum_value=source_config_checksum(sources),
587 + requested_sources=[source.name for source in sources],
588 + source_statuses=statuses or [
589 + {"source": "alpha", "host": "techcrunch.com", "success": True, "attempts": 1, "timeout_seconds": 15, "total_articles": 1, "relevant_articles": 1, "github_links_found": 0, "started_at": "2026-05-19T08:00:00Z", "ended_at": "2026-05-19T08:00:01Z", "duration_seconds": 1.0, "error_class": "", "error_message": ""},
590 + {"source": "beta", "host": "github.blog", "success": True, "attempts": 1, "timeout_seconds": 15, "total_articles": 0, "relevant_articles": 0, "github_links_found": 0, "started_at": "2026-05-19T08:00:00Z", "ended_at": "2026-05-19T08:00:01Z", "duration_seconds": 1.0, "error_class": "", "error_message": ""},
591 + ],
592 + source_reuse_summary=[],
593 + source_artifact_provenance=[],
594 + run_id="111",
595 + )
596 + path.write_text(json.dumps(output), encoding="utf-8")
597 +
598 + def test_reuses_successful_same_day_sources(self, tmp_path):
599 + from scripts.techcrunch_crawler import plan_source_reuse, source_config_checksum
600 + sources = self._sources()
601 + path = tmp_path / "external.json"
602 + now = datetime(2026, 5, 19, 9, 0, tzinfo=UTC)
603 + self._write_previous(path, crawled_at=now)
604 +
605 + reused, pending, summary, provenance, _ = plan_source_reuse(
606 + path,
607 + sources,
608 + now=now,
609 + since=datetime(2026, 5, 12, tzinfo=UTC),
610 + until=datetime(2026, 5, 19, tzinfo=UTC),
611 + config_checksum=source_config_checksum(sources),
612 + )
613 +
614 + assert [item["action"] for item in summary] == ["reused", "reused"]
615 + assert pending == []
616 + assert [article["title"] for article in reused] == ["Alpha"]
617 + assert provenance[0]["original_run_id"] == "111"
618 + assert provenance[0]["content_checksum"]
619 +
620 + def test_rejects_yesterday_artifact_as_stale(self, tmp_path):
621 + from scripts.techcrunch_crawler import plan_source_reuse, source_config_checksum
622 + sources = self._sources()
623 + path = tmp_path / "external.json"
624 + self._write_previous(path, crawled_at=datetime(2026, 5, 18, 9, 0, tzinfo=UTC))
625 +
626 + reused, pending, summary, _, _ = plan_source_reuse(
627 + path,
628 + sources,
629 + now=datetime(2026, 5, 19, 9, 0, tzinfo=UTC),
630 + since=datetime(2026, 5, 12, tzinfo=UTC),
631 + until=datetime(2026, 5, 19, tzinfo=UTC),
632 + config_checksum=source_config_checksum(sources),
633 + )
634 +
635 + assert reused == []
636 + assert [source.name for source in pending] == ["alpha", "beta"]
637 + assert {item["action"] for item in summary} == {"stale"}
638 +
639 + def test_partial_rerun_reuses_success_and_fetches_failed(self, tmp_path):
640 + from scripts.techcrunch_crawler import plan_source_reuse, source_config_checksum
641 + sources = self._sources()
642 + path = tmp_path / "external.json"
643 + self._write_previous(
644 + path,
645 + crawled_at=datetime(2026, 5, 19, 9, 0, tzinfo=UTC),
646 + statuses=[
647 + {"source": "alpha", "host": "techcrunch.com", "success": True, "attempts": 1, "timeout_seconds": 15, "total_articles": 1, "relevant_articles": 1, "github_links_found": 0, "started_at": "2026-05-19T08:00:00Z", "ended_at": "2026-05-19T08:00:01Z", "duration_seconds": 1.0, "error_class": "", "error_message": ""},
648 + {"source": "beta", "host": "github.blog", "success": False, "attempts": 2, "timeout_seconds": 15, "total_articles": 0, "relevant_articles": 0, "github_links_found": 0, "started_at": "2026-05-19T08:00:00Z", "ended_at": "2026-05-19T08:00:01Z", "duration_seconds": 1.0, "error_class": "TimeoutError", "error_message": "timeout"},
649 + ],
650 + )
651 +
652 + reused, pending, summary, _, _ = plan_source_reuse(
653 + path,
654 + sources,
655 + now=datetime(2026, 5, 19, 10, 0, tzinfo=UTC),
656 + since=datetime(2026, 5, 12, tzinfo=UTC),
657 + until=datetime(2026, 5, 19, tzinfo=UTC),
658 + config_checksum=source_config_checksum(sources),
659 + )
660 +
661 + assert [article["source"] for article in reused] == ["alpha"]
662 + assert [source.name for source in pending] == ["beta"]
663 + assert {item["source"]: item["action"] for item in summary} == {"alpha": "reused", "beta": "failed"}
664 +
665 + def test_deterministic_fan_in_dedupes_reused_and_refreshed_articles(self):
666 + first = self._article("alpha", "Same", "https://example.com/story/")
667 + second = self._article("beta", "Same mirror", "https://example.com/story")
668 + deduped_once, count_once = dedupe_articles([first, second])
669 + deduped_twice, count_twice = dedupe_articles([second, first])
670 +
671 + assert count_once == count_twice == 1
672 + assert deduped_once == deduped_twice
673 + assert deduped_once[0]["sources"] == ["alpha", "beta"]