"""Import attributable Call of Duty Wiki table snapshots into a factual challenge catalog. Input is one or more rendered, line-numbered source snapshots per game, exported by the research tool. Ambiguous or incomplete rows are skipped. No thresholds are invented. Keys depend on category + name, never page line numbers. Usage: python3 scripts/build-cod-challenges.py --snapshots /tmp/cod-wiki The checked-in JSON is the deployable snapshot; this command does not need DB access. """ import argparse import json from pathlib import Path import re TITLES={'black-ops':'Call_of_Duty:_Black_Ops','black-ops-2':'Call_of_Duty:_Black_Ops_II','ghosts':'Operations_(challenges)/Call_of_Duty:_Ghosts','advanced-warfare':'Call_of_Duty:_Advanced_Warfare','black-ops-3':'Call_of_Duty:_Black_Ops_III','infinite-warfare':'Call_of_Duty:_Infinite_Warfare','wwii':'Call_of_Duty:_WWII'} VERBS=r'(?:Get|Earn|Kill|Win|Complete|Destroy|Perform|Play|Achieve|Reach|Use|Capture|Defend|Survive|Assist|Shoot|Recover|Hack|Call in|Obtain|Unlock|Steal|Equip|Finish|Plant|Defuse|Deny|Pick up|Revive|Take|Absorb|Damage|Land|Hold|Score|Purchase|Make|Eliminate|Return|Collect|Prevent|Detonate|End|Resupply)' def slug(s):return re.sub(r'[^a-z0-9]+','-',s.lower()).strip('-') def clean(s):return re.sub(r'\s+',' ',s).strip().replace('[]','') def lines_for(directory,game): lines={} for p in directory.glob(game+'*.txt'): if p.name.split('--')[0].split('.txt')[0]!=game:continue raw=p.read_text();raw=re.sub(r'cite[^†]+†([^†]+)(?:†[^]+)?',r'\1',raw) for m in re.finditer(r'L(\d+):\s*(.*?)(?=L\d+:|$)',raw,re.S): number=int(m[1]);text=m[2].split('-------------------------')[0].strip() if number not in lines or len(text)>len(lines[number]):lines[number]=text return sorted(lines.items()) def parse(directory,game): rows=lines_for(directory,game);heads={};items={};previous='';previous_line=0 source='https://callofduty.fandom.com/wiki/'+('' if game=='ghosts' else 'Challenges/')+TITLES[game] for number,line in rows: line=line.replace('\xa0',' ') heading=re.match(r'^(#{2,6})\s+(.+)',line) if heading: level=len(heading[1]);heads={k:v for k,v in heads.items() if k1000 or re.search(r'\bL\d+:',requirement):continue # Numeric progress only when the requirement starts with an explicit count. count=re.match(r'^(?:Get|Earn|Kill|Win|Destroy|Play|Capture|Complete|Absorb)\s+(\d[\d,]*)\s+',requirement) target=int(count[1].replace(',','')) if count else None category=' / '.join(heads[k] for k in sorted(heads)) if game=='black-ops':category='Multiplayer / '+category name=clean(name).strip('* ') if re.search(r'Image:|\bXP\b',name):continue # merged table cells, not a valid challenge name key=slug(category)+':'+slug(name) if key in items:continue items[key]=dict(key=key,name=name,category=category,requirement=requirement,reward=reward or None,target=target,icon=None,sourceUrl=source+'#'+heads[max(heads)].replace(' ','_')) return list(items.values()) def main(): parser=argparse.ArgumentParser();parser.add_argument('--snapshots',type=Path,required=True);args=parser.parse_args() games={game:parse(args.snapshots,game) for game in TITLES} result=dict(source=dict(name='Call of Duty Wiki contributors',license='CC BY-SA',licenseUrl='https://www.fandom.com/licensing',attribution='https://callofduty.fandom.com/wiki/Call_of_Duty_Wiki:Copyrights',retrieved='2026-09-25',scope='Named challenge rows with explicit requirements. Incomplete and ambiguous source rows are excluded. Source coverage varies; these are not complete game inventories. Numeric targets are recorded only when explicit at the start of a requirement.'),games=games) Path('data/cod-challenges.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n') print({game:len(rows) for game,rows in games.items()}) if __name__=='__main__':main()