import json
import os
import re
import sys
import time
from pathlib import Path
from functools import partial
print = partial(print, flush=True)
try:
from firecrawl import FirecrawlApp
except ImportError:
print("Install: pip install firecrawl-py")
sys.exit(1)
API_KEY = os.environ.get("FIRECRAWL_API_KEY", "fc-69796415b7bb45238b965b246023b34d")
PASSPORT_CODES = [
"ae","af","ag","al","am","ao","ar","at","au","az","ba","bb","bd","be","bf",
"bg","bh","bi","bj","bn","bo","br","bs","bt","bw","by","bz","ca","cd","cf",
"cg","ch","ci","cl","cm","cn","co","cr","cu","cv","cy","cz","de","dj","dk",
"dm","do","dz","ec","ee","eg","er","es","et","fi","fj","fm","fr","ga","gb",
"gd","ge","gh","gm","gn","gq","gr","gt","gw","gy","hk","hn","hr","ht","hu",
"id","ie","il","in","iq","ir","is","it","jm","jo","jp","ke","kg","kh","ki",
"km","kn","kp","kr","kw","kz","la","lb","lc","li","lk","lr","ls","lt","lu",
"lv","ly","ma","mc","md","me","mg","mh","mk","ml","mm","mn","mo","mr","mt",
"mu","mv","mw","mx","my","mz","na","ne","ng","ni","nl","no","np","nr","nz",
"om","pa","pe","pg","ph","pk","pl","ps","pt","pw","py","qa","ro","rs","ru",
"rw","sa","sb","sc","sd","se","sg","si","sk","sl","sm","sn","so","sr","ss",
"st","sv","sy","sz","td","tg","th","tj","tl","tm","tn","to","tr","tt","tv",
"tw","tz","ua","ug","us","uy","uz","va","vc","ve","vn","vu","ws","xk","ye",
"za","zm","zw",
]
CODE_TO_SLUG = {
"ae": "united-arab-emirates", "ag": "antigua-and-barbuda",
"ba": "bosnia-and-herzegovina", "cd": "dr-congo",
"cf": "central-african-republic", "ci": "ivory-coast",
"cv": "cape-verde", "cz": "czech-republic",
"do": "dominican-republic", "fm": "micronesia",
"gb": "united-kingdom", "gq": "equatorial-guinea",
"gw": "guinea-bissau", "hk": "hong-kong",
"kn": "saint-kitts-and-nevis", "kp": "north-korea",
"kr": "south-korea", "lc": "saint-lucia",
"mh": "marshall-islands", "mk": "north-macedonia",
"mo": "macao", "pg": "papua-new-guinea",
"ps": "palestinian-territories", "ru": "russian-federation",
"sa": "saudi-arabia", "sb": "solomon-islands",
"sl": "sierra-leone", "ss": "south-sudan",
"st": "sao-tome-and-principe", "sv": "el-salvador",
"sz": "swaziland", "tl": "timor-leste",
"tr": "turkiye", "tt": "trinidad-and-tobago",
"us": "united-states", "vc": "saint-vincent-and-the-grenadines",
"vn": "viet-nam", "ws": "samoa",
"xk": "kosovo", "za": "south-africa",
"cr": "costa-rica", "nz": "new-zealand",
"bf": "burkina-faso", "lk": "sri-lanka",
}
JUNK_WORDS = [
'AVERAGE', 'MEDIAN', 'POPULATION', 'BECOME', 'EMPOWERED',
'NEWSLETTER', 'VISA CHECKER', 'EXPLORE', 'RANK', 'COMPARE',
'IMPROVE', 'DISCOVER', 'MOBILITY', 'PASSPORT POWER',
'COUNTRY', 'VISA REQUIREMENTS', 'WORLD REACH', 'APPLY NOW',
'PASSPORT DASHBOARD', 'INDIVIDUAL POWER', 'ยฉ',
]
def parse_visa_markdown(markdown: str) -> list[dict]:
results = []
lines = markdown.split('\n')
in_visa_section = False
for line in lines:
line = line.strip().strip('|').strip()
if not line:
continue
if 'visa requirements' in line.lower() or 'country' in line.lower() and 'visa' in line.lower():
in_visa_section = True
continue
if not in_visa_section:
if not re.search(r'VISA[- ]FREE|VISA ON ARRIVAL|VISA REQUIRED|EVISA|ETA\b', line, re.IGNORECASE):
continue
upper = line.upper()
if any(j in upper for j in JUNK_WORDS):
continue
if line.startswith('---') or line.startswith('|--'):
continue
parts = [p.strip() for p in line.split('|') if p.strip()]
if len(parts) < 2:
parts = line.split('\t')
if len(parts) < 2:
parts = re.split(r'\s{2,}', line)
if len(parts) < 2:
m = re.match(r'^(.+?)\s+(VISA[- ]FREE|VISA ON ARRIVAL|VISA REQUIRED|EVISA|ETA|EVISITOR|PRE-ENROLLMENT|TOURIST|E-TICKET|NO ADMISSION)(.*)$', line, re.IGNORECASE)
if m:
parts = [m.group(1), m.group(2) + m.group(3)]
else:
continue
country = parts[0].strip()
country = re.sub(r'\[([^\]]+)\]\([^)]*\)', r'\1', country)
country = re.sub(r'^\[?\!?\[?[^\]]*\]\([^)]*\)\s*', '', country)
country = re.sub(r'^\*+|\*+$', '', country).strip()
country = re.sub(r'^[\U0001F1E6-\U0001F1FF]+\s*', '', country) country = re.sub(r'^\d+[\.\s]+', '', country).strip()
if len(country) < 2 or len(country) > 60:
continue
if any(j in country.upper() for j in JUNK_WORDS):
continue
visa_part = ' '.join(parts[1:]).strip().upper()
access_type = ''
days = None
if 'VISA-FREE' in visa_part or 'VISA FREE' in visa_part:
access_type = 'visa-free'
elif 'VISA ON ARRIVAL' in visa_part or 'VOA' in visa_part:
access_type = 'visa-on-arrival'
elif 'EVISITOR' in visa_part:
access_type = 'eta'
elif 'EVISA' in visa_part or 'E-VISA' in visa_part:
access_type = 'e-visa'
elif re.match(r'^ETA\b', visa_part):
access_type = 'eta'
elif 'PRE-ENROLLMENT' in visa_part:
access_type = 'e-visa'
elif 'TOURIST REGISTRATION' in visa_part:
access_type = 'visa-free'
elif 'TOURIST CARD' in visa_part:
access_type = 'visa-free'
elif 'E-TICKET' in visa_part:
access_type = 'visa-free'
elif 'VISA REQUIRED' in visa_part:
access_type = 'visa-required'
elif 'NO ADMISSION' in visa_part:
access_type = 'no-admission'
if not access_type:
continue
days_match = re.search(r'(\d+)', visa_part)
if days_match:
d = int(days_match.group(1))
if 0 < d <= 360:
days = d
results.append({
'dest_code': '',
'dest_name': country,
'category': {'visa-free': 'vf', 'visa-on-arrival': 'voa',
'eta': 'eta', 'e-visa': 'eta',
'visa-required': 'vr', 'no-admission': 'vr',
}.get(access_type, 'unknown'),
'detail': access_type,
'days': days,
})
return results
def main():
output_file = Path("passport_matrix.json")
checkpoint_file = Path("passport_matrix_checkpoint.json")
app = FirecrawlApp(api_key=API_KEY)
matrix = {}
if checkpoint_file.exists():
with open(checkpoint_file) as f:
matrix = json.load(f)
print(f"Loaded checkpoint: {len(matrix)} passports")
remaining = [c for c in PASSPORT_CODES if c not in matrix]
if not remaining:
print("All done!")
save_output(matrix, output_file)
return
print(f"Scraping {len(remaining)} passports via Firecrawl...\n")
for i, code in enumerate(remaining, 1):
slug = CODE_TO_SLUG.get(code, code)
url = f"https://www.passportindex.org/passport/{slug}/"
try:
r = app.scrape(url, formats=['markdown'], wait_for=5000)
markdown = r.markdown or '' if hasattr(r, 'markdown') else ''
entries = parse_visa_markdown(markdown)
if entries and len(entries) > 10:
matrix[code] = entries
n_vf = sum(1 for e in entries if e['category'] == 'vf')
n_voa = sum(1 for e in entries if e['category'] == 'voa')
n_eta = sum(1 for e in entries if e['category'] == 'eta')
n_vr = sum(1 for e in entries if e['category'] == 'vr')
print(f"[{len(matrix)}/{len(PASSPORT_CODES)}] {code} ({slug}): "
f"{len(entries)} dest (vf:{n_vf} voa:{n_voa} eta:{n_eta} vr:{n_vr})")
elif entries:
matrix[code] = entries
print(f"[{len(matrix)}/{len(PASSPORT_CODES)}] {code} ({slug}): "
f"WARNING only {len(entries)} entries")
else:
print(f"[{i}] {code} ({slug}): EMPTY โ no visa data parsed")
if len(matrix) % 10 == 0 and len(matrix) > 0:
with open(checkpoint_file, "w") as f:
json.dump(matrix, f, ensure_ascii=False)
time.sleep(1.2)
except Exception as e:
print(f"[{i}] {code} ({slug}): ERROR โ {e}")
if matrix:
with open(checkpoint_file, "w") as f:
json.dump(matrix, f, ensure_ascii=False)
time.sleep(3)
save_output(matrix, output_file)
if checkpoint_file.exists():
checkpoint_file.unlink()
def save_output(matrix: dict, output_file: Path):
with open(output_file, "w") as f:
json.dump(matrix, f, indent=2, ensure_ascii=False)
total = sum(len(v) for v in matrix.values())
print(f"\nSaved: {output_file}")
print(f"Passports: {len(matrix)}, Total pairs: {total}")
if __name__ == "__main__":
main()