This commit is contained in:
2026-06-27 05:28:43 -05:00
parent 290eb74573
commit 531bb53769
3 changed files with 631 additions and 14 deletions
+4
View File
@@ -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.
+507 -7
View File
@@ -11,6 +11,7 @@ import (
"os"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
@@ -22,7 +23,7 @@ const (
type options struct {
kind string
query 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)<script[^>]*>.*?</script>`)
styleRe = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`)
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)
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,21 +121,29 @@ 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 == "" {
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
}
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,8 +636,21 @@ 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 {
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)
if err != nil {
@@ -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
}
+115 -2
View File
@@ -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 := `
<script>var cpuDetailsId = "3494";</script>
@@ -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
}