initial commit
This commit is contained in:
@@ -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.
|
||||||
@@ -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)<script[^>]*>.*?</script>`)
|
||||||
|
styleRe = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`)
|
||||||
|
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)<tr[^>]*>\s*<td[^>]*>\s*<a[^>]+href=["']([^"']*(?:%s)\?[^"']*(?:cpu|gpu)=[^"']*(?:&|&)id=\d+[^"']*)["'][^>]*>(.*?)</a>\s*</td>\s*<td[^>]*>(.*?)</td>\s*<td[^>]*>(.*?)</td>\s*<td[^>]*>(.*?)</td>\s*<td[^>]*>(.*?)</td>\s*</tr>`, 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)<h1[^>]*>(.*?)</h1>`)
|
||||||
|
if name == "" {
|
||||||
|
name = firstMatch(body, `(?is)<title[^>]*>(.*?)</title>`)
|
||||||
|
}
|
||||||
|
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.*?<div[^>]*>\s*([^<]*Rating)\s*</div>\s*<div[^>]*>\s*([0-9,]+)\s*</div>`)
|
||||||
|
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*</div>\s*<div[^>]*>\s*([0-9,]+)\s*</div>`)
|
||||||
|
if m := singleRe.FindStringSubmatch(body); len(m) == 2 {
|
||||||
|
add("Single Thread Rating", m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
rowRe := regexp.MustCompile(`(?is)<tr[^>]*>\s*<t[dh][^>]*>(.*?)</t[dh]>\s*<t[dh][^>]*>(.*?)</t[dh]>`)
|
||||||
|
for _, m := range rowRe.FindAllStringSubmatch(body, -1) {
|
||||||
|
add(m[1], m[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
strongRe := regexp.MustCompile(`(?is)<(?:strong|b)[^>]*>([^:<]{2,80}:?)</(?:strong|b)>\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)<li[^>]+id=["']pk`+regexp.QuoteMeta(id)+`["'][^>]*>(.*?)</li>`)
|
||||||
|
if row != "" {
|
||||||
|
if product := cleanText(stripTags(firstMatch(row, `(?is)<span[^>]+class=["'][^"']*prdname[^"']*["'][^>]*>(.*?)</span>`))); product != "" {
|
||||||
|
stats = appendUnique(stats, stat{Name: "Matched Product", Value: product})
|
||||||
|
}
|
||||||
|
if score := cleanText(stripTags(firstMatch(row, `(?is)<span[^>]+class=["'][^"']*mark-neww[^"']*["'][^>]*>(.*?)</span>`))); score != "" {
|
||||||
|
stats = appendUnique(stats, stat{Name: "PassMark Score", Value: score})
|
||||||
|
}
|
||||||
|
if value := cleanText(stripTags(firstMatch(row, `(?is)<span[^>]+class=["'][^"']*count[^"']*["'][^>]*>(.*?)</span>`))); value != "" {
|
||||||
|
stats = appendUnique(stats, stat{Name: "Value", Value: value})
|
||||||
|
}
|
||||||
|
if price := cleanText(stripTags(firstMatch(row, `(?is)<span[^>]+class=["'][^"']*price-neww[^"']*["'][^>]*>(.*?)</span>`))); price != "" {
|
||||||
|
stats = appendUnique(stats, stat{Name: "Price", Value: price})
|
||||||
|
}
|
||||||
|
score := cleanText(stripTags(firstMatch(row, `(?is)<span[^>]+class=["'][^"']*mark-neww[^"']*["'][^>]*>(.*?)</span>`)))
|
||||||
|
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)<li[^>]+id=["']rk`+regexp.QuoteMeta(id)+`["'][^>]*>(.*?)</li>`)
|
||||||
|
if rankRow != "" {
|
||||||
|
if score := cleanText(stripTags(firstMatch(rankRow, `(?is)<span[^>]+class=["'][^"']*count[^"']*["'][^>]*>(.*?)</span>`))); 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)
|
||||||
|
}
|
||||||
@@ -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 := `
|
||||||
|
<script>var cpuDetailsId = "3494";</script>
|
||||||
|
<li id="pk3494"><span class="more_details" onclick="p(event, '18,123', 1103, 8702, '$97.71');"></span>
|
||||||
|
<a><span class="prdname">AMD Ryzen 5 3600X</span><span class="count">185.5</span><span class="mark-neww">18,123</span><span class="price-neww">$97.71</span></a></li>`
|
||||||
|
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 := `
|
||||||
|
<script>var gpuDetailsId = "4541";</script>
|
||||||
|
<li id="pk4541"><span class="more_details" onclick="p(event, 124, 17111, 5093, 299.99);"></span>
|
||||||
|
<a><span class="prdname">Radeon RX 6650 XT</span><span class="count">57.0</span><span class="mark-neww">17111</span><span class="price-neww">299.99*</span></a></li>`
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user