From 531bb537695e313199d2b6a39307fdd557c33ced Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sat, 27 Jun 2026 05:28:43 -0500 Subject: [PATCH] updates --- README.md | 4 + main.go | 524 +++++++++++++++++++++++++++++++++++++++++++++++++-- main_test.go | 117 +++++++++++- 3 files changed, 631 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4708c0d..7e964c1 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,14 @@ Use `-buildvcs=false` when building outside a Git checkout. ```sh ./passctl --cpu "Ryzen 3600x" +./passctl --cpu "Ryzen 3600x" "Core i7-10700" +./passctl --cpu "Ryzen 3600x" "Core i7-10700" "Core i5-12400" ./passctl --gpu "Radeon RX 6650 XT" +./passctl --gpu "Radeon RX 6650 XT" "GeForce RTX 3060" ``` - `--cpu`: search PassMark CPU benchmarks - `--gpu`: search PassMark GPU benchmarks +- pass two or more CPU or GPU queries to print a color-coded comparison table 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/main.go b/main.go index 4027966..57b6a0f 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "os" "regexp" "sort" + "strconv" "strings" "time" ) @@ -21,8 +22,8 @@ const ( ) type options struct { - kind string - query string + kind string + queries []string } type result struct { @@ -45,12 +46,21 @@ var ( letterNumberRe = regexp.MustCompile(`([a-z])([0-9])`) numberLetterRe = regexp.MustCompile(`([0-9])([a-z])`) nonWordRe = regexp.MustCompile(`[^a-z0-9]+`) + ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m`) scriptRe = regexp.MustCompile(`(?is)]*>.*?`) styleRe = regexp.MustCompile(`(?is)]*>.*?`) tagRe = regexp.MustCompile(`(?s)<[^>]+>`) tokenReplacer = strings.NewReplacer("+", " plus ", "-", " ", "_", " ", "(", " ", ")", " ", "/", " ") ) +const ( + colorReset = "\x1b[0m" + colorBold = "\x1b[1m" + colorDim = "\x1b[2m" + colorGreen = "\x1b[32m" + colorRed = "\x1b[31m" +) + func main() { opts, err := parseArgs(os.Args[1:]) if err != nil { @@ -62,12 +72,21 @@ func main() { 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) + results := make([]result, 0, len(opts.queries)) + for _, query := range opts.queries { + res, err := c.passmark(ctx, opts.kind, query) + if err != nil { + exitErr(err) + } + results = append(results, res) } - printResult(os.Stdout, res) + if len(results) > 1 { + printComparison(os.Stdout, results...) + return + } + + printResult(os.Stdout, results[0]) } func parseArgs(args []string) (options, error) { @@ -86,8 +105,8 @@ func parseArgs(args []string) (options, error) { os.Exit(0) } rest := fs.Args() - if len(rest) != 1 { - return options{}, errors.New("expected exactly one CPU or GPU search query") + if len(rest) < 1 { + return options{}, errors.New("expected at least one search query") } var opts options if *cpu && *gpu { @@ -102,9 +121,12 @@ func parseArgs(args []string) (options, error) { 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") + for _, query := range rest { + query = strings.TrimSpace(query) + if query == "" { + return options{}, errors.New("query cannot be empty") + } + opts.queries = append(opts.queries, query) } return opts, nil } @@ -112,11 +134,16 @@ func parseArgs(args []string) (options, error) { func printUsage(w io.Writer) { fmt.Fprintln(w, "Usage:") fmt.Fprintln(w, ` passctl --cpu "Ryzen 3600x"`) + fmt.Fprintln(w, ` passctl --cpu "Ryzen 3600x" "Core i7-10700"`) + fmt.Fprintln(w, ` passctl --cpu "Ryzen 3600x" "Core i7-10700" "Core i5-12400"`) fmt.Fprintln(w, ` passctl --gpu "Radeon RX 6650 XT"`) + fmt.Fprintln(w, ` passctl --gpu "Radeon RX 6650 XT" "GeForce RTX 3060"`) fmt.Fprintln(w) fmt.Fprintln(w, "Options:") fmt.Fprintln(w, " --cpu Search PassMark CPU benchmarks") fmt.Fprintln(w, " --gpu Search PassMark GPU benchmarks") + fmt.Fprintln(w) + fmt.Fprintln(w, "Passing multiple queries prints a color-coded comparison.") } func exitErr(err error) { @@ -141,6 +168,444 @@ func printResult(w io.Writer, res result) { } } +func printComparison(w io.Writer, results ...result) { + if len(results) == 0 { + return + } + fmt.Fprintf(w, "%sPassMark %s Comparison%s\n", colorBold, strings.ToUpper(results[0].Kind), colorReset) + names := make([]string, 0, len(results)) + for _, res := range results { + names = append(names, res.Name) + } + fmt.Fprintf(w, "%s\n", strings.Join(names, " vs ")) + for i, res := range results { + if res.URL != "" { + fmt.Fprintf(w, "%s%s:%s %s\n", colorDim, resultLabel(i), colorReset, res.URL) + } + } + fmt.Fprintln(w) + + rows := comparisonRows(results) + if len(rows) == 0 { + fmt.Fprintln(w, "No comparable stats found.") + return + } + + headers := []string{"Metric"} + for _, res := range results { + headers = append(headers, res.Name) + } + headers = append(headers, "Better") + widths := make([]int, len(headers)) + for i, header := range headers { + widths[i] = visibleLen(header) + } + for _, row := range rows { + for i, cell := range row { + if l := visibleLen(cell); l > widths[i] { + widths[i] = l + } + } + } + + printTableRule(w, widths) + printTableRow(w, headers, widths) + printTableRule(w, widths) + for _, row := range rows { + printTableRow(w, row, widths) + } + printTableRule(w, widths) +} + +func resultLabel(i int) string { + if i < 26 { + return string(rune('A' + i)) + } + return fmt.Sprintf("Q%d", i+1) +} + +func comparisonRows(results []result) [][]string { + stats := make([]map[string]string, 0, len(results)) + for _, res := range results { + stats = append(stats, statsMap(res.Stats)) + } + ordered := comparisonStatOrder(stats...) + var rows [][]string + for _, name := range ordered { + row, ok := compareRow(name, stats, results) + if !ok { + continue + } + rows = append(rows, row) + } + return rows +} + +func statsMap(stats []stat) map[string]string { + out := make(map[string]string) + for _, s := range stats { + out[strings.ToLower(s.Name)] = s.Value + } + addAlias := func(from, to string) { + if v, ok := out[from]; ok { + if _, exists := out[to]; !exists { + out[to] = v + } + } + } + addAlias("average cpu mark", "passmark score") + addAlias("average g3d mark", "passmark score") + addAlias("average g2d mark", "passmark score") + addAlias("cpumark/$price", "value") + addAlias("single thread", "single thread rating") + return out +} + +func comparisonStatOrder(stats ...map[string]string) []string { + preferred := []string{ + "PassMark Score", + "Single Thread Rating", + "Value", + "Price", + "Rank", + "Samples", + "CPU First Seen on Charts", + "CPU Released", + "Release Date", + "Launched", + } + seen := make(map[string]bool) + var out []string + for _, name := range preferred { + key := strings.ToLower(name) + for _, statMap := range stats { + if _, ok := statMap[key]; ok { + out = append(out, name) + seen[key] = true + break + } + } + } + + var rest []string + for _, statMap := range stats { + for key := range statMap { + if !seen[key] && !representedAlias(key, seen) { + rest = append(rest, key) + } + } + } + sort.Strings(rest) + for _, key := range rest { + if seen[key] { + continue + } + seen[key] = true + out = append(out, titleStatName(key, stats...)) + } + return out +} + +func representedAlias(key string, seen map[string]bool) bool { + aliases := map[string]string{ + "average cpu mark": "passmark score", + "average g3d mark": "passmark score", + "average g2d mark": "passmark score", + "cpumark/$price": "value", + "single thread": "single thread rating", + } + canonical, ok := aliases[key] + return ok && seen[canonical] +} + +func titleStatName(key string, stats ...map[string]string) string { + for _, s := range stats { + for name := range s { + if name == key { + parts := strings.Fields(name) + for i := range parts { + parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] + } + return strings.Join(parts, " ") + } + } + } + return key +} + +func compareRow(name string, stats []map[string]string, results []result) ([]string, bool) { + key := strings.ToLower(name) + values := make([]string, len(stats)) + present := make([]bool, len(stats)) + anyPresent := false + for i, statMap := range stats { + values[i], present[i] = statMap[key] + anyPresent = anyPresent || present[i] + } + if !anyPresent { + return nil, false + } + + cells := make([]string, len(values)) + copy(cells, values) + for i := range cells { + if !present[i] { + cells[i] = colorDim + "n/a" + colorReset + } + } + + winners, comparable := winningIndexes(name, values, present) + better := colorDim + "n/a" + colorReset + if comparable { + if len(winners) == countPresent(present) { + better = colorDim + "same" + colorReset + } else { + for i := range cells { + if !present[i] { + continue + } + if containsIndex(winners, i) { + cells[i] = colorGreen + cells[i] + colorReset + } else { + cells[i] = colorRed + cells[i] + colorReset + } + } + names := make([]string, 0, len(winners)) + for _, idx := range winners { + names = append(names, results[idx].Name) + } + better = strings.Join(names, ", ") + } + } + + row := []string{name} + row = append(row, cells...) + row = append(row, better) + return row, true +} + +func winningIndexes(name string, values []string, present []bool) ([]int, bool) { + best := -1 + for i := range values { + if !present[i] { + continue + } + if best == -1 { + best = i + continue + } + cmp, ok := compareStat(name, values[i], values[best]) + if !ok { + return nil, false + } + if cmp > 0 { + best = i + } + } + if best == -1 { + return nil, false + } + winners := []int{best} + for i := range values { + if i == best || !present[i] { + continue + } + cmp, ok := compareStat(name, values[i], values[best]) + if !ok { + return nil, false + } + if cmp == 0 { + winners = append(winners, i) + } + } + sort.Ints(winners) + return winners, true +} + +func countPresent(present []bool) int { + count := 0 + for _, ok := range present { + if ok { + count++ + } + } + return count +} + +func containsIndex(indexes []int, needle int) bool { + for _, idx := range indexes { + if idx == needle { + return true + } + } + return false +} + +func compareCells(name, lv, rv, leftName, rightName string, lok, rok bool) (string, string, string) { + if !lok { + return colorDim + "n/a" + colorReset, rv, colorDim + "n/a" + colorReset + } + if !rok { + return lv, colorDim + "n/a" + colorReset, colorDim + "n/a" + colorReset + } + + cmp, ok := compareStat(name, lv, rv) + if !ok || cmp == 0 { + better := colorDim + "n/a" + colorReset + if strings.EqualFold(lv, rv) { + better = colorDim + "same" + colorReset + } + return lv, rv, better + } + if cmp > 0 { + return colorGreen + lv + colorReset, colorRed + rv + colorReset, leftName + } + return colorRed + lv + colorReset, colorGreen + rv + colorReset, rightName +} + +func compareStat(name, left, right string) (int, bool) { + lower := strings.ToLower(name) + if strings.Contains(lower, "date") || strings.Contains(lower, "released") || strings.Contains(lower, "launched") || strings.Contains(lower, "first seen") { + lv, lok := parseDateValue(left) + rv, rok := parseDateValue(right) + if lok && rok { + return compareTime(lv, rv), true + } + } + if !numericComparableStat(lower) { + return 0, false + } + + lv, lok := parseNumberValue(left) + rv, rok := parseNumberValue(right) + if !lok || !rok { + return 0, false + } + cmp := compareFloat(lv, rv) + if lowerBetterStat(lower) { + cmp = -cmp + } + return cmp, true +} + +func numericComparableStat(lower string) bool { + terms := []string{ + "score", + "mark", + "rating", + "value", + "price", + "rank", + "samples", + "clock", + "speed", + "core", + "thread", + "tdp", + "power", + "compression", + "encryption", + "instructions", + "math", + "physics", + "sorting", + "prime", + "fps", + "frames", + "watt", + } + for _, term := range terms { + if strings.Contains(lower, term) { + return true + } + } + return false +} + +func lowerBetterStat(lower string) bool { + return strings.Contains(lower, "price") || + strings.Contains(lower, "rank") || + strings.Contains(lower, "cost") || + strings.Contains(lower, "tdp") || + strings.Contains(lower, "power") +} + +func parseNumberValue(s string) (float64, bool) { + re := regexp.MustCompile(`-?\d[\d,]*(?:\.\d+)?`) + match := re.FindString(s) + if match == "" { + return 0, false + } + match = strings.ReplaceAll(match, ",", "") + v, err := strconv.ParseFloat(match, 64) + return v, err == nil +} + +func parseDateValue(s string) (time.Time, bool) { + s = strings.TrimSpace(s) + quarterRe := regexp.MustCompile(`(?i)^q([1-4])\s+(\d{4})$`) + if m := quarterRe.FindStringSubmatch(s); len(m) == 3 { + quarter, _ := strconv.Atoi(m[1]) + year, _ := strconv.Atoi(m[2]) + month := time.Month(((quarter - 1) * 3) + 1) + return time.Date(year, month, 1, 0, 0, 0, 0, time.UTC), true + } + layouts := []string{ + "Jan 2006", + "January 2006", + "Jan 2, 2006", + "January 2, 2006", + "2006-01-02", + "2006", + } + for _, layout := range layouts { + if t, err := time.Parse(layout, s); err == nil { + return t, true + } + } + return time.Time{}, false +} + +func compareFloat(left, right float64) int { + if left > right { + return 1 + } + if left < right { + return -1 + } + return 0 +} + +func compareTime(left, right time.Time) int { + if left.After(right) { + return 1 + } + if left.Before(right) { + return -1 + } + return 0 +} + +func printTableRule(w io.Writer, widths []int) { + fmt.Fprint(w, "+") + for _, width := range widths { + fmt.Fprint(w, strings.Repeat("-", width+2), "+") + } + fmt.Fprintln(w) +} + +func printTableRow(w io.Writer, cells []string, widths []int) { + fmt.Fprint(w, "|") + for i, cell := range cells { + fmt.Fprintf(w, " %s%s |", cell, strings.Repeat(" ", widths[i]-visibleLen(cell))) + } + fmt.Fprintln(w) +} + +func visibleLen(s string) int { + return len(ansiRe.ReplaceAllString(s, "")) +} + func (c *client) passmark(ctx context.Context, kind, query string) (result, error) { baseHost := "www.cpubenchmark.net" listPath := "/cpu-list/all" @@ -171,7 +636,20 @@ func (c *client) passmark(ctx context.Context, kind, query string) (result, erro 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 + detailURL := canonicalDetailURL(kind, best.URL) + detail, err := c.get(ctx, detailURL) + if err != nil { + detailURL = best.URL + detail, err = c.get(ctx, detailURL) + if err != nil { + return result{Kind: kind, Name: best.Name, URL: best.URL, Stats: best.Stats}, nil + } + } + name, stats := parsePassMarkDetail(detail) + if name == "" { + name = best.Name + } + return result{Kind: kind, Name: name, URL: detailURL, Stats: mergeStats(best.Stats, stats)}, nil } detail, err := c.get(ctx, best.URL) @@ -188,6 +666,20 @@ func (c *client) passmark(ctx context.Context, kind, query string) (result, erro return result{Kind: kind, Name: name, URL: best.URL, Stats: stats}, nil } +func canonicalDetailURL(kind, rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + switch kind { + case kindCPU: + u.Path = "/cpu.php" + case kindGPU: + u.Path = "/gpu.php" + } + return u.String() +} + func (c *client) get(ctx context.Context, rawURL string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { @@ -522,3 +1014,11 @@ func appendUnique(stats []stat, next stat) []stat { } return append(stats, next) } + +func mergeStats(first, second []stat) []stat { + out := append([]stat{}, first...) + for _, s := range second { + out = appendUnique(out, s) + } + return out +} diff --git a/main_test.go b/main_test.go index e4cfcd5..5f6bb8a 100644 --- a/main_test.go +++ b/main_test.go @@ -7,7 +7,7 @@ func TestParseArgsCPU(t *testing.T) { if err != nil { t.Fatalf("parseArgs returned error: %v", err) } - if opts.kind != kindCPU || opts.query != "Ryzen 3600x" { + if opts.kind != kindCPU || len(opts.queries) != 1 || opts.queries[0] != "Ryzen 3600x" { t.Fatalf("unexpected options: %+v", opts) } } @@ -17,11 +17,115 @@ func TestParseArgsGPU(t *testing.T) { if err != nil { t.Fatalf("parseArgs returned error: %v", err) } - if opts.kind != kindGPU || opts.query != "Radeon RX 6650 XT" { + if opts.kind != kindGPU || len(opts.queries) != 1 || opts.queries[0] != "Radeon RX 6650 XT" { t.Fatalf("unexpected options: %+v", opts) } } +func TestParseArgsCPUCompare(t *testing.T) { + opts, err := parseArgs([]string{"--cpu", "Ryzen 3600x", "Core i7-10700"}) + if err != nil { + t.Fatalf("parseArgs returned error: %v", err) + } + if opts.kind != kindCPU || len(opts.queries) != 2 || opts.queries[0] != "Ryzen 3600x" || opts.queries[1] != "Core i7-10700" { + t.Fatalf("unexpected options: %+v", opts) + } +} + +func TestParseArgsCPUMany(t *testing.T) { + opts, err := parseArgs([]string{"--cpu", "Ryzen 3600x", "Core i7-10700", "Core i5-12400", "Ryzen 5800X3D"}) + if err != nil { + t.Fatalf("parseArgs returned error: %v", err) + } + if opts.kind != kindCPU || len(opts.queries) != 4 { + t.Fatalf("unexpected options: %+v", opts) + } + want := []string{"Ryzen 3600x", "Core i7-10700", "Core i5-12400", "Ryzen 5800X3D"} + for i := range want { + if opts.queries[i] != want[i] { + t.Fatalf("query %d = %q, want %q; all options: %+v", i, opts.queries[i], want[i], opts) + } + } +} + +func TestComparisonRowsMany(t *testing.T) { + results := []result{ + {Kind: kindCPU, Name: "A", Stats: []stat{{Name: "PassMark Score", Value: "10"}, {Name: "Price", Value: "$300"}}}, + {Kind: kindCPU, Name: "B", Stats: []stat{{Name: "PassMark Score", Value: "20"}, {Name: "Price", Value: "$200"}}}, + {Kind: kindCPU, Name: "C", Stats: []stat{{Name: "PassMark Score", Value: "15"}, {Name: "Price", Value: "$250"}}}, + } + + rows := comparisonRows(results) + if len(rows) < 2 { + t.Fatalf("expected comparison rows, got %+v", rows) + } + score := findRow(rows, "PassMark Score") + if len(score) != 5 { + t.Fatalf("score row has %d cells, want 5: %+v", len(score), score) + } + if score[4] != "B" { + t.Fatalf("score winner = %q, want B; row: %+v", score[4], score) + } + price := findRow(rows, "Price") + if price[4] != "B" { + t.Fatalf("price winner = %q, want B; row: %+v", price[4], price) + } +} + +func TestCompareStatPriceLowerIsBetter(t *testing.T) { + cmp, ok := compareStat("Price", "$97.71", "$299.99") + if !ok { + t.Fatal("expected price to compare") + } + if cmp <= 0 { + t.Fatalf("cmp = %d, want left price to win", cmp) + } +} + +func TestCompareStatPassMarkHigherIsBetter(t *testing.T) { + cmp, ok := compareStat("PassMark Score", "18,123", "17,111") + if !ok { + t.Fatal("expected score to compare") + } + if cmp <= 0 { + t.Fatalf("cmp = %d, want left score to win", cmp) + } +} + +func TestCompareStatReleaseDateNewerIsBetter(t *testing.T) { + cmp, ok := compareStat("CPU First Seen on Charts", "Jan 2024", "January 2021") + if !ok { + t.Fatal("expected release date to compare") + } + if cmp <= 0 { + t.Fatalf("cmp = %d, want newer date to win", cmp) + } +} + +func TestCompareStatQuarterDateNewerIsBetter(t *testing.T) { + cmp, ok := compareStat("CPU First Seen on Charts", "Q3 2019", "Q2 2018") + if !ok { + t.Fatal("expected quarter date to compare") + } + if cmp <= 0 { + t.Fatalf("cmp = %d, want newer quarter to win", cmp) + } +} + +func TestCompareStatSocketIsNotNumeric(t *testing.T) { + if _, ok := compareStat("Socket", "AM4", "FCLGA1200"); ok { + t.Fatal("socket values should not be treated as numeric comparisons") + } +} + +func TestCanonicalDetailURLCPU(t *testing.T) { + got := canonicalDetailURL(kindCPU, "https://www.cpubenchmark.net/cpu_lookup.php?cpu=Intel+Core+i7&id=3747") + want := "https://www.cpubenchmark.net/cpu.php?cpu=Intel+Core+i7&id=3747" + if got != want { + t.Fatalf("canonicalDetailURL = %q, want %q", got, want) + } +} + func TestPassMarkSelectedStatsCPU(t *testing.T) { html := ` @@ -81,3 +185,12 @@ func assertStats(t *testing.T, stats []stat, want map[string]string) { } } } + +func findRow(rows [][]string, name string) []string { + for _, row := range rows { + if row[0] == name { + return row + } + } + return nil +}