#!/usr/bin/env python3 """ update_mac_vendors Downloads the latest MAC vendor database from maclookup.app and regenerates the vendor-to-device-type mapping used by the backend for auto device classification. Outputs (relative to the script's own directory): mac-vendors-export.json - raw vendor database (replaces existing) vendor-device-type.json - vendor name -> device type mapping (non-unknown only) """ import argparse import html.parser import json import os import re import sys import urllib.request DOWNLOAD_PAGE = "https://maclookup.app/downloads/json-database" BASE_URL = "https://maclookup.app" SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) VENDORS_FILE = os.path.join(SCRIPT_DIR, "mac-vendors-export.json") MAPPING_FILE = os.path.join(SCRIPT_DIR, "vendor-device-type.json") LLM_MODEL = "claude-haiku-4-5" LLM_BATCH_SIZE = 100 VALID_DEVICE_TYPES = frozenset([ "phone", "laptop", "tablet", "server", "tv", "printer", "network_appliance", "home_security", "home_appliance", "watch", "pc", "gaming_console", "unknown", ]) LLM_SYSTEM_PROMPT = """You are a network device classifier. Given a list of MAC address vendor names, classify each one into exactly one device type. Device types: - phone: smartphones and mobile phones (Apple, Samsung, Xiaomi, etc.) - laptop: laptops and notebook computers (Dell, Lenovo, Acer, ASUS, etc.) - tablet: tablets and e-readers (Amazon Fire, iPad, etc.) - server: rack servers, NAS, data center equipment (Supermicro, Synology, QNAP, etc.) - tv: smart TVs, set-top boxes, streaming devices (Roku, Vizio, Hisense, etc.) - printer: printers, scanners, copiers, plotters, label printers - network_appliance: routers, switches, firewalls, access points, modems, gateways (Cisco, Ubiquiti, etc.) - home_security: security cameras, doorbells, surveillance equipment (Hikvision, Dahua, etc.) - home_appliance: household appliances (refrigerators, washers, ovens, dishwashers, etc.) - watch: smartwatches and fitness trackers (Garmin, Fitbit, etc.) - pc: desktop computers and workstations (Intel NUC, Gigabyte, custom builds) - gaming_console: video game consoles (Nintendo, Sony PlayStation, Xbox, etc.) - unknown: cannot be clearly identified as any of the above Respond ONLY with a valid JSON object mapping each vendor name to its device type. No explanation, no markdown fences, just raw JSON.""" # ── HTML parser to extract the download link ────────────────────────────────── class _DownloadLinkParser(html.parser.HTMLParser): def __init__(self): super().__init__() self.download_href = None def handle_starttag(self, tag, attrs): if tag == "a" and self.download_href is None: attrs_dict = dict(attrs) href = attrs_dict.get("href", "") if "/downloads/json-database/get-db" in href: self.download_href = href _HEADERS = { "User-Agent": ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0 Safari/537.36" ) } def _get_download_url() -> str: print(f"Fetching download page: {DOWNLOAD_PAGE}") req = urllib.request.Request(DOWNLOAD_PAGE, headers=_HEADERS) with urllib.request.urlopen(req, timeout=30) as resp: html_body = resp.read().decode("utf-8", errors="replace") parser = _DownloadLinkParser() parser.feed(html_body) if not parser.download_href: print("ERROR: Could not find download link on the page.", file=sys.stderr) sys.exit(1) href = parser.download_href if href.startswith("http"): return href return BASE_URL + href # ── Phase 1: download vendor database ──────────────────────────────────────── def download_vendors(): url = _get_download_url() print(f"Downloading vendor database from: {url}") req = urllib.request.Request(url, headers=_HEADERS) with urllib.request.urlopen(req, timeout=60) as resp: body = resp.read() # Validate it is parseable JSON before overwriting try: records = json.loads(body) except json.JSONDecodeError as exc: print(f"ERROR: Downloaded data is not valid JSON: {exc}", file=sys.stderr) sys.exit(1) with open(VENDORS_FILE, "wb") as f: f.write(body) print(f"Saved {len(records)} vendor records to {VENDORS_FILE}") return records # ── Phase 2: classify vendors and generate mapping ─────────────────────────── # Brand rules: (compiled_regex, device_type) # Evaluated in order; first match wins. # Use word boundaries (\b) to avoid substring false-positives. _BRAND_RULES_RAW = [ # ── watch ── ("watch", r"\b(garmin|fitbit|fossil group|withings|polar electro|suunto|amazfit|huami)\b"), # ── home_appliance ── ("home_appliance", r"\b(whirlpool|electrolux|miele|ge appliances|bosch (home|thermotechnology|siemens home))\b"), # ── home_security ── ("home_security", r"\b(hikvision|prama hikvision|dahua|avigilon|hanwha|vivotek|mobotix|pelco|" r"axis communications|amcrest|reolink|wyze|arlo|geovision|milestone systems|" r"bosch security|dvtel|video insight)\b"), ("home_security", r"\bblink (services|by amazon)\b"), ("home_security", r"\bring (video doorbell|video|llc|inc\.?)\b"), # ── printer ── ("printer", r"\b(canon|epson|brother industries|brother, brother|lexmark|ricoh|kyocera|" r"konica minolta|xerox|bixolon|printronix|zebra technologies|datamax|oki data|" r"toshiba tec)\b"), # ── tv ── ("tv", r"\b(roku|tivo|vizio|skyworth|hisense|vestel)\b"), # TCL Communications is a phone maker; other TCL entries are TVs ("tv", r"\btcl\b(?!.*communications)"), # ── server ── ("server", r"\b(supermicro|hewlett packard enterprise|hp enterprise|dell emc|" r"sun microsystems|oracle|netapp|synology|qnap|proxmox)\b"), ("server", r"\bibm\b"), # ── tablet ── ("tablet", r"\bamazon (technologies|\.com|robotics)\b"), # ── gaming_console ── ("gaming_console", r"\bnintendo\b"), ("gaming_console", r"\bsony (computer entertainment|interactive entertainment)\b"), # ── phone ── ("phone", r"samsung electro"), # SAMSUNG ELECTRONICS, SAMSUNG ELECTRO-MECHANICS ("phone", r"\b(xiaomi|miui)\b"), ("phone", r"\boppo\b"), ("phone", r"\b(vivo mobile|bbk electronics)\b"), ("phone", r"\boneplus\b"), ("phone", r"\b(htc corporation|htc corp\.?)\b"), ("phone", r"\bblackberry\b"), ("phone", r"\b(motorola mobility|motorola, inc)\b"), ("phone", r"\blg electronics\b"), ("phone", r"\bmicrosoft mobile\b"), ("phone", r"\bgoogle,?\s*inc\.?\b"), # Pixel phones ("phone", r"\btcl communication\b"), ("phone", r"\b(huawei device|huawei technologies co)\b"), ("phone", r"\bhmd global\b"), # Nokia phones ("phone", r"\b(realme|honor device|honor co\.|honor terminal)\b"), ("phone", r"\bapple,?\s*inc\.?\b"), # iPhone is the most commonly detected Apple device # ── laptop ── ("laptop", r"\bdell (inc\.?|computer|technologies)\b(?!.*emc)"), ("laptop", r"\blenovo\b"), ("laptop", r"\bacer\b"), ("laptop", r"\b(asustek|asus)\b"), ("laptop", r"\bmsi\b"), ("laptop", r"\bfujitsu\b"), ("laptop", r"\btoshiba\b(?!.*tec)"), ("laptop", r"\bnec (corporation|computers|personal|corp\.?)\b"), ("laptop", r"\brazer inc\.?\b"), ("laptop", r"\bmicrosoft (corporation|corp\.?)\b"), # Surface devices # ── pc ── ("pc", r"\bintel (corporation|corp\.?|, inc\.?)\b"), ("pc", r"\bgigabyte technology\b"), # ── network_appliance ── ("network_appliance", r"\b(cisco|meraki)\b"), ("network_appliance", r"\bjuniper\b"), ("network_appliance", r"\baruba\b"), ("network_appliance", r"\b(ubiquiti|ubnt)\b"), ("network_appliance", r"\b(mikrotik|routerboard)\b"), ("network_appliance", r"\bfortinet\b"), ("network_appliance", r"\bsonicwall\b"), ("network_appliance", r"\barista networks\b"), ("network_appliance", r"\bnetgear\b"), ("network_appliance", r"\btp.?link\b"), ("network_appliance", r"\bd.?link\b"), ("network_appliance", r"\bzyxel\b"), ("network_appliance", r"\blinksys\b"), ("network_appliance", r"\belkin,?\s*inc\.?\b"), ("network_appliance", r"\bruckus\b"), ("network_appliance", r"\b(aerohive|aero hive)\b"), ("network_appliance", r"\bcradlepoint\b"), ("network_appliance", r"\b(peplink|pepwave)\b"), ("network_appliance", r"\bcambium networks\b"), ("network_appliance", r"\bengenius\b"), ("network_appliance", r"\bwatchguard\b"), ("network_appliance", r"\bbarracuda networks\b"), ("network_appliance", r"\b(check point|checkpoint)\b"), ("network_appliance", r"\bpalo alto networks\b"), ("network_appliance", r"\bextreme networks\b"), ("network_appliance", r"\bedgecore\b"), ("network_appliance", r"\badtran\b"), ("network_appliance", r"\bcalix\b"), ("network_appliance", r"\bciena\b"), ("network_appliance", r"\bopengear\b"), ("network_appliance", r"\b2wire\b"), ("network_appliance", r"\bactiontec\b"), ("network_appliance", r"\btenda (technology|network)\b"), ("network_appliance", r"\b(buffalo\.?inc|melco holdings)\b"), ("network_appliance", r"\bhuawei\b"), # primarily networking gear in enterprise ("network_appliance", r"\bzte corporation\b"), ("network_appliance", r"\bnokia\b"), ("network_appliance", r"\bericsson\b"), ("network_appliance", r"\bat&t\b"), ("network_appliance", r"\badva optical\b"), ("network_appliance", r"\beero\b"), ("network_appliance", r"\bsagecom\b"), ("network_appliance", r"\bcomtrend\b"), ] # Keyword fallback rules: (compiled_regex, device_type) _KEYWORD_RULES_RAW = [ ("watch", r"\b(smartwatch|fitness tracker|wearable)\b"), ("home_appliance", r"\b(refrigerator|dishwasher|home appliance|oven|microwave)\b"), ("home_appliance", r"\b(washer|dryer)\b(?!.*hair)"), # exclude hair dryer edge cases ("home_security", r"\b(cctv|ip.?camera|surveillance camera|doorbell camera|ptz camera|security camera|surveillance)\b"), ("printer", r"\b(printer|scanner|copier|plotter|label print|receipt print)\b"), ("tv", r"\b(smart tv|television|set.?top.?box|iptv|streaming device|media player|streaming)\b"), ("server", r"\b(rack server|blade server|data center|storage|nas)\b"), ("tablet", r"\b(tablet|e.?reader|kindle)\b"), ("gaming_console", r"\b(gaming console|game console)\b"), ("phone", r"\b(smartphone|mobile phone|mobile handset|cell phone|handset|cellular)\b"), ("laptop", r"\b(laptop|notebook computer)\b"), ("pc", r"\b(desktop computer|personal computer)\b"), ("network_appliance", r"\b(wireless access point|wifi access point|wlan controller|network switch|" r"ethernet switch|managed switch|wifi router|wireless router|network router|" r"firewall|vpn concentrator|broadband router|cable modem|dsl modem|" r"fiber gateway|optical network terminal|broadband|wifi|wlan|modem)\b"), ] _BRAND_RULES = [ (re.compile(pat, re.IGNORECASE), dt) for dt, pat in _BRAND_RULES_RAW ] _KEYWORD_RULES = [ (re.compile(pat, re.IGNORECASE), dt) for dt, pat in _KEYWORD_RULES_RAW ] def _classify(vendor_name: str) -> str: for pattern, device_type in _BRAND_RULES: if pattern.search(vendor_name): return device_type for pattern, device_type in _KEYWORD_RULES: if pattern.search(vendor_name): return device_type return "unknown" def generate_mapping(records: list) -> dict: vendors = sorted({r["vendorName"] for r in records if "vendorName" in r}) print(f"Classifying {len(vendors)} unique vendor names...") mapping = {} counts: dict[str, int] = {} for vendor in vendors: dt = _classify(vendor) counts[dt] = counts.get(dt, 0) + 1 if dt != "unknown": mapping[vendor] = dt print("Classification summary:") for dt, n in sorted(counts.items(), key=lambda x: -x[1]): print(f" {dt}: {n}") print(f"Classified (non-unknown) entries: {len(mapping)}") return mapping def save_mapping(mapping: dict): with open(MAPPING_FILE, "w", encoding="utf-8") as f: json.dump(mapping, f, indent=2, ensure_ascii=False, sort_keys=True) print(f"Saved mapping to {MAPPING_FILE}") # ── Phase 3: LLM classification for unknown vendors ────────────────────────── def _extract_json(text: str) -> str: """Strip markdown code fences if present, return raw JSON string.""" text = text.strip() if text.startswith("```"): lines = text.splitlines() # drop opening fence (```json or ```) and closing fence inner = [] for line in lines[1:]: if line.strip() == "```": break inner.append(line) return "\n".join(inner) return text def classify_unknowns_with_llm(unknown_vendors: list[str], api_key: str) -> dict: try: import anthropic except ImportError: print( "ERROR: 'anthropic' package not found. Install it with: pip install anthropic", file=sys.stderr, ) sys.exit(1) client = anthropic.Anthropic(api_key=api_key) result: dict[str, str] = {} total = len(unknown_vendors) batches = [ unknown_vendors[i : i + LLM_BATCH_SIZE] for i in range(0, total, LLM_BATCH_SIZE) ] print(f"Sending {total} vendors to LLM in {len(batches)} batches (model: {LLM_MODEL})") for idx, batch in enumerate(batches, 1): print(f" Batch {idx}/{len(batches)} ({len(batch)} vendors)...", end=" ", flush=True) vendor_list = json.dumps(batch, ensure_ascii=False) message = client.messages.create( model=LLM_MODEL, max_tokens=4096, system=LLM_SYSTEM_PROMPT, messages=[ { "role": "user", "content": f"Classify these vendor names:\n{vendor_list}", } ], ) raw = message.content[0].text try: classifications = json.loads(_extract_json(raw)) except json.JSONDecodeError as exc: print(f"WARNING: could not parse LLM response for batch {idx}: {exc}", file=sys.stderr) continue accepted = 0 for vendor, device_type in classifications.items(): if device_type in VALID_DEVICE_TYPES and device_type != "unknown": result[vendor] = device_type accepted += 1 print(f"{accepted} classified") return result # ── Entry point ─────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description="Download the MAC vendor database and generate a vendor→device-type mapping." ) parser.add_argument( "--skip-download", action="store_true", help="Skip downloading the vendor database (use existing mac-vendors-export.json).", ) parser.add_argument( "--llm", action="store_true", help="Use an LLM to classify vendors not matched by algorithmic rules.", ) args = parser.parse_args() if args.skip_download: print("=== Phase 1: Skipped (using existing vendor database) ===") with open(VENDORS_FILE, encoding="utf-8") as f: records = json.load(f) print(f"Loaded {len(records)} vendor records from {VENDORS_FILE}") else: print("=== Phase 1: Download vendor database ===") records = download_vendors() print() print("=== Phase 2: Generate device-type mapping ===") mapping = generate_mapping(records) if args.llm: print() print("=== Phase 3: LLM classification of unmatched vendors ===") api_key = os.environ.get("ANTHROPIC_API_KEY", "") if not api_key: import getpass api_key = getpass.getpass("Anthropic API key: ").strip() if not api_key: print("ERROR: No API key provided.", file=sys.stderr) sys.exit(1) all_vendors = sorted({r["vendorName"] for r in records if "vendorName" in r}) unknown_vendors = [v for v in all_vendors if v not in mapping] print(f"Vendors not yet classified: {len(unknown_vendors)}") llm_mapping = classify_unknowns_with_llm(unknown_vendors, api_key) print(f"LLM classified {len(llm_mapping)} additional vendors") mapping.update(llm_mapping) save_mapping(mapping) print() print("Done. Remember to recompile the backend to pick up the updated vendor database.") if __name__ == "__main__": main()