All checks were successful
Deploy to Server / deploy (push) Successful in 29s
364 lines
11 KiB
Python
Executable file
364 lines
11 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Extract all unique historical versions of drops.json/rewards.json from the SunkwiBOT/twitch-drops-api repo and merge them into a single JSON file.
|
|
|
|
For each campaign (identified by its ``id``), channels from ALL commits are
|
|
accumulated and deduplicated. The output file can be imported in a single
|
|
ORM pass via ``--from-file``, which is much faster than iterating 14k+
|
|
git commits through Django.
|
|
|
|
Usage:
|
|
python tools/extract_historical_drops.py [--rewards] [--git-dir DIR] [--output FILE]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import shutil
|
|
import subprocess # noqa: S404
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
REPO_URL = "https://github.com/SunkwiBOT/twitch-drops-api"
|
|
|
|
# Fields that should be taken from the *first* occurrence of a campaign
|
|
# (game metadata, names, descriptions, timestamps, etc.)
|
|
CAMPAIGN_FIRST_FIELDS = {
|
|
"gameId",
|
|
"gameDisplayName",
|
|
"gameBoxArtURL",
|
|
"startAt",
|
|
"endAt",
|
|
}
|
|
|
|
# Fields within individual rewards that should be first-occurrence only
|
|
REWARD_FIRST_FIELDS: set[str] = set()
|
|
|
|
# Fields that should be *merged* across all occurrences (channels, time-based drops)
|
|
REWARD_MERGE_FIELDS = {"channels"} # mapped from allow.channels
|
|
|
|
|
|
def run_git(git_dir: str, *args: str, check: bool = True) -> str:
|
|
"""Run a git command and return stdout.
|
|
|
|
Args:
|
|
git_dir: Path to the git repository.
|
|
*args: Git command arguments.
|
|
check: If True, raise on non-zero exit.
|
|
|
|
Returns:
|
|
The stdout output of the git command.
|
|
|
|
Raises:
|
|
subprocess.CalledProcessError: If the command fails and check is True.
|
|
FileNotFoundError: If the git executable is not found.
|
|
"""
|
|
try:
|
|
git: str | None = shutil.which("git")
|
|
if not git:
|
|
msg = "Git executable not found in PATH."
|
|
raise FileNotFoundError(msg)
|
|
result: subprocess.CompletedProcess[str] = subprocess.run( # noqa: S603
|
|
[git, "--git-dir", git_dir, *args],
|
|
capture_output=True,
|
|
text=True,
|
|
check=check,
|
|
)
|
|
except subprocess.CalledProcessError:
|
|
if check:
|
|
raise
|
|
return ""
|
|
else:
|
|
return result.stdout
|
|
|
|
|
|
def extract(
|
|
git_dir: str,
|
|
file_path: str,
|
|
*,
|
|
skip_latest: bool = True,
|
|
) -> list[dict[str, Any]]:
|
|
"""Extract and merge all historical versions of *file_path* from the repo.
|
|
|
|
Returns:
|
|
A list of top-level entries (drop groups or reward campaigns)
|
|
with channels accumulated across all commits.
|
|
"""
|
|
log_output = run_git(
|
|
git_dir,
|
|
"log",
|
|
"--all",
|
|
"--format=%H %ct",
|
|
"--follow",
|
|
"--reverse",
|
|
"--",
|
|
file_path,
|
|
)
|
|
lines = [ln.strip() for ln in log_output.strip().split("\n") if ln.strip()]
|
|
|
|
if skip_latest and lines:
|
|
lines = lines[:-1]
|
|
|
|
# Per-campaign-id accumulators
|
|
# Structure: {twitch_id: {"_first_data": {...}, "_channels": {channel_id: channel_data}}}
|
|
campaigns: dict[str, dict[str, Any]] = {}
|
|
# For drops.json, track groups separately
|
|
groups: dict[str, dict[str, Any]] = {}
|
|
total_processed = 0
|
|
seen_hashes: set[str] = set()
|
|
|
|
for line in lines:
|
|
sha = line.split(None, 1)[0]
|
|
|
|
try:
|
|
content = run_git(git_dir, "show", f"{sha}:{file_path}", check=True)
|
|
except subprocess.CalledProcessError:
|
|
continue
|
|
|
|
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
|
if content_hash in seen_hashes:
|
|
continue
|
|
seen_hashes.add(content_hash)
|
|
|
|
try:
|
|
data = json.loads(content)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
if not isinstance(data, list):
|
|
continue
|
|
|
|
if file_path == "drops.json":
|
|
_merge_drops_version(data, groups, campaigns)
|
|
else:
|
|
_merge_rewards_version(data, campaigns)
|
|
|
|
total_processed += 1
|
|
|
|
# Build final output from accumulated data
|
|
result: list[dict[str, Any]] = []
|
|
|
|
if file_path == "drops.json":
|
|
# Sort groups by their startAt for stable output
|
|
sorted_groups = sorted(groups.values(), key=lambda g: g.get("startAt", ""))
|
|
for group in sorted_groups:
|
|
group_rewards = [
|
|
_build_reward_output(campaigns[cid])
|
|
for cid in group.get("_reward_ids", [])
|
|
if cid in campaigns
|
|
]
|
|
if group_rewards:
|
|
entry = {k: v for k, v in group.items() if not k.startswith("_")}
|
|
entry["rewards"] = group_rewards
|
|
result.append(entry)
|
|
else:
|
|
sorted_campaigns = sorted(
|
|
campaigns.values(),
|
|
key=lambda c: c.get("_first_data", {}).get("startAt", ""),
|
|
)
|
|
result.extend(_build_reward_output(camp) for camp in sorted_campaigns)
|
|
|
|
return result
|
|
|
|
|
|
def _merge_drops_version(
|
|
data: list[dict[str, Any]],
|
|
groups: dict[str, dict[str, Any]],
|
|
campaigns: dict[str, dict[str, Any]],
|
|
) -> None:
|
|
"""Merge a single commit's drops.json data into the accumulators."""
|
|
for item in data:
|
|
game_id = item.get("gameId", "")
|
|
if game_id not in groups:
|
|
groups[game_id] = {k: v for k, v in item.items() if k != "rewards"}
|
|
groups[game_id]["_reward_ids"] = []
|
|
|
|
for reward in item.get("rewards", []):
|
|
rid = reward.get("id", "")
|
|
if not rid:
|
|
continue
|
|
|
|
if rid not in campaigns:
|
|
# First occurrence: store all fields
|
|
campaigns[rid] = {
|
|
"_first_data": dict(reward),
|
|
"_channels": {},
|
|
}
|
|
groups[game_id]["_reward_ids"].append(rid)
|
|
else:
|
|
# Subsequent occurrence: merge metadata + channels
|
|
_merge_metadata(campaigns[rid], reward)
|
|
_merge_channels(campaigns[rid], reward)
|
|
|
|
# Always merge channels from every commit
|
|
_merge_channels(campaigns[rid], reward)
|
|
|
|
|
|
def _merge_rewards_version(
|
|
data: list[dict[str, Any]],
|
|
campaigns: dict[str, dict[str, Any]],
|
|
) -> None:
|
|
"""Merge a single commit's rewards.json data into the accumulators."""
|
|
for reward in data:
|
|
rid = reward.get("id", "")
|
|
if not rid:
|
|
continue
|
|
|
|
if rid not in campaigns:
|
|
campaigns[rid] = {
|
|
"_first_data": dict(reward),
|
|
"_channels": {},
|
|
}
|
|
else:
|
|
# Subsequent occurrence: merge metadata
|
|
_merge_metadata(campaigns[rid], reward)
|
|
|
|
_merge_channels(campaigns[rid], reward)
|
|
|
|
|
|
def _merge_metadata(
|
|
campaign: dict[str, Any],
|
|
reward_data: dict[str, Any],
|
|
) -> None:
|
|
"""Fill empty/missing fields in ``_first_data`` with values from later commits.
|
|
|
|
For each top-level field in *reward_data*, if the stored value in
|
|
``_first_data`` is empty/None and the new value is not, update it.
|
|
This ensures campaigns get the best available description, dates,
|
|
image URL, etc. across all historical versions.
|
|
"""
|
|
first = campaign["_first_data"]
|
|
|
|
# Top-level string/primitive fields to merge (first non-empty wins)
|
|
for key in (
|
|
"name",
|
|
"description",
|
|
"imageURL",
|
|
"detailsURL",
|
|
"accountLinkURL",
|
|
"startAt",
|
|
"endAt",
|
|
"status",
|
|
):
|
|
old_val = first.get(key)
|
|
new_val = reward_data.get(key)
|
|
if not old_val and new_val:
|
|
first[key] = new_val
|
|
|
|
# Nested fields: allow.isEnabled
|
|
old_allow = first.get("allow")
|
|
new_allow = reward_data.get("allow")
|
|
if (
|
|
isinstance(old_allow, dict)
|
|
and isinstance(new_allow, dict)
|
|
and not old_allow.get("isEnabled")
|
|
and new_allow.get("isEnabled")
|
|
):
|
|
old_allow["isEnabled"] = new_allow["isEnabled"]
|
|
|
|
|
|
def _merge_channels(
|
|
campaign: dict[str, Any],
|
|
reward_data: dict[str, Any],
|
|
) -> None:
|
|
"""Merge channels from *reward_data* into the accumulated *campaign*."""
|
|
allow = reward_data.get("allow")
|
|
if not allow or not isinstance(allow, dict):
|
|
return
|
|
channels = allow.get("channels")
|
|
if not channels or not isinstance(channels, list):
|
|
return
|
|
|
|
for ch in channels:
|
|
ch_id = ch.get("id", "")
|
|
if ch_id and ch_id not in campaign["_channels"]:
|
|
campaign["_channels"][ch_id] = dict(ch)
|
|
|
|
|
|
def _build_reward_output(campaign: dict[str, Any]) -> dict[str, Any]:
|
|
"""Build the final reward dict from accumulated data.
|
|
|
|
Returns:
|
|
The merged reward dict with all accumulated channels.
|
|
"""
|
|
first = campaign["_first_data"]
|
|
|
|
# Start with all first-occurrence data
|
|
result = dict(first)
|
|
|
|
# Build the allow field with merged channels
|
|
merged_channels = list(campaign["_channels"].values())
|
|
if merged_channels or "allow" in first:
|
|
allow = (
|
|
dict(first.get("allow", {})) if isinstance(first.get("allow"), dict) else {}
|
|
)
|
|
allow["channels"] = merged_channels
|
|
allow["isEnabled"] = (
|
|
first.get("allow", {}).get("isEnabled", True)
|
|
if isinstance(first.get("allow"), dict)
|
|
else True
|
|
)
|
|
result["allow"] = allow
|
|
|
|
return result
|
|
|
|
|
|
def main() -> None:
|
|
"""Entry point: clone repo, extract historical data, write output JSON."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Extract historical drops data from SunkwiBOT/twitch-drops-api",
|
|
)
|
|
parser.add_argument(
|
|
"--rewards",
|
|
action="store_true",
|
|
help="Extract rewards.json instead of drops.json",
|
|
)
|
|
parser.add_argument(
|
|
"--git-dir",
|
|
type=str,
|
|
default="",
|
|
help="Path to an existing bare clone (avoids re-cloning)",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
type=str,
|
|
default="",
|
|
help="Output file path (default: drops_merged.json or rewards_merged.json)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
file_path = "rewards.json" if args.rewards else "drops.json"
|
|
default_output = "rewards_merged.json" if args.rewards else "drops_merged.json"
|
|
output_path = args.output or default_output
|
|
|
|
git_dir = args.git_dir
|
|
cleanup = False
|
|
tmp: Path | None = None
|
|
|
|
if not git_dir:
|
|
tmp = Path(tempfile.mkdtemp())
|
|
run_git(
|
|
str(tmp),
|
|
"clone",
|
|
"--filter=blob:none",
|
|
"--bare",
|
|
REPO_URL,
|
|
str(tmp / "repo.git"),
|
|
check=True,
|
|
)
|
|
git_dir = str(tmp / "repo.git")
|
|
cleanup = True
|
|
|
|
try:
|
|
result = extract(git_dir, file_path)
|
|
with Path(output_path).open("w", encoding="utf-8") as f:
|
|
json.dump(result, f, indent=2)
|
|
finally:
|
|
if cleanup and tmp is not None:
|
|
shutil.rmtree(str(tmp), ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|