feat: add check-new-releases tools; fix msdls_v3.py; add missing 25H2 V2 China variants
Moves msdls_v3.py from repo root into scripts/check-new-releases/, alongside a new Go tool, documented as Method 1 (Python brute-force range scan, can write results to a catalog-shaped JSON) and Method 2 (Go auto-discovery via page-scrape + bounded adjacent-ID probe, no range-guessing needed). Only Windows 11 is checked -- 8.1 is fully frozen and Windows 10 is past end-of-life, so neither will produce a new consumer ISO again. Fixed msdls_v3.py's release-name extraction: it checked EditionName/ ReleaseName/FriendlyName, none of which exist in the real API response -- the actual field is ProductDisplayName, confirmed live. Also added the vlscppe session-permit call to setup_session(), which was skipped entirely; higher-risk gap for this script's brute-force range-scan usage pattern than for a single lookup. Both tools' live runs surfaced a real catalog gap: product IDs 3322/3323/3325/3326 (Home/Pro China variants of the 25H2 "V2" refresh) exist on Microsoft's side but weren't in our catalog. Added them to cli/catalog.go, products.json, validContributeProducts, and sitemap.xml, and renamed 3321/3324 from our own guessed "(Updated Oct)" label to Microsoft's actual name for this refresh, "(V2)" (their internal name is "Windows 11 25H2__V2"; cleaned up the double-underscore for user-facing display). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f67fe84348
commit
da8787e93a
9 changed files with 445 additions and 12 deletions
|
|
@ -285,8 +285,8 @@ Returns real-time cache statistics for the running instance. Auth via `?secret=`
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Windows 11 25H2 | 3262 | x64 |
|
| Windows 11 25H2 | 3262 | x64 |
|
||||||
| Windows 11 25H2 | 3265 | ARM64 |
|
| Windows 11 25H2 | 3265 | ARM64 |
|
||||||
| Windows 11 25H2 (updated) | 3321 | x64 |
|
| Windows 11 25H2 (V2) | 3321 | x64 |
|
||||||
| Windows 11 25H2 (updated) | 3324 | ARM64 |
|
| Windows 11 25H2 (V2) | 3324 | ARM64 |
|
||||||
| Windows 11 24H2 | 3113 | x64 |
|
| Windows 11 24H2 | 3113 | x64 |
|
||||||
| Windows 11 24H2 | 3131 | ARM64 |
|
| Windows 11 24H2 | 3131 | ARM64 |
|
||||||
| Windows 10 22H2 | 2618 | x64 / x86 |
|
| Windows 10 22H2 | 2618 | x64 / x86 |
|
||||||
|
|
|
||||||
|
|
@ -479,7 +479,8 @@ var validContributeProducts = map[string]bool{
|
||||||
"3131": true, "3132": true, "3133": true,
|
"3131": true, "3132": true, "3133": true,
|
||||||
"3262": true, "3263": true, "3264": true,
|
"3262": true, "3263": true, "3264": true,
|
||||||
"3265": true, "3266": true, "3267": 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.
|
// allowedCDNSuffixes is the CDN host allow-list for contributed download URLs.
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,12 @@ var consumerProducts = []Product{
|
||||||
{"3265", "Windows 11 Arm64 25H2 (26200.6584)"},
|
{"3265", "Windows 11 Arm64 25H2 (26200.6584)"},
|
||||||
{"3266", "Windows 11 Arm64 25H2 Home China (26200.6584)"},
|
{"3266", "Windows 11 Arm64 25H2 Home China (26200.6584)"},
|
||||||
{"3267", "Windows 11 Arm64 25H2 Pro China (26200.6584)"},
|
{"3267", "Windows 11 Arm64 25H2 Pro China (26200.6584)"},
|
||||||
{"3321", "Windows 11 25H2 (Updated Oct)"},
|
{"3321", "Windows 11 25H2 (V2)"},
|
||||||
{"3324", "Windows 11 Arm64 25H2 (Updated Oct)"},
|
{"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{
|
var evalProducts = []EvalProduct{
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"3321": {
|
"3321": {
|
||||||
"name": "Windows 11 25H2 (Updated Oct)",
|
"name": "Windows 11 25H2 (V2)",
|
||||||
"badge": "LATEST",
|
"badge": "LATEST",
|
||||||
"archs": [
|
"archs": [
|
||||||
"x64"
|
"x64"
|
||||||
|
|
@ -180,8 +180,30 @@
|
||||||
"3113"
|
"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": {
|
"3324": {
|
||||||
"name": "Windows 11 Arm64 25H2 (Updated Oct)",
|
"name": "Windows 11 Arm64 25H2 (V2)",
|
||||||
"badge": "LATEST",
|
"badge": "LATEST",
|
||||||
"archs": [
|
"archs": [
|
||||||
"ARM64"
|
"ARM64"
|
||||||
|
|
@ -191,5 +213,27 @@
|
||||||
"3262",
|
"3262",
|
||||||
"3265"
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -63,5 +63,9 @@
|
||||||
<url><loc>https://msdl.tech-latest.com/product/3266</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
<url><loc>https://msdl.tech-latest.com/product/3266</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||||
<url><loc>https://msdl.tech-latest.com/product/3267</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
<url><loc>https://msdl.tech-latest.com/product/3267</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||||
<url><loc>https://msdl.tech-latest.com/product/3321</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
<url><loc>https://msdl.tech-latest.com/product/3321</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||||
|
<url><loc>https://msdl.tech-latest.com/product/3322</loc><lastmod>2026-07-14</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||||
|
<url><loc>https://msdl.tech-latest.com/product/3323</loc><lastmod>2026-07-14</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||||
<url><loc>https://msdl.tech-latest.com/product/3324</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
<url><loc>https://msdl.tech-latest.com/product/3324</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||||
|
<url><loc>https://msdl.tech-latest.com/product/3325</loc><lastmod>2026-07-14</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||||
|
<url><loc>https://msdl.tech-latest.com/product/3326</loc><lastmod>2026-07-14</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||||
</urlset>
|
</urlset>
|
||||||
|
|
|
||||||
35
scripts/check-new-releases/README.md
Normal file
35
scripts/check-new-releases/README.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# check-new-releases
|
||||||
|
|
||||||
|
Checks Microsoft's public Windows 11 download page for a new "flagship" product
|
||||||
|
edition ID not yet in `cli/catalog.go`, and if found, probes nearby IDs to
|
||||||
|
discover the accompanying variant family (Home China, Pro China, ARM64, ...).
|
||||||
|
|
||||||
|
Windows 8.1 and Windows 10 aren't checked -- 8.1 is fully frozen and Windows 10
|
||||||
|
is past end-of-life, so neither will ever produce a new consumer ISO release.
|
||||||
|
Windows 11 is the only OS still shipping feature updates.
|
||||||
|
|
||||||
|
This tool only *discovers* candidate IDs. It doesn't touch `cli/catalog.go`,
|
||||||
|
`products.json`, or `validContributeProducts` automatically -- naming a new
|
||||||
|
entry correctly (build number, "Updated Oct"-style qualifiers, etc.) needs a
|
||||||
|
human checking Microsoft's own release-health pages.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```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>` — path to `cli/catalog.go` (default: `../../cli/catalog.go`)
|
||||||
|
- `-probe-range <n>` — 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`.
|
||||||
3
scripts/check-new-releases/go.mod
Normal file
3
scripts/check-new-releases/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module check-new-releases
|
||||||
|
|
||||||
|
go 1.25.6
|
||||||
321
scripts/check-new-releases/main.go
Normal file
321
scripts/check-new-releases/main.go
Normal file
|
|
@ -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(`<option value="(\d+)">Windows`)
|
||||||
|
|
||||||
|
func scrapeFlagshipID(pageURL string) (string, error) {
|
||||||
|
req, _ := http.NewRequest("GET", pageURL, nil)
|
||||||
|
req.Header.Set("User-Agent", msUA)
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fetching download page: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
m := flagshipRe.FindStringSubmatch(string(body))
|
||||||
|
if len(m) < 2 {
|
||||||
|
return "", fmt.Errorf("could not find a product edition ID on the page -- Microsoft may have changed the page layout")
|
||||||
|
}
|
||||||
|
return m[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
catalogPath := flag.String("catalog", "../../cli/catalog.go", "path to cli/catalog.go")
|
||||||
|
probeRange := flag.Int("probe-range", 20, "how many adjacent IDs to probe after a new flagship is found")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
const windows11Page = "https://www.microsoft.com/en-us/software-download/windows11"
|
||||||
|
fmt.Printf("Checking %s for the current flagship product edition ID...\n", windows11Page)
|
||||||
|
|
||||||
|
flagshipID, err := scrapeFlagshipID(windows11Page)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Flagship ID found: %s\n", flagshipID)
|
||||||
|
|
||||||
|
known, err := knownCatalogIDs(*catalogPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error reading catalog:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Loaded %d known product IDs from %s\n\n", len(known), *catalogPath)
|
||||||
|
|
||||||
|
if name, ok := known[flagshipID]; ok {
|
||||||
|
fmt.Printf("Up to date -- flagship ID %s is already in the catalog as %q\n", flagshipID, name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("NEW RELEASE DETECTED -- product edition ID %s is not in the current catalog.\n\n", flagshipID)
|
||||||
|
fmt.Println("Probing adjacent IDs to discover the variant family (this makes real")
|
||||||
|
fmt.Println("requests to Microsoft -- allow it to finish rather than re-running):")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
client, sessionID := newSession()
|
||||||
|
idNum, err := strconv.Atoi(flagshipID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error: flagship ID is not numeric:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-8s %-8s %s\n", "ID", "STATUS", "NOTES")
|
||||||
|
for offset := 0; offset <= *probeRange; offset++ {
|
||||||
|
candidate := strconv.Itoa(idNum + offset)
|
||||||
|
langs, err := fetchLanguages(client, sessionID, candidate)
|
||||||
|
time.Sleep(300 * time.Millisecond) // be a good citizen -- don't hammer Microsoft
|
||||||
|
if err != nil {
|
||||||
|
continue // not a valid product ID; skip silently, only report hits
|
||||||
|
}
|
||||||
|
realName := ""
|
||||||
|
if len(langs) > 0 {
|
||||||
|
realName = langs[0].ProductDisplayName
|
||||||
|
}
|
||||||
|
note := fmt.Sprintf("%q -- %d language(s)", realName, len(langs))
|
||||||
|
switch {
|
||||||
|
case len(langs) == 1:
|
||||||
|
note += " -- likely a China-only single-edition variant"
|
||||||
|
case len(langs) > 30:
|
||||||
|
note += " -- likely a worldwide multi-edition variant"
|
||||||
|
}
|
||||||
|
if existingName, ok := known[candidate]; ok {
|
||||||
|
note += fmt.Sprintf(" (already in catalog as %q)", existingName)
|
||||||
|
}
|
||||||
|
fmt.Printf("%-8s %-8s %s\n", candidate, "FOUND", note)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Note: the ARM64 family's offset from the flagship isn't consistent release")
|
||||||
|
fmt.Println("to release (seen +3 for 25H2, +18 for 24H2) -- if no ARM64 variant showed up")
|
||||||
|
fmt.Println("above, try a wider -probe-range.")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Next steps: confirm the build number from Microsoft's release-health pages,")
|
||||||
|
fmt.Println("then add entries to cli/catalog.go, frontend/public/data/products.json,")
|
||||||
|
fmt.Println("and the validContributeProducts map in backend/main.go.")
|
||||||
|
}
|
||||||
|
|
@ -12,8 +12,27 @@ LOCALE = "en-US"
|
||||||
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
def setup_session():
|
def setup_session():
|
||||||
"""Initializes a session with Microsoft tracking servers."""
|
"""Initializes a session with Microsoft tracking servers.
|
||||||
|
|
||||||
|
Registers the session_id via vlscppe's "permit" endpoint before use --
|
||||||
|
confirmed live that getskuinformationbyproductedition still returns valid
|
||||||
|
data without this for a single lookup, but this script's brute-force
|
||||||
|
range scan makes many rapid consecutive requests, which is a much
|
||||||
|
higher-risk pattern for triggering a Sentinel block than the single-shot
|
||||||
|
case that was tested. The CLI/backend's full session flow additionally
|
||||||
|
replays an ov-df.microsoft.com fingerprint; add that too if this alone
|
||||||
|
turns out not to be enough under real scanning load.
|
||||||
|
"""
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
|
try:
|
||||||
|
requests.get(
|
||||||
|
"https://vlscppe.microsoft.com/tags",
|
||||||
|
params={"org_id": "y6jn8c31", "session_id": session_id},
|
||||||
|
headers={"User-Agent": UA},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"Session permit call failed (continuing anyway): {e}")
|
||||||
return session_id
|
return session_id
|
||||||
|
|
||||||
def get_product(product_id, session_id):
|
def get_product(product_id, session_id):
|
||||||
|
|
@ -66,13 +85,15 @@ def scan_id(product_id):
|
||||||
# MS usually puts the release name in the first SKU
|
# MS usually puts the release name in the first SKU
|
||||||
skus = data.get("Skus", [])
|
skus = data.get("Skus", [])
|
||||||
if skus:
|
if skus:
|
||||||
# Check multiple possible name keys
|
|
||||||
s = skus[0]
|
s = skus[0]
|
||||||
# ReleaseName is often "Windows 10 Version 22H2 (Updated Oct 2025)"
|
# Confirmed live 2026-07-14: the real field is "ProductDisplayName"
|
||||||
name = s.get("EditionName") or s.get("ReleaseName") or s.get("FriendlyName")
|
# (e.g. "Windows 11 25H2"), not EditionName/ReleaseName/FriendlyName --
|
||||||
|
# those don't exist in the actual response and always fell through to
|
||||||
|
# the generic fallback below, silently.
|
||||||
|
name = s.get("ProductDisplayName") or s.get("LocalizedProductDisplayName")
|
||||||
if name:
|
if name:
|
||||||
return name
|
return name
|
||||||
|
|
||||||
return f"Windows Product {product_id}"
|
return f"Windows Product {product_id}"
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Loading…
Add table
Reference in a new issue