diff --git a/README.md b/README.md
index e88c0fc..69874f0 100644
--- a/README.md
+++ b/README.md
@@ -285,8 +285,8 @@ Returns real-time cache statistics for the running instance. Auth via `?secret=`
|---|---|---|
| Windows 11 25H2 | 3262 | x64 |
| Windows 11 25H2 | 3265 | ARM64 |
-| Windows 11 25H2 (updated) | 3321 | x64 |
-| Windows 11 25H2 (updated) | 3324 | ARM64 |
+| Windows 11 25H2 (V2) | 3321 | x64 |
+| Windows 11 25H2 (V2) | 3324 | ARM64 |
| Windows 11 24H2 | 3113 | x64 |
| Windows 11 24H2 | 3131 | ARM64 |
| Windows 10 22H2 | 2618 | x64 / x86 |
diff --git a/backend/main.go b/backend/main.go
index d2ea1b4..c544f57 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -479,7 +479,8 @@ var validContributeProducts = map[string]bool{
"3131": true, "3132": true, "3133": true,
"3262": true, "3263": true, "3264": true,
"3265": true, "3266": true, "3267": true,
- "3321": true, "3324": true,
+ "3321": true, "3322": true, "3323": true,
+ "3324": true, "3325": true, "3326": true,
}
// allowedCDNSuffixes is the CDN host allow-list for contributed download URLs.
diff --git a/cli/catalog.go b/cli/catalog.go
index ffa8188..5263ff1 100644
--- a/cli/catalog.go
+++ b/cli/catalog.go
@@ -29,8 +29,12 @@ var consumerProducts = []Product{
{"3265", "Windows 11 Arm64 25H2 (26200.6584)"},
{"3266", "Windows 11 Arm64 25H2 Home China (26200.6584)"},
{"3267", "Windows 11 Arm64 25H2 Pro China (26200.6584)"},
- {"3321", "Windows 11 25H2 (Updated Oct)"},
- {"3324", "Windows 11 Arm64 25H2 (Updated Oct)"},
+ {"3321", "Windows 11 25H2 (V2)"},
+ {"3322", "Windows 11 25H2 Home China (V2)"},
+ {"3323", "Windows 11 25H2 Pro China (V2)"},
+ {"3324", "Windows 11 Arm64 25H2 (V2)"},
+ {"3325", "Windows 11 Arm64 25H2 Home China (V2)"},
+ {"3326", "Windows 11 Arm64 25H2 Pro China (V2)"},
}
var evalProducts = []EvalProduct{
diff --git a/frontend/public/data/products.json b/frontend/public/data/products.json
index ee9f298..e4b9c13 100644
--- a/frontend/public/data/products.json
+++ b/frontend/public/data/products.json
@@ -169,7 +169,7 @@
]
},
"3321": {
- "name": "Windows 11 25H2 (Updated Oct)",
+ "name": "Windows 11 25H2 (V2)",
"badge": "LATEST",
"archs": [
"x64"
@@ -180,8 +180,30 @@
"3113"
]
},
+ "3322": {
+ "name": "Windows 11 25H2 Home China (V2)",
+ "badge": "LATEST",
+ "archs": [
+ "x64"
+ ],
+ "related": [
+ "3321",
+ "3323"
+ ]
+ },
+ "3323": {
+ "name": "Windows 11 25H2 Pro China (V2)",
+ "badge": "LATEST",
+ "archs": [
+ "x64"
+ ],
+ "related": [
+ "3321",
+ "3322"
+ ]
+ },
"3324": {
- "name": "Windows 11 Arm64 25H2 (Updated Oct)",
+ "name": "Windows 11 Arm64 25H2 (V2)",
"badge": "LATEST",
"archs": [
"ARM64"
@@ -191,5 +213,27 @@
"3262",
"3265"
]
+ },
+ "3325": {
+ "name": "Windows 11 Arm64 25H2 Home China (V2)",
+ "badge": "LATEST",
+ "archs": [
+ "ARM64"
+ ],
+ "related": [
+ "3324",
+ "3326"
+ ]
+ },
+ "3326": {
+ "name": "Windows 11 Arm64 25H2 Pro China (V2)",
+ "badge": "LATEST",
+ "archs": [
+ "ARM64"
+ ],
+ "related": [
+ "3324",
+ "3325"
+ ]
}
}
\ No newline at end of file
diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml
index c7a6ecb..b4d34be 100644
--- a/frontend/public/sitemap.xml
+++ b/frontend/public/sitemap.xml
@@ -63,5 +63,9 @@
https://msdl.tech-latest.com/product/32662026-05-120.8monthlyhttps://msdl.tech-latest.com/product/32672026-05-120.8monthlyhttps://msdl.tech-latest.com/product/33212026-05-120.8monthly
+ https://msdl.tech-latest.com/product/33222026-07-140.8monthly
+ https://msdl.tech-latest.com/product/33232026-07-140.8monthlyhttps://msdl.tech-latest.com/product/33242026-05-120.8monthly
+ https://msdl.tech-latest.com/product/33252026-07-140.8monthly
+ https://msdl.tech-latest.com/product/33262026-07-140.8monthly
diff --git a/scripts/check-new-releases/README.md b/scripts/check-new-releases/README.md
new file mode 100644
index 0000000..de4fdd2
--- /dev/null
+++ b/scripts/check-new-releases/README.md
@@ -0,0 +1,91 @@
+# check-new-releases
+
+Two complementary tools for finding Windows product edition IDs that exist on
+Microsoft's side but aren't yet in `cli/catalog.go` / `products.json` /
+`validContributeProducts`. Neither touches those files automatically --
+naming a new entry correctly (build number, refresh qualifiers like "V2",
+etc.) still needs a human checking Microsoft's own release-health pages.
+Both only *discover* candidates.
+
+Only Windows 11 is worth checking at all: Windows 8.1 is fully frozen (no
+updates since Jan 2023) and Windows 10 is past end-of-life (ESU security
+patches only, delivered via Windows Update, not new consumer ISOs). Neither
+will ever produce a new release again.
+
+## Method 1 -- `msdls_v3.py` (brute-force range scan)
+
+Checks every ID in a range you specify, one by one, against Microsoft's
+SKU-info API. Use this when you already have a rough idea of where to look
+(e.g. "the last release was around 3260, check up to 3350") or want to
+directly merge results into a JSON catalog file.
+
+```bash
+pip install requests
+python msdls_v3.py --first 3320 --last 3330
+```
+
+Optionally write results to a JSON file (merges with an existing one if
+present, preserving fields like `badge`/`archs`/`related`, marking IDs that
+stopped responding as `"active": false`):
+
+```bash
+python msdls_v3.py --first 3320 --last 3330 --write scan-results.json
+```
+
+Don't point `--write` directly at `frontend/public/data/products.json` --
+write to a scratch file first and review before merging into the real
+catalog.
+
+**How it identifies a release:** reads `ProductDisplayName` from the first
+SKU in the API response (e.g. `"Windows 11 25H2__V2"`). This field name was
+wrong in an earlier version of this script (checked `EditionName`/
+`ReleaseName`/`FriendlyName`, none of which exist in the real response) --
+confirmed live and fixed 2026-07-14.
+
+**Session handling:** registers the session via `vlscppe`'s permit endpoint
+before scanning. A single SKU lookup was confirmed to work without this, but
+this script's use case -- many rapid consecutive requests across a range --
+is a much higher-risk pattern for triggering a Sentinel block than a
+one-shot lookup, so the permit call is worth keeping. The CLI/backend's full
+session flow additionally replays an `ov-df.microsoft.com` fingerprint; add
+that too if the permit call alone isn't enough under real scanning load.
+
+## Method 2 -- `main.go` (auto-discovery)
+
+Scrapes Microsoft's public `/windows11` download page for the *current*
+flagship product edition ID (no range-guessing needed), compares it against
+`cli/catalog.go`, and if it's new, probes a bounded range of adjacent IDs to
+discover the accompanying variant family (Home China, Pro China, ARM64).
+Use this when you just want to know "is there anything new" without knowing
+where to look.
+
+```bash
+cd scripts/check-new-releases
+go run .
+```
+
+If a new release is found, it makes real requests to Microsoft to probe the
+variant family -- let it finish rather than re-running immediately.
+
+Flags:
+- `-catalog ` — path to `cli/catalog.go` (default: `../../cli/catalog.go`)
+- `-probe-range ` — how many adjacent IDs to check after a new flagship is found (default: 20)
+
+**Known limitation:** the ARM64 family's offset from the flagship isn't
+consistent release to release (observed: +3 for the 25H2 family, +18 for
+24H2's). The default probe range of 20 covers both known cases, but if no
+ARM64 variant shows up, try a wider `-probe-range`.
+
+## Which one to use
+
+| | Method 1 (Python) | Method 2 (Go) |
+|---|---|---|
+| Need to guess a starting range? | Yes | No -- finds the current flagship automatically |
+| Gets the real Microsoft name? | Yes (`ProductDisplayName`) | Yes (`ProductDisplayName`) |
+| Can write results to a catalog-shaped JSON? | Yes (`--write`) | No -- prints a report only |
+| Best for | "I want to scan a specific range and get a mergeable file" | "Just tell me if anything's new" |
+
+Both were verified end-to-end against live Microsoft endpoints on
+2026-07-14, which surfaced a real gap: product IDs `3322`/`3323`/`3325`/`3326`
+(Home/Pro China variants of the 25H2 "V2" refresh) existed on Microsoft's
+side but weren't yet in the catalog. Since fixed.
diff --git a/scripts/check-new-releases/go.mod b/scripts/check-new-releases/go.mod
new file mode 100644
index 0000000..da35d70
--- /dev/null
+++ b/scripts/check-new-releases/go.mod
@@ -0,0 +1,3 @@
+module check-new-releases
+
+go 1.25.6
diff --git a/scripts/check-new-releases/main.go b/scripts/check-new-releases/main.go
new file mode 100644
index 0000000..977a789
--- /dev/null
+++ b/scripts/check-new-releases/main.go
@@ -0,0 +1,321 @@
+// check-new-releases scrapes Microsoft's public Windows 11 download page for
+// the current "flagship" product edition ID and compares it against the
+// catalog already tracked in cli/catalog.go. Windows 8.1 and Windows 10 are
+// intentionally not checked here -- 8.1 is fully frozen and Windows 10 is
+// past end-of-life, so neither will ever produce a new consumer ISO release.
+//
+// If a new flagship ID is found, it probes a bounded range of adjacent IDs to
+// discover the accompanying variant family (Home China, Pro China, ARM64,
+// ...), since Microsoft allocates those in a cluster near the flagship ID
+// but the exact offset isn't consistent release to release (observed gaps:
+// +3 for the 25H2 family, +18 for 24H2's ARM64 family).
+//
+// This tool only *discovers* candidate IDs -- it does not touch cli/catalog.go,
+// products.json, or validContributeProducts automatically. Naming a new entry
+// correctly (build number, "Updated Oct"-style qualifiers, etc.) still needs a
+// human checking Microsoft's own release-health pages.
+//
+// Usage: go run . [-catalog path/to/cli/catalog.go] [-probe-range N]
+package main
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "regexp"
+ "strconv"
+ "sync"
+ "time"
+)
+
+// --- Copied from cli/microsoft.go (not imported -- cli/ is package main and
+// can't be imported from elsewhere). This is a standalone maintenance tool,
+// not part of the shipped CLI/backend; keep in sync manually if the real
+// session flow ever changes. ---
+
+const (
+ msUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ msProfile = "606624d44113"
+ msLocale = "en-US"
+ msOrgID = "y6jn8c31"
+ msCustomer = "560dc9f3-1aa5-4a2f-b63c-9e18f8d0e175"
+)
+
+var (
+ reW = regexp.MustCompile(`[&?]w=([^&"'\s]+)`)
+ reRt = regexp.MustCompile(`rticks[="]+\+?\s*(\d{10,})`)
+)
+
+type skuLang struct {
+ ID string `json:"Id"`
+ Language string `json:"Language"`
+ ProductDisplayName string `json:"ProductDisplayName"`
+}
+
+type simpleCookieJar struct {
+ mu sync.Mutex
+ cookies []*http.Cookie
+}
+
+func (j *simpleCookieJar) SetCookies(_ *url.URL, cookies []*http.Cookie) {
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ for _, c := range cookies {
+ found := false
+ for i, existing := range j.cookies {
+ if existing.Name == c.Name {
+ j.cookies[i] = c
+ found = true
+ break
+ }
+ }
+ if !found {
+ j.cookies = append(j.cookies, c)
+ }
+ }
+}
+
+func (j *simpleCookieJar) Cookies(_ *url.URL) []*http.Cookie {
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ out := make([]*http.Cookie, len(j.cookies))
+ copy(out, j.cookies)
+ return out
+}
+
+func newSessionID() string {
+ b := make([]byte, 16)
+ rand.Read(b)
+ b[6] = (b[6] & 0x0f) | 0x40
+ b[8] = (b[8] & 0x3f) | 0x80
+ return fmt.Sprintf("%s-%s-%s-%s-%s",
+ hex.EncodeToString(b[0:4]), hex.EncodeToString(b[4:6]),
+ hex.EncodeToString(b[6:8]), hex.EncodeToString(b[8:10]),
+ hex.EncodeToString(b[10:16]))
+}
+
+func newSession() (*http.Client, string) {
+ sessionID := newSessionID()
+ jar := &simpleCookieJar{}
+ client := &http.Client{Timeout: 15 * time.Second, Jar: jar}
+
+ q1 := url.Values{}
+ q1.Set("org_id", msOrgID)
+ q1.Set("session_id", sessionID)
+ req1, _ := http.NewRequest("GET", "https://vlscppe.microsoft.com/tags?"+q1.Encode(), nil)
+ req1.Header.Set("User-Agent", msUA)
+ client.Do(req1)
+
+ q2 := url.Values{}
+ q2.Set("instanceId", msCustomer)
+ q2.Set("PageId", "si")
+ q2.Set("session_id", sessionID)
+ req2, _ := http.NewRequest("GET", "https://ov-df.microsoft.com/mdt.js?"+q2.Encode(), nil)
+ req2.Header.Set("User-Agent", msUA)
+ resp2, err := client.Do(req2)
+ if err != nil {
+ return client, sessionID
+ }
+ body, _ := io.ReadAll(resp2.Body)
+ resp2.Body.Close()
+
+ wMatch := reW.FindStringSubmatch(string(body))
+ rtMatch := reRt.FindStringSubmatch(string(body))
+ if len(wMatch) > 1 && len(rtMatch) > 1 {
+ q3 := url.Values{}
+ q3.Set("session_id", sessionID)
+ q3.Set("CustomerId", msCustomer)
+ q3.Set("PageId", "si")
+ q3.Set("w", wMatch[1])
+ q3.Set("mdt", fmt.Sprintf("%d", time.Now().UnixMilli()))
+ q3.Set("rticks", rtMatch[1])
+ req3, _ := http.NewRequest("GET", "https://ov-df.microsoft.com/?"+q3.Encode(), nil)
+ req3.Header.Set("User-Agent", msUA)
+ client.Do(req3)
+ }
+ return client, sessionID
+}
+
+type msErrorEntry struct {
+ Type float64 `json:"Type"`
+ Value string `json:"Value"`
+}
+
+func firstError(errs []msErrorEntry) string {
+ if len(errs) == 0 {
+ return ""
+ }
+ if int(errs[0].Type) == 9 {
+ if errs[0].Value != "" {
+ return errs[0].Value
+ }
+ return "Your IP has been temporarily blocked by Microsoft (Code 715-123130)"
+ }
+ if errs[0].Value != "" {
+ return errs[0].Value
+ }
+ return "Microsoft API error"
+}
+
+func fetchLanguages(client *http.Client, sessionID, productID string) ([]skuLang, error) {
+ q := url.Values{}
+ q.Set("profile", msProfile)
+ q.Set("productEditionId", productID)
+ q.Set("SKU", "undefined")
+ q.Set("friendlyFileName", "undefined")
+ q.Set("Locale", msLocale)
+ q.Set("sessionID", sessionID)
+
+ req, _ := http.NewRequest("GET", "https://www.microsoft.com/software-download-connector/api/getskuinformationbyproductedition?"+q.Encode(), nil)
+ req.Header.Set("User-Agent", msUA)
+ req.Header.Set("Referer", "https://www.microsoft.com/en-us/software-download/windows11")
+ req.Header.Set("Accept", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("Microsoft returned HTTP %d", resp.StatusCode)
+ }
+ if len(body) > 0 && body[0] == '"' {
+ var unquoted string
+ json.Unmarshal(body, &unquoted)
+ body = []byte(unquoted)
+ }
+
+ var data struct {
+ Skus []skuLang `json:"Skus"`
+ Errors []msErrorEntry `json:"Errors"`
+ }
+ if err := json.Unmarshal(body, &data); err != nil {
+ return nil, fmt.Errorf("invalid SKU response: %w", err)
+ }
+ if msg := firstError(data.Errors); msg != "" {
+ return nil, fmt.Errorf("%s", msg)
+ }
+ if len(data.Skus) == 0 {
+ return nil, fmt.Errorf("no languages found for this product")
+ }
+ return data.Skus, nil
+}
+
+// --- New logic specific to this tool ---
+
+var catalogEntryRe = regexp.MustCompile(`\{"(\d+)",\s*"([^"]*)"\}`)
+
+func knownCatalogIDs(catalogPath string) (map[string]string, error) {
+ data, err := os.ReadFile(catalogPath)
+ if err != nil {
+ return nil, err
+ }
+ matches := catalogEntryRe.FindAllStringSubmatch(string(data), -1)
+ out := make(map[string]string, len(matches))
+ for _, m := range matches {
+ out[m[1]] = m[2]
+ }
+ return out, nil
+}
+
+var flagshipRe = regexp.MustCompile(`