updates
This commit is contained in:
@@ -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)<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)
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user