commit 6f7fae24f79091d14e2ec25a0512d243b08d6e64 Author: Owen Rummage Date: Sat Jun 27 00:32:45 2026 -0500 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..4708c0d --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# passctl + +`passctl` is a small Go CLI for looking up data on CPU/GPU benchmarks from PassMark's benchmark database. + +## Build + +```sh +go build -buildvcs=false -o passctl . +``` + +Use `-buildvcs=false` when building outside a Git checkout. + +## Usage + +```sh +./passctl --cpu "Ryzen 3600x" +./passctl --gpu "Radeon RX 6650 XT" +``` + +- `--cpu`: search PassMark CPU benchmarks +- `--gpu`: search PassMark GPU benchmarks + +PassMark may return anti-bot or challenge pages from some networks. When that happens, `passctl` exits with a clear error to let you know you have been rate limited. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1a8716a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module passctl + +go 1.26 diff --git a/main.go b/main.go new file mode 100644 index 0000000..4027966 --- /dev/null +++ b/main.go @@ -0,0 +1,524 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "regexp" + "sort" + "strings" + "time" +) + +const ( + kindCPU = "cpu" + kindGPU = "gpu" +) + +type options struct { + kind string + query string +} + +type result struct { + Kind string + Name string + URL string + Stats []stat +} + +type stat struct { + Name string + Value string +} + +type client struct { + http *http.Client +} + +var ( + letterNumberRe = regexp.MustCompile(`([a-z])([0-9])`) + numberLetterRe = regexp.MustCompile(`([0-9])([a-z])`) + nonWordRe = regexp.MustCompile(`[^a-z0-9]+`) + scriptRe = regexp.MustCompile(`(?is)]*>.*?`) + styleRe = regexp.MustCompile(`(?is)]*>.*?`) + tagRe = regexp.MustCompile(`(?s)<[^>]+>`) + tokenReplacer = strings.NewReplacer("+", " plus ", "-", " ", "_", " ", "(", " ", ")", " ", "/", " ") +) + +func main() { + opts, err := parseArgs(os.Args[1:]) + if err != nil { + printUsage(os.Stderr) + exitErr(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + c := &client{http: &http.Client{Timeout: 15 * time.Second}} + res, err := c.passmark(ctx, opts.kind, opts.query) + if err != nil { + exitErr(err) + } + + printResult(os.Stdout, res) +} + +func parseArgs(args []string) (options, error) { + fs := flag.NewFlagSet("passctl", flag.ContinueOnError) + fs.SetOutput(io.Discard) + cpu := fs.Bool("cpu", false, "search PassMark CPUs") + gpu := fs.Bool("gpu", false, "search PassMark GPUs") + longHelp := fs.Bool("help", false, "show help") + shortHelp := fs.Bool("h", false, "show help") + + if err := fs.Parse(args); err != nil { + return options{}, err + } + if *shortHelp || *longHelp { + printUsage(os.Stdout) + os.Exit(0) + } + rest := fs.Args() + if len(rest) != 1 { + return options{}, errors.New("expected exactly one CPU or GPU search query") + } + var opts options + if *cpu && *gpu { + return options{}, errors.New("choose only one target: --cpu or --gpu") + } + if *cpu { + opts.kind = kindCPU + } + if *gpu { + opts.kind = kindGPU + } + if opts.kind == "" { + return options{}, errors.New("missing target: use --cpu or --gpu") + } + opts.query = strings.TrimSpace(rest[0]) + if opts.query == "" { + return options{}, errors.New("query cannot be empty") + } + return opts, nil +} + +func printUsage(w io.Writer) { + fmt.Fprintln(w, "Usage:") + fmt.Fprintln(w, ` passctl --cpu "Ryzen 3600x"`) + fmt.Fprintln(w, ` passctl --gpu "Radeon RX 6650 XT"`) + fmt.Fprintln(w) + fmt.Fprintln(w, "Options:") + fmt.Fprintln(w, " --cpu Search PassMark CPU benchmarks") + fmt.Fprintln(w, " --gpu Search PassMark GPU benchmarks") +} + +func exitErr(err error) { + fmt.Fprintf(os.Stderr, "passctl: %v\n", err) + os.Exit(1) +} + +func printResult(w io.Writer, res result) { + fmt.Fprintf(w, "PassMark %s: %s\n", strings.ToUpper(res.Kind), res.Name) + if res.URL != "" { + fmt.Fprintf(w, "URL: %s\n", res.URL) + } + fmt.Fprintln(w) + width := 0 + for _, s := range res.Stats { + if len(s.Name) > width { + width = len(s.Name) + } + } + for _, s := range res.Stats { + fmt.Fprintf(w, "%-*s %s\n", width, s.Name+":", s.Value) + } +} + +func (c *client) passmark(ctx context.Context, kind, query string) (result, error) { + baseHost := "www.cpubenchmark.net" + listPath := "/cpu-list/all" + detailPaths := []string{"/cpu.php", "/cpu_lookup.php"} + if kind == kindGPU { + baseHost = "www.videocardbenchmark.net" + listPath = "/gpu_list.php" + detailPaths = []string{"/gpu.php", "/gpu_lookup.php", "video_lookup.php", "/video_lookup.php"} + } + + if kind == kindCPU { + directURL := (&url.URL{Scheme: "https", Host: baseHost, Path: "/cpu.php", RawQuery: "cpu=" + url.QueryEscape(query)}).String() + if direct, err := c.get(ctx, directURL); err == nil { + name, stats := parsePassMarkDetail(direct) + if name != "" && len(stats) > 0 { + return result{Kind: kind, Name: name, URL: directURL, Stats: stats}, nil + } + } + } + listURL := (&url.URL{Scheme: "https", Host: baseHost, Path: listPath}).String() + body, err := c.get(ctx, listURL) + if err != nil { + return result{}, err + } + candidates := passmarkCandidates(body, baseHost, detailPaths) + best, ok := bestCandidate(query, candidates) + if !ok { + return result{}, fmt.Errorf("no PassMark %s match found for %q", kind, query) + } + if len(best.Stats) > 0 { + return result{Kind: kind, Name: best.Name, URL: best.URL, Stats: best.Stats}, nil + } + + detail, err := c.get(ctx, best.URL) + if err != nil { + return result{}, err + } + name, stats := parsePassMarkDetail(detail) + if name == "" { + name = best.Name + } + if len(stats) == 0 { + return result{}, fmt.Errorf("PassMark page was fetched but no benchmark stats could be parsed: %s", best.URL) + } + return result{Kind: kind, Name: name, URL: best.URL, Stats: stats}, nil +} + +func (c *client) get(ctx context.Context, rawURL string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 passctl/0.1") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return "", err + } + body := string(data) + if resp.StatusCode >= 400 { + return "", fmt.Errorf("%s returned %s", rawURL, resp.Status) + } + if isBlocked(body) { + return "", fmt.Errorf("%s returned an anti-bot/challenge page; try again later or from a network/browser session the site accepts", rawURL) + } + return body, nil +} + +func isBlocked(body string) bool { + lower := strings.ToLower(body) + return strings.Contains(lower, "403 forbidden") || + strings.TrimSpace(lower) == "forbidden" || + strings.Contains(lower, "just a moment") || + strings.Contains(lower, "enable javascript and cookies") || + strings.Contains(lower, "cf-chl") +} + +type candidate struct { + Name string + URL string + Stats []stat +} + +func passmarkCandidates(body, host string, detailPaths []string) []candidate { + var pathAlternates []string + for _, p := range detailPaths { + pathAlternates = append(pathAlternates, regexp.QuoteMeta(p)) + } + pattern := fmt.Sprintf(`(?is)]*>\s*]*>\s*]+href=["']([^"']*(?:%s)\?[^"']*(?:cpu|gpu)=[^"']*(?:&|&)id=\d+[^"']*)["'][^>]*>(.*?)\s*\s*]*>(.*?)\s*]*>(.*?)\s*]*>(.*?)\s*]*>(.*?)\s*`, strings.Join(pathAlternates, "|")) + re := regexp.MustCompile(pattern) + seen := make(map[string]bool) + var out []candidate + for _, m := range re.FindAllStringSubmatch(body, -1) { + href := htmlUnescape(m[1]) + name := cleanText(stripTags(m[2])) + if name == "" { + if u, err := url.Parse(href); err == nil { + name = u.Query().Get("cpu") + if name == "" { + name = u.Query().Get("gpu") + } + } + } + if strings.HasPrefix(href, "/") { + href = "https://" + host + href + } + if !strings.HasPrefix(href, "http") { + href = "https://" + host + "/" + strings.TrimPrefix(href, "/") + } + key := strings.ToLower(name + "|" + href) + if name != "" && !seen[key] { + seen[key] = true + stats := []stat{ + {Name: "PassMark Score", Value: cleanText(stripTags(m[3]))}, + {Name: "Rank", Value: cleanText(stripTags(m[4]))}, + {Name: "Value", Value: cleanText(stripTags(m[5]))}, + {Name: "Price", Value: cleanText(stripTags(m[6]))}, + } + out = append(out, candidate{Name: name, URL: href, Stats: compactStats(stats)}) + } + } + return out +} + +func compactStats(stats []stat) []stat { + var out []stat + for _, s := range stats { + s.Name = strings.TrimSpace(s.Name) + s.Value = strings.TrimSpace(s.Value) + if s.Name == "" || s.Value == "" { + continue + } + out = append(out, s) + } + return out +} + +func bestCandidate(query string, candidates []candidate) (candidate, bool) { + if len(candidates) == 0 { + return candidate{}, false + } + normalizedQuery := normalizeTokens(query) + type scored struct { + candidate + score int + } + var scoredList []scored + for _, c := range candidates { + score := matchScoreNormalized(normalizedQuery, c.Name) + if score > 0 { + scoredList = append(scoredList, scored{candidate: c, score: score}) + } + } + if len(scoredList) == 0 { + return candidates[0], true + } + sort.SliceStable(scoredList, func(i, j int) bool { + return scoredList[i].score > scoredList[j].score + }) + return scoredList[0].candidate, true +} + +func matchScore(query, name string) int { + return matchScoreNormalized(normalizeTokens(query), name) +} + +func matchScoreNormalized(q, name string) int { + n := normalizeTokens(name) + if q == "" || n == "" { + return 0 + } + if q == n { + return 1000 + } + score := 0 + if strings.Contains(n, q) { + score += 500 + } + for _, tok := range strings.Fields(q) { + if strings.Contains(n, tok) { + score += 50 + len(tok) + } + } + return score +} + +func normalizeTokens(s string) string { + s = strings.ToLower(s) + s = tokenReplacer.Replace(s) + s = letterNumberRe.ReplaceAllString(s, "$1 $2") + s = numberLetterRe.ReplaceAllString(s, "$1 $2") + return strings.Join(strings.Fields(nonWordRe.ReplaceAllString(s, " ")), " ") +} + +func parsePassMarkDetail(body string) (string, []stat) { + name := firstMatch(body, `(?is)]*>(.*?)`) + if name == "" { + name = firstMatch(body, `(?is)]*>(.*?)`) + } + name = strings.TrimSuffix(cleanText(stripTags(name)), " PassMark CPU Mark") + name = strings.TrimSuffix(name, " Videocard Benchmarks") + + var stats []stat + add := func(k, v string) { + k, v = cleanText(stripTags(k)), cleanText(stripTags(v)) + if k == "" || v == "" || len(k) > 80 || len(v) > 200 { + return + } + if skipPassMarkStat(k, v) { + return + } + stats = appendUnique(stats, stat{Name: k, Value: v}) + } + + for _, s := range passmarkSelectedStats(body) { + add(s.Name, s.Value) + } + + directScoreRe := regexp.MustCompile(`(?is)Average\s+(CPU|G3D|G2D)\s+Mark.*?]*>\s*([^<]*Rating)\s*\s*]*>\s*([0-9,]+)\s*`) + for _, m := range directScoreRe.FindAllStringSubmatch(body, -1) { + add("Average "+strings.ToUpper(m[1])+" Mark", m[3]) + } + singleRe := regexp.MustCompile(`(?is)Single\s+Thread\s+Rating\s*\s*]*>\s*([0-9,]+)\s*`) + if m := singleRe.FindStringSubmatch(body); len(m) == 2 { + add("Single Thread Rating", m[1]) + } + + rowRe := regexp.MustCompile(`(?is)]*>\s*]*>(.*?)\s*]*>(.*?)`) + for _, m := range rowRe.FindAllStringSubmatch(body, -1) { + add(m[1], m[2]) + } + + strongRe := regexp.MustCompile(`(?is)<(?:strong|b)[^>]*>([^:<]{2,80}:?)\s*([^<\n]{1,200})`) + for _, m := range strongRe.FindAllStringSubmatch(body, -1) { + add(strings.TrimSuffix(m[1], ":"), m[2]) + } + + return name, stats +} + +func skipPassMarkStat(k, v string) bool { + lk := strings.ToLower(k) + lv := strings.ToLower(v) + switch lk { + case "merchant", "processor", "baseline": + return true + } + if strings.Contains(k, `"`) || strings.Contains(v, `" +`) { + return true + } + if strings.Contains(lk, "detected and enabled") || strings.Contains(lk, "view current prices") { + return true + } + if strings.Contains(lv, "for our website") || strings.Contains(lv, "selected cpu") { + return true + } + return false +} + +func passmarkSelectedStats(body string) []stat { + id := firstMatch(body, `(?is)var\s+(?:cpu|gpu|videocard)DetailsId\s*=\s*["']?(\d+)`) + if id == "" { + id = firstMatch(body, `(?is)["'](?:cpu|gpu|videocard)DetailsId["']\s*:\s*["']?(\d+)`) + } + if id == "" { + return nil + } + + var stats []stat + row := firstMatch(body, `(?is)]+id=["']pk`+regexp.QuoteMeta(id)+`["'][^>]*>(.*?)`) + if row != "" { + if product := cleanText(stripTags(firstMatch(row, `(?is)]+class=["'][^"']*prdname[^"']*["'][^>]*>(.*?)`))); product != "" { + stats = appendUnique(stats, stat{Name: "Matched Product", Value: product}) + } + if score := cleanText(stripTags(firstMatch(row, `(?is)]+class=["'][^"']*mark-neww[^"']*["'][^>]*>(.*?)`))); score != "" { + stats = appendUnique(stats, stat{Name: "PassMark Score", Value: score}) + } + if value := cleanText(stripTags(firstMatch(row, `(?is)]+class=["'][^"']*count[^"']*["'][^>]*>(.*?)`))); value != "" { + stats = appendUnique(stats, stat{Name: "Value", Value: value}) + } + if price := cleanText(stripTags(firstMatch(row, `(?is)]+class=["'][^"']*price-neww[^"']*["'][^>]*>(.*?)`))); price != "" { + stats = appendUnique(stats, stat{Name: "Price", Value: price}) + } + score := cleanText(stripTags(firstMatch(row, `(?is)]+class=["'][^"']*mark-neww[^"']*["'][^>]*>(.*?)`))) + nums := onclickNumbers(row) + if len(nums) >= 3 { + if sameNumber(nums[0], score) { + stats = appendUnique(stats, stat{Name: "Rank", Value: nums[1]}) + stats = appendUnique(stats, stat{Name: "Samples", Value: nums[2]}) + } else { + stats = appendUnique(stats, stat{Name: "Rank", Value: nums[0]}) + stats = appendUnique(stats, stat{Name: "Samples", Value: nums[2]}) + } + } + } + + rankRow := firstMatch(body, `(?is)]+id=["']rk`+regexp.QuoteMeta(id)+`["'][^>]*>(.*?)`) + if rankRow != "" { + if score := cleanText(stripTags(firstMatch(rankRow, `(?is)]+class=["'][^"']*count[^"']*["'][^>]*>(.*?)`))); score != "" { + stats = appendUnique(stats, stat{Name: "PassMark Score", Value: score}) + } + } + return stats +} + +func onclickNumbers(row string) []string { + payload := firstMatch(row, `(?is)onclick=["'][^(]*\((.*?)\)`) + if payload == "" { + return nil + } + re := regexp.MustCompile(`['"]?(-?(?:\d{1,3},)*\d+(?:\.\d+)?)['"]?`) + var nums []string + for _, m := range re.FindAllStringSubmatch(payload, -1) { + nums = append(nums, m[1]) + } + return nums +} + +func sameNumber(a, b string) bool { + clean := func(s string) string { + s = strings.ReplaceAll(s, ",", "") + s = strings.TrimSpace(s) + return strings.TrimSuffix(s, ".0") + } + return clean(a) == clean(b) +} + +func firstMatch(s, pattern string) string { + re := regexp.MustCompile(pattern) + m := re.FindStringSubmatch(s) + if len(m) < 2 { + return "" + } + return m[1] +} + +func stripScripts(s string) string { + s = scriptRe.ReplaceAllString(s, " ") + return styleRe.ReplaceAllString(s, " ") +} + +func stripTags(s string) string { + s = stripScripts(s) + return tagRe.ReplaceAllString(s, " ") +} + +func cleanText(s string) string { + s = htmlUnescape(s) + s = strings.ReplaceAll(s, "\u00a0", " ") + return strings.Join(strings.Fields(s), " ") +} + +func htmlUnescape(s string) string { + repl := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + """, `"`, + "'", "'", + "'", "'", + " ", " ", + ) + return repl.Replace(s) +} + +func appendUnique(stats []stat, next stat) []stat { + key := strings.ToLower(next.Name) + for _, s := range stats { + if strings.ToLower(s.Name) == key { + return stats + } + } + return append(stats, next) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..e4cfcd5 --- /dev/null +++ b/main_test.go @@ -0,0 +1,83 @@ +package main + +import "testing" + +func TestParseArgsCPU(t *testing.T) { + opts, err := parseArgs([]string{"--cpu", "Ryzen 3600x"}) + if err != nil { + t.Fatalf("parseArgs returned error: %v", err) + } + if opts.kind != kindCPU || opts.query != "Ryzen 3600x" { + t.Fatalf("unexpected options: %+v", opts) + } +} + +func TestParseArgsGPU(t *testing.T) { + opts, err := parseArgs([]string{"--gpu", "Radeon RX 6650 XT"}) + if err != nil { + t.Fatalf("parseArgs returned error: %v", err) + } + if opts.kind != kindGPU || opts.query != "Radeon RX 6650 XT" { + t.Fatalf("unexpected options: %+v", opts) + } +} + +func TestPassMarkSelectedStatsCPU(t *testing.T) { + html := ` + +
  • +AMD Ryzen 5 3600X185.518,123$97.71
  • ` + stats := passmarkSelectedStats(html) + want := map[string]string{ + "Matched Product": "AMD Ryzen 5 3600X", + "PassMark Score": "18,123", + "Value": "185.5", + "Price": "$97.71", + "Rank": "1103", + "Samples": "8702", + } + assertStats(t, stats, want) +} + +func TestPassMarkSelectedStatsGPU(t *testing.T) { + html := ` + +
  • +Radeon RX 6650 XT57.017111299.99*
  • ` + stats := passmarkSelectedStats(html) + want := map[string]string{ + "Matched Product": "Radeon RX 6650 XT", + "PassMark Score": "17111", + "Value": "57.0", + "Price": "299.99*", + "Rank": "124", + "Samples": "5093", + } + assertStats(t, stats, want) +} + +func TestBestCandidateSplitsGPUModelNumbers(t *testing.T) { + best, ok := bestCandidate("Radeon RX6650xt", []candidate{ + {Name: "256MB DDR Radeon 9800 XT", URL: "old"}, + {Name: "Radeon RX 6650 XT", URL: "new"}, + }) + if !ok { + t.Fatal("expected a match") + } + if best.URL != "new" { + t.Fatalf("matched %q, want Radeon RX 6650 XT", best.Name) + } +} + +func assertStats(t *testing.T, stats []stat, want map[string]string) { + t.Helper() + got := make(map[string]string) + for _, s := range stats { + got[s.Name] = s.Value + } + for k, v := range want { + if got[k] != v { + t.Fatalf("stat %q = %q, want %q; all stats: %+v", k, got[k], v, stats) + } + } +} diff --git a/passctl b/passctl new file mode 100755 index 0000000..3e076dc Binary files /dev/null and b/passctl differ