1+ #!/usr/bin/env python3
2+ """check_build.py — Update data/win_builds.json from Microsoft release health pages.
3+
4+ Scrapes the official Microsoft documentation to discover new Windows build
5+ numbers and adds them to the local data/win_builds.json dictionary, organized
6+ by OS family (Windows 10, Windows 11, Windows Server XXXX, etc.).
7+
8+ Sources:
9+ - Windows 10: https://learn.microsoft.com/en-us/windows/release-health/release-information
10+ - Windows 11: https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information
11+ - Windows Server: https://learn.microsoft.com/en-us/windows/release-health/windows-server-release-info
12+
13+ Usage:
14+ python check_build.py # update data/win_builds.json
15+ python check_build.py --dry-run # show what would change, don't write
16+ python check_build.py --wipe-clients # delete data/known_clients.json (reset IP cache)
17+ """
18+
19+ import json
20+ import os
21+ import re
22+ import datetime
23+ import argparse
24+ from urllib .request import urlopen , Request
25+ from urllib .error import URLError
26+
27+ # ---------------------------------------------------------------------------
28+ # Configuration
29+ # ---------------------------------------------------------------------------
30+
31+ _SCRIPT_DIR = os .path .dirname (os .path .abspath (__file__ ))
32+ _DATA_DIR = os .path .join (_SCRIPT_DIR , "data" )
33+ _BUILDS_JSON = os .path .join (_DATA_DIR , "win_builds.json" )
34+ _CLIENTS_JSON = os .path .join (_DATA_DIR , "known_clients.json" )
35+
36+ # Client sources: scrape "Version XXX (OS build NNNNN)" patterns
37+ CLIENT_SOURCES = [
38+ {
39+ "url" : "https://learn.microsoft.com/en-us/windows/release-health/release-information" ,
40+ "section" : "Windows 10" ,
41+ },
42+ {
43+ "url" : "https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information" ,
44+ "section" : "Windows 11" ,
45+ },
46+ ]
47+
48+ # Server source: scrape "Windows Server XXXX (OS build NNNNN)" headers
49+ SERVER_SOURCE = {
50+ "url" : "https://learn.microsoft.com/en-us/windows/release-health/windows-server-release-info" ,
51+ }
52+
53+ HEADERS = {
54+ "User-Agent" : "check_build/1.0 (pywsus; build dictionary updater)" ,
55+ "Accept" : "text/html" ,
56+ }
57+
58+ # ---------------------------------------------------------------------------
59+ # Scraping
60+ # ---------------------------------------------------------------------------
61+
62+ def fetch_page (url ):
63+ """Fetch a page and return its HTML as a string."""
64+ req = Request (url , headers = HEADERS )
65+ try :
66+ with urlopen (req , timeout = 15 ) as resp :
67+ return resp .read ().decode ("utf-8" , errors = "replace" )
68+ except URLError as e :
69+ print (f" [!] Failed to fetch { url } : { e } " )
70+ return ""
71+
72+
73+ def extract_client_builds (html_content ):
74+ """Extract { base_build: version_label } from a Win10/Win11 release health page.
75+
76+ Looks for patterns like 'Version 22H2 (OS build 19045)'.
77+ """
78+ results = {}
79+
80+ # Pattern 1: "Version 22H2 (OS build 19045)" style headers
81+ for m in re .finditer (
82+ r'Version\s+([\w.]+)\s*\(OS\s+build\s+(\d{5,})\)' ,
83+ html_content , re .IGNORECASE
84+ ):
85+ results [m .group (2 )] = m .group (1 )
86+
87+ # Pattern 2: table rows with version + build pairs
88+ version_pat = re .compile (
89+ r'<td[^>]*>\s*(?:Version\s+)?(1[5-9]\d{2}|2[0-9]H[12]|2[0-9]{3})\s*</td>' ,
90+ re .IGNORECASE
91+ )
92+ build_pat = re .compile (r'<td[^>]*>\s*(\d{5,})(?:\.\d+)?\s*</td>' )
93+
94+ for row in re .finditer (r'<tr[^>]*>(.*?)</tr>' , html_content , re .DOTALL ):
95+ row_html = row .group (1 )
96+ ver_m = version_pat .search (row_html )
97+ bld_m = build_pat .search (row_html )
98+ if ver_m and bld_m :
99+ build = bld_m .group (1 )
100+ if build not in results :
101+ results [build ] = ver_m .group (1 )
102+
103+ return results
104+
105+
106+ def extract_server_builds (html_content ):
107+ """Extract { section_name: { base_build: version } } from the Server page.
108+
109+ Looks for detail headers like 'Windows Server 2025 (OS build 26100)'
110+ and main table rows like 'Windows Server 2019 (version 1809)'.
111+ """
112+ results = {} # { "Windows Server 2025": { "26100": "24H2" }, ... }
113+
114+ # Step 1: extract section → base_build from detail headers
115+ # Pattern: "Windows Server XXXX (OS build NNNNN)"
116+ section_builds = {}
117+ for m in re .finditer (
118+ r'Windows\s+Server\s+(\d{4})\s*\(OS\s+build\s+(\d{5,})\)' ,
119+ html_content , re .IGNORECASE
120+ ):
121+ name = f"Windows Server { m .group (1 )} "
122+ base_build = m .group (2 )
123+ section_builds [name ] = base_build
124+
125+ # Step 2: extract version from main table
126+ # Pattern: "Windows Server XXXX (version YYYY)" or just "Windows Server XXXX"
127+ version_map = {}
128+ for m in re .finditer (
129+ r'Windows\s+Server\s+(\d{4})\s*\(version\s+([\w.]+)\)' ,
130+ html_content , re .IGNORECASE
131+ ):
132+ version_map [f"Windows Server { m .group (1 )} " ] = m .group (2 )
133+
134+ # Step 3: for servers without explicit version, try to derive it
135+ # Server 2025 = build 26100 = "24H2", Server 2022 = build 20348 = "21H2"
136+ _KNOWN_SERVER_VERSIONS = {
137+ "Windows Server 2025" : "24H2" ,
138+ "Windows Server 2022" : "21H2" ,
139+ }
140+
141+ # Combine
142+ for name , base_build in section_builds .items ():
143+ version = version_map .get (name , _KNOWN_SERVER_VERSIONS .get (name , "" ))
144+ if name not in results :
145+ results [name ] = {}
146+ if version :
147+ results [name ][base_build ] = version
148+
149+ return results
150+
151+
152+ # ---------------------------------------------------------------------------
153+ # JSON management
154+ # ---------------------------------------------------------------------------
155+
156+ def load_json ():
157+ """Load the existing data/win_builds.json or create a skeleton."""
158+ if os .path .exists (_BUILDS_JSON ):
159+ with open (_BUILDS_JSON , "r" , encoding = "utf-8" ) as f :
160+ return json .load (f )
161+ return {
162+ "_comment" : "OS family → { build_number: version }. Updated by check_build.py" ,
163+ "_updated" : "" ,
164+ "_sources" : [s ["url" ] for s in CLIENT_SOURCES ] + [SERVER_SOURCE ["url" ]],
165+ }
166+
167+
168+ def save_json (data ):
169+ """Write the updated JSON file with sorted builds per section."""
170+ data ["_updated" ] = datetime .date .today ().isoformat ()
171+ for key , val in data .items ():
172+ if key .startswith ("_" ) or not isinstance (val , dict ):
173+ continue
174+ data [key ] = dict (sorted (val .items (), key = lambda x : int (x [0 ])))
175+ os .makedirs (_DATA_DIR , exist_ok = True )
176+ with open (_BUILDS_JSON , "w" , encoding = "utf-8" ) as f :
177+ json .dump (data , f , indent = 4 , ensure_ascii = False )
178+ f .write ("\n " )
179+
180+
181+ # ---------------------------------------------------------------------------
182+ # Main
183+ # ---------------------------------------------------------------------------
184+
185+ def main ():
186+ parser = argparse .ArgumentParser (
187+ description = "Update data/win_builds.json from Microsoft release health pages." ,
188+ epilog = (
189+ "Examples:\n "
190+ " python check_build.py # fetch latest builds\n "
191+ " python check_build.py --dry-run # preview without saving\n "
192+ " python check_build.py --wipe-clients # reset known client IPs\n "
193+ ),
194+ formatter_class = argparse .RawDescriptionHelpFormatter ,
195+ )
196+ parser .add_argument ("--dry-run" , action = "store_true" ,
197+ help = "Show changes without writing to disk" )
198+ parser .add_argument ("--wipe-clients" , action = "store_true" ,
199+ help = "Reset data/known_clients.json (clear all known client IPs)" )
200+ args = parser .parse_args ()
201+
202+ # --- Wipe clients if requested ---
203+ if args .wipe_clients :
204+ os .makedirs (_DATA_DIR , exist_ok = True )
205+ empty = {"_comment" : "Known WSUS clients — { ip: {build, arch, os_desc} }. Auto-populated by pywsus." }
206+ with open (_CLIENTS_JSON , "w" , encoding = "utf-8" ) as f :
207+ json .dump (empty , f , indent = 2 , ensure_ascii = False )
208+ f .write ("\n " )
209+ print (f"[*] Cleared { _CLIENTS_JSON } " )
210+ return
211+
212+ data = load_json ()
213+ added = {}
214+
215+ # Count existing
216+ existing_count = sum (len (v ) for k , v in data .items ()
217+ if not k .startswith ("_" ) and isinstance (v , dict ))
218+ print (f"[*] Current dictionary: { existing_count } builds in { _BUILDS_JSON } " )
219+ print ()
220+
221+ # --- Client builds (Windows 10, Windows 11) ---
222+ for source in CLIENT_SOURCES :
223+ url = source ["url" ]
224+ section = source ["section" ]
225+
226+ print (f"[*] Fetching { section } release info..." )
227+ html_content = fetch_page (url )
228+ if not html_content :
229+ continue
230+
231+ discovered = extract_client_builds (html_content )
232+ print (f" Found { len (discovered )} build(s) on page" )
233+
234+ existing = data .setdefault (section , {})
235+ for build , version in discovered .items ():
236+ if build not in existing :
237+ added [f"{ section } /{ build } " ] = version
238+ existing [build ] = version
239+ print (f" [+] NEW: { section } build { build } → version { version } " )
240+ elif existing [build ] != version :
241+ print (f" [~] UPDATE: { section } build { build } : "
242+ f"{ existing [build ]} → { version } " )
243+ existing [build ] = version
244+ added [f"{ section } /{ build } " ] = version
245+
246+ # --- Server builds ---
247+ print (f"[*] Fetching Windows Server release info..." )
248+ html_content = fetch_page (SERVER_SOURCE ["url" ])
249+ if html_content :
250+ server_results = extract_server_builds (html_content )
251+ total_server = sum (len (v ) for v in server_results .values ())
252+ print (f" Found { total_server } build(s) across { len (server_results )} server edition(s)" )
253+
254+ for section , builds in server_results .items ():
255+ existing = data .setdefault (section , {})
256+ for build , version in builds .items ():
257+ if build not in existing :
258+ added [f"{ section } /{ build } " ] = version
259+ existing [build ] = version
260+ print (f" [+] NEW: { section } build { build } → version { version } " )
261+ elif existing [build ] != version :
262+ print (f" [~] UPDATE: { section } build { build } : "
263+ f"{ existing [build ]} → { version } " )
264+ existing [build ] = version
265+ added [f"{ section } /{ build } " ] = version
266+
267+ print ()
268+
269+ if not added :
270+ print ("[*] Dictionary is already up to date, no new builds found." )
271+ else :
272+ print (f"[+] { len (added )} new/updated build(s):" )
273+ for key , version in sorted (added .items ()):
274+ print (f" { key } → { version } " )
275+
276+ if added and not args .dry_run :
277+ save_json (data )
278+ print (f"\n [*] Saved to { _BUILDS_JSON } " )
279+ elif added and args .dry_run :
280+ print (f"\n [*] Dry run — changes NOT saved" )
281+
282+ total = sum (len (v ) for k , v in data .items ()
283+ if not k .startswith ("_" ) and isinstance (v , dict ))
284+ families = sum (1 for k in data if not k .startswith ("_" ) and isinstance (data [k ], dict ))
285+ print (f"\n [*] Total: { total } builds / { families } OS families" )
286+
287+
288+ if __name__ == "__main__" :
289+ main ()
0 commit comments