Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
mergeCodes.py
__pycache__/
*.pyc
*.pyo
133 changes: 133 additions & 0 deletions merge_shiftcodes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
Merge shift codes from GitHub repositories and web scrapers.
"""

import json
import requests
from datetime import datetime, timezone
from collections import OrderedDict

from scrapers import mentalmars, gamedevtools, xsmashx88x

REPOS = [
"https://raw.githubusercontent.com/Majawat/autoshift-codes/main/shiftcodes.json",
"https://raw.githubusercontent.com/ugoogalizer/autoshift-codes/main/shiftcodes.json",
"https://raw.githubusercontent.com/DankestMemeLord/autoshift-codes/main/shiftcodes.json",
"https://raw.githubusercontent.com/zarmstrong/autoshift-codes/main/shiftcodes.json",
]

SCRAPERS = [
mentalmars,
gamedevtools,
xsmashx88x,
]


def fetch_json(url):
print(f"Fetching {url}...")
try:
response = requests.get(url, timeout=30)
response.raise_for_status()
return response.json()
except Exception as e:
print(f" Error fetching {url}: {e}")
return None


def merge_codes(all_data):
"""Merge codes from multiple sources, keeping the entry with the most recent archived date."""
merged = OrderedDict()

for data in all_data:
if not data or not isinstance(data, list) or len(data) == 0:
continue

# Handles [{meta, codes}] (repo JSON) or a flat list of code dicts (scrapers)
if isinstance(data[0], dict) and "codes" in data[0]:
codes = data[0].get("codes", [])
else:
codes = data

for entry in codes:
if not isinstance(entry, dict):
continue
code = entry.get("code")
if not code:
continue

if code in merged:
if entry.get("archived", "") > merged[code].get("archived", ""):
merged[code] = entry
else:
merged[code] = entry

return list(merged.values())


def main():
print("Starting shift codes merge...\n")

all_data = []

# --- GitHub repos ---
repo_count = 0
for url in REPOS:
data = fetch_json(url)
if data:
all_data.append(data)
repo_count += 1
print(f"\nFetched {repo_count}/{len(REPOS)} repositories")

# --- Web scrapers ---
print()
scraper_total = 0
for scraper in SCRAPERS:
try:
codes = scraper.scrape()
if codes:
all_data.append(codes)
scraper_total += len(codes)
except Exception as e:
print(f" [ERROR] {scraper.__name__} failed: {e}")

print(f"\nScraped {scraper_total} codes from web sources")

if not all_data:
print("\nError: No data from any source!")
return

# --- Merge & sort ---
merged = merge_codes(all_data)
print(f"Total unique codes after merge: {len(merged)}")

# Sort A→Z by game, then newest-first within each game
merged.sort(key=lambda x: (x.get("game", ""), x.get("archived", "")), reverse=False)
merged.sort(key=lambda x: x.get("game", ""))

output = [
{
"meta": {
"version": "0.1",
"description": "GitHub Alternate Source for Shift Codes",
"attribution": "Data sourced from mentalmars.com, gamedevtools.net, xsmashx88x.github.io, and GitHub forks",
"permalink": "https://raw.githubusercontent.com/Majawat/autoshift-codes/main/shiftcodes.json",
"generated": {
"human": datetime.now(timezone.utc).isoformat()
},
"newcodecount": len(merged)
},
"codes": merged
}
]

output_file = "shiftcodes.json"
print(f"\nWriting to {output_file}...")
with open(output_file, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, ensure_ascii=False)

print(f"[SUCCESS] {len(merged)} unique codes written to {output_file}")


if __name__ == "__main__":
main()
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
requests
beautifulsoup4
Empty file added scrapers/__init__.py
Empty file.
79 changes: 79 additions & 0 deletions scrapers/gamedevtools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import re
import requests
from bs4 import BeautifulSoup
from datetime import datetime, timezone

from .utils import CODE_PATTERN, format_expires

PAGES = [
("Borderlands 4", "https://gamedevtools.net/shift-codes-borderlands-4"),
]

_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; autoshift-scraper/1.0)"}

# en-dash or hyphen between code and description
_SEP = re.compile(r"\s*[–\-]+\s*")


def _scrape_page(game, url):
try:
resp = requests.get(url, timeout=30, headers=_HEADERS)
resp.raise_for_status()
except Exception as e:
print(f" [gamedevtools] Error fetching {url}: {e}")
return []

soup = BeautifulSoup(resp.text, "html.parser")
codes = []

for li in soup.find_all("li"):
strong = li.find("strong")
if not strong:
continue

code = strong.get_text(strip=True)
if not CODE_PATTERN.match(code):
continue

full_text = li.get_text(" ", strip=True)
rest = _SEP.split(full_text[len(code):].strip(), maxsplit=1)
description = rest[-1].strip() if rest else ""

# Split "REWARD, expires DATE"
expires_str = ""
if re.search(r",\s*expires\s+", description, re.IGNORECASE):
parts = re.split(r",\s*expires\s+", description, maxsplit=1, flags=re.IGNORECASE)
reward = parts[0].strip()
expires_str = parts[1].strip()
else:
reward = description

codes.append({
"code": code,
"type": "shift",
"game": game,
"platform": "universal",
"reward": reward,
"archived": datetime.now(timezone.utc).isoformat(),
"expires": format_expires(expires_str),
"expired": False,
"link": url,
})

return codes


def scrape():
print("Scraping gamedevtools.net...")
codes = []
for game, url in PAGES:
page_codes = _scrape_page(game, url)
print(f" {game}: {len(page_codes)} codes")
codes.extend(page_codes)
print(f" Total from gamedevtools: {len(codes)}")
return codes


if __name__ == "__main__":
for entry in scrape():
print(entry)
98 changes: 98 additions & 0 deletions scrapers/mentalmars.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import requests
from bs4 import BeautifulSoup
from datetime import datetime, timezone

from .utils import CODE_PATTERN, parse_date, format_expires

PAGES = [
("Borderlands 4", "https://mentalmars.com/game-news/borderlands-4-shift-codes/"),
("Borderlands 3", "https://mentalmars.com/game-news/borderlands-3-golden-keys/"),
("Tiny Tina's Wonderlands", "https://mentalmars.com/game-news/tiny-tinas-wonderlands-shift-codes/"),
("Borderlands 2", "https://mentalmars.com/game-news/borderlands-2-golden-keys/"),
]

_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; autoshift-scraper/1.0)"}


def _scrape_page(game, url):
try:
resp = requests.get(url, timeout=30, headers=_HEADERS)
resp.raise_for_status()
except Exception as e:
print(f" [mentalmars] Error fetching {url}: {e}")
return []

soup = BeautifulSoup(resp.text, "html.parser")
codes = []

for table in soup.find_all("table"):
thead = table.find("thead")
if not thead:
continue
headers = [th.get_text(strip=True).lower() for th in thead.find_all("th")]

# Require a column whose header contains "code"
code_col = next((i for i, h in enumerate(headers) if "code" in h), None)
if code_col is None:
continue

reward_col = next((i for i, h in enumerate(headers) if "reward" in h), 0)
added_col = next((i for i, h in enumerate(headers) if "added" in h), 1)
expires_col = next((i for i, h in enumerate(headers) if "expir" in h), 3)

tbody = table.find("tbody")
if not tbody:
continue

for row in tbody.find_all("tr"):
cells = row.find_all("td")
if len(cells) <= code_col:
continue

code_cell = cells[code_col]
code_tag = code_cell.find("code")
if not code_tag:
continue

code = code_tag.get_text(strip=True)
if not CODE_PATTERN.match(code):
continue

expired = bool(code_cell.find("s"))

reward = cells[reward_col].get_text(" ", strip=True) if len(cells) > reward_col else ""
added_str = cells[added_col].get_text(strip=True) if len(cells) > added_col else ""
expires_str = cells[expires_col].get_text(strip=True) if len(cells) > expires_col else ""

added_dt = parse_date(added_str)
archived = (added_dt or datetime.now(timezone.utc)).isoformat()

codes.append({
"code": code,
"type": "shift",
"game": game,
"platform": "universal",
"reward": reward,
"archived": archived,
"expires": format_expires(expires_str),
"expired": expired,
"link": url,
})

return codes


def scrape():
print("Scraping mentalmars.com...")
codes = []
for game, url in PAGES:
page_codes = _scrape_page(game, url)
print(f" {game}: {len(page_codes)} codes")
codes.extend(page_codes)
print(f" Total from mentalmars: {len(codes)}")
return codes


if __name__ == "__main__":
for entry in scrape():
print(entry)
33 changes: 33 additions & 0 deletions scrapers/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import re
from datetime import datetime, timezone

CODE_PATTERN = re.compile(r'^[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}$')

_DATE_FORMATS = [
"%b %d, %Y", # May 1, 2026
"%B %d, %Y", # May 01, 2026
"%d %B %Y", # 1 May 2026
"%d %b %Y", # 1 May 2026 (abbrev)
"%Y-%m-%d", # 2026-05-01
"%m/%d/%Y", # 4/6/2026
]

def parse_date(s):
if not s:
return None
s = s.strip()
for fmt in _DATE_FORMATS:
try:
return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc)
except ValueError:
continue
return None

def format_expires(s):
"""Normalise an expiry string to YYYY-MM-DD, 'Never', or 'Unknown'."""
dt = parse_date(s)
if dt:
return dt.strftime("%Y-%m-%d")
if s and s.strip().lower() in ("never", "ued", "unlimited", "n/a"):
return "Never"
return "Unknown"
Loading