feat: initial commit
build / test (push) Successful in 9s
build / build (push) Successful in 10s
build / build-image (push) Successful in 1m4s

This commit is contained in:
2026-06-04 14:19:37 +02:00
commit cf961ab94f
28 changed files with 3036 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
package anubis
import (
"encoding/json"
"fmt"
"regexp"
)
// Anubis embeds several JSON blobs in its interstitial as
// <script id="...">{...}</script> elements. We read two:
//
// - anubis_challenge: the canonical challenge (id, randomData, rules)
// - preact_info: the preact frontend's view, crucially the `redir`
// (the fully-formed pass-challenge URL) and `challenge` string.
//
// root-me.org uses the "preact" challenge: the answer is simply
// SHA-256(randomData) (no proof-of-work nonce), gated by a minimum wait of
// difficulty*80ms server-side.
type anubisChallenge struct {
Challenge struct {
ID string `json:"id"`
Method string `json:"method"`
RandomData string `json:"randomData"`
IssuedAt string `json:"issuedAt"`
} `json:"challenge"`
Rules struct {
Difficulty int `json:"difficulty"`
Algorithm string `json:"algorithm"`
} `json:"rules"`
}
type preactInfo struct {
Challenge string `json:"challenge"`
Difficulty int `json:"difficulty"`
Redir string `json:"redir"` // relative pass-challenge URL with id+redir baked in
}
// challenge is the normalized view the transport acts on.
type challenge struct {
algorithm string
id string
randomData string
difficulty int
redir string // preact: server-provided relative pass-challenge URL
}
func scriptRe(id string) *regexp.Regexp {
return regexp.MustCompile(
`(?is)<script[^>]*\bid=["']` + regexp.QuoteMeta(id) + `["'][^>]*>(.*?)</script>`)
}
var (
anubisChallengeRe = scriptRe("anubis_challenge")
preactInfoRe = scriptRe("preact_info")
)
// isInterstitial reports whether an HTML body is an Anubis challenge page.
func isInterstitial(body []byte) bool {
return anubisChallengeRe.Match(body)
}
func extractJSON(re *regexp.Regexp, body []byte, into any) error {
m := re.FindSubmatch(body)
if m == nil {
return fmt.Errorf("anubis: script element not found")
}
if err := json.Unmarshal(m[1], into); err != nil {
return fmt.Errorf("anubis: decode embedded JSON: %w", err)
}
return nil
}
// parseChallenge extracts and normalizes the Anubis challenge from
// interstitial HTML.
func parseChallenge(body []byte) (*challenge, error) {
var ac anubisChallenge
if err := extractJSON(anubisChallengeRe, body, &ac); err != nil {
return nil, fmt.Errorf("anubis: parse anubis_challenge: %w", err)
}
if ac.Challenge.RandomData == "" || ac.Rules.Difficulty <= 0 {
return nil, fmt.Errorf("anubis: incomplete challenge (randomData=%q difficulty=%d)",
ac.Challenge.RandomData, ac.Rules.Difficulty)
}
c := &challenge{
algorithm: ac.Rules.Algorithm,
id: ac.Challenge.ID,
randomData: ac.Challenge.RandomData,
difficulty: ac.Rules.Difficulty,
}
// The preact challenge carries the fully-formed pass-challenge URL in
// preact_info.redir; grab it when present.
if c.algorithm == "preact" {
var pi preactInfo
if err := extractJSON(preactInfoRe, body, &pi); err != nil {
return nil, fmt.Errorf("anubis: parse preact_info: %w", err)
}
if pi.Redir == "" {
return nil, fmt.Errorf("anubis: preact_info missing redir")
}
c.redir = pi.Redir
if pi.Challenge != "" {
c.randomData = pi.Challenge
}
if pi.Difficulty > 0 {
c.difficulty = pi.Difficulty
}
}
return c, nil
}
+52
View File
@@ -0,0 +1,52 @@
package anubis
import (
"bytes"
"io"
"net/http"
"net/url"
"strings"
)
// resolveRef resolves a (possibly relative) URL reference against a base URL,
// preserving the base's scheme and host. Anubis's preact redir is host-relative
// (e.g. "/.within.website/...").
func resolveRef(base *url.URL, ref string) *url.URL {
u, err := url.Parse(ref)
if err != nil {
// Fall back to a copy of base; the caller's cookie check will catch it.
c := *base
return &c
}
return base.ResolveReference(u)
}
// maybeHTML reports whether a response could be an HTML page (and thus possibly
// an Anubis interstitial). Non-HTML responses are passed through untouched.
func maybeHTML(resp *http.Response) bool {
ct := resp.Header.Get("Content-Type")
if ct == "" {
return true // unknown; be safe and inspect
}
return strings.Contains(strings.ToLower(ct), "text/html")
}
// withBody returns resp with its body replaced by an in-memory reader over b,
// so the (already consumed) body can be read again by the caller.
func withBody(resp *http.Response, b []byte) *http.Response {
resp.Body = io.NopCloser(bytes.NewReader(b))
resp.ContentLength = int64(len(b))
resp.Header.Del("Content-Length")
return resp
}
// applyCookies replaces the request's Cookie header with the cookies the shared
// jar holds for the request URL. The replayed request goes through the base
// transport directly (bypassing the client jar), so we must attach the freshly
// obtained auth cookie ourselves.
func (t *Transport) applyCookies(req *http.Request) {
req.Header.Del("Cookie")
for _, c := range t.Jar.Cookies(req.URL) {
req.AddCookie(c)
}
}
+63
View File
@@ -0,0 +1,63 @@
// Package anubis implements a native Go client for getting past the Anubis
// (TecharoHQ) proof-of-work anti-scraper proxy without a headless browser.
//
// The challenge is embedded in the interstitial HTML as a JSON document inside
// a <script id="anubis_challenge"> element. The client must find an integer
// nonce such that SHA-256(randomData + nonce) has `difficulty` leading hex-zero
// digits, then submit it to the pass-challenge endpoint to obtain a signed-JWT
// auth cookie.
package anubis
import (
"crypto/sha256"
"encoding/hex"
"strconv"
)
// sha256Hex returns the hex-encoded SHA-256 of s. This is the answer to a
// "preact" Anubis challenge: result = SHA256(randomData).
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// Solve performs the Anubis proof-of-work: it searches for the smallest nonce
// (starting at 0) such that SHA-256(randomData + nonce) has `difficulty`
// leading hex-zero digits. It returns the hex-encoded hash and the nonce.
//
// The zero check mirrors the Anubis worker: the first floor(difficulty/2)
// bytes must be zero and, when difficulty is odd, the high nibble of the next
// byte must also be zero.
func Solve(randomData string, difficulty int) (hash string, nonce uint64) {
prefix := []byte(randomData)
zeroBytes := difficulty / 2
oddNibble := difficulty%2 != 0
// Reusable buffer: challenge prefix + decimal nonce, refilled each round.
buf := make([]byte, 0, len(prefix)+20)
for n := uint64(0); ; n++ {
buf = append(buf[:0], prefix...)
buf = strconv.AppendUint(buf, n, 10)
sum := sha256.Sum256(buf)
if leadingZeros(sum[:], zeroBytes, oddNibble) {
return hex.EncodeToString(sum[:]), n
}
}
}
// leadingZeros reports whether sum has the required number of leading hex-zero
// digits: zeroBytes fully-zero bytes, plus (when oddNibble) a zero high nibble
// in the following byte.
func leadingZeros(sum []byte, zeroBytes int, oddNibble bool) bool {
for i := range zeroBytes {
if sum[i] != 0 {
return false
}
}
if oddNibble {
return sum[zeroBytes]>>4 == 0
}
return true
}
+58
View File
@@ -0,0 +1,58 @@
package anubis
import (
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"testing"
)
func TestSolveProducesLeadingZeros(t *testing.T) {
cases := []struct {
randomData string
difficulty int
}{
{"abc123", 1},
{"deadbeefcafebabe", 2},
{"root-me-scoreboard", 3},
{"odd-difficulty-check", 5},
}
for _, tc := range cases {
hash, nonce := Solve(tc.randomData, tc.difficulty)
// The hash must have exactly `difficulty` leading '0' hex digits.
if got := leadingZeroDigits(hash); got < tc.difficulty {
t.Errorf("Solve(%q, %d): hash %s has %d leading zeros, want >= %d",
tc.randomData, tc.difficulty, hash, got, tc.difficulty)
}
// And it must be reproducible: SHA-256(randomData + nonce) == hash.
want := sha256.Sum256([]byte(tc.randomData + strconv.FormatUint(nonce, 10)))
if hex.EncodeToString(want[:]) != hash {
t.Errorf("Solve(%q, %d): hash not reproducible from nonce %d",
tc.randomData, tc.difficulty, nonce)
}
}
}
func TestSolveFindsSmallestNonce(t *testing.T) {
// Brute the expected smallest nonce independently and compare.
const data, diff = "smallest", 2 // difficulty 2 == first byte zero
var want uint64
for {
sum := sha256.Sum256([]byte(data + strconv.FormatUint(want, 10)))
if sum[0] == 0 {
break
}
want++
}
if _, nonce := Solve(data, diff); nonce != want {
t.Errorf("Solve smallest nonce = %d, want %d", nonce, want)
}
}
func leadingZeroDigits(hexHash string) int {
return len(hexHash) - len(strings.TrimLeft(hexHash, "0"))
}
+269
View File
@@ -0,0 +1,269 @@
package anubis
import (
"fmt"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
const (
// AuthCookie is the signed-JWT cookie Anubis sets once a challenge passes.
AuthCookie = "techaro.lol-anubis-auth"
// passChallengePath is the endpoint that validates a solved challenge.
passChallengePath = "/.within.website/x/cmd/anubis/api/pass-challenge"
// DefaultUserAgent is a stable, browser-like UA. The Anubis JWT is bound to
// request metadata (incl. User-Agent), so every request through this
// transport must use the same value.
DefaultUserAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
)
// maxBodyPeek caps how much of a response body we read to detect/parse a
// challenge. Anubis interstitials are small; real pages can be large, so we
// only buffer when the response looks like an interstitial.
const maxChallengePeek = 1 << 20 // 1 MiB
// Transport is an http.RoundTripper that transparently solves Anubis
// proof-of-work challenges. When a request hits an interstitial, it solves the
// PoW, calls pass-challenge to obtain the auth cookie (stored in a shared
// jar), then replays the original request.
//
// A singleflight group collapses concurrent solves so only one goroutine pays
// the PoW cost while others wait for the resulting cookie.
type Transport struct {
Base http.RoundTripper
UserAgent string
Jar http.CookieJar
Logger *slog.Logger
sf singleflight.Group
mu sync.Mutex // guards client construction
hc *http.Client
}
// New returns a Transport with a fresh cookie jar and default settings.
func New() (*Transport, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, fmt.Errorf("anubis: create cookie jar: %w", err)
}
return &Transport{
Base: http.DefaultTransport,
UserAgent: DefaultUserAgent,
Jar: jar,
Logger: slog.Default(),
}, nil
}
// Client returns an *http.Client that uses this Transport and shares its cookie
// jar, so callers benefit from both the auto-solving and the persisted cookie.
func (t *Transport) Client() *http.Client {
t.mu.Lock()
defer t.mu.Unlock()
if t.hc == nil {
t.hc = &http.Client{Transport: t, Jar: t.Jar, Timeout: 30 * time.Second}
}
return t.hc
}
// RoundTrip implements http.RoundTripper.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
t.setUA(req)
resp, err := t.base().RoundTrip(req)
if err != nil {
return nil, err
}
// Anubis serves interstitials as 200/text-html. Cheap content-type gate
// before we buffer anything.
if !maybeHTML(resp) {
return resp, nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxChallengePeek))
if err != nil {
resp.Body.Close()
return nil, fmt.Errorf("anubis: read body: %w", err)
}
resp.Body.Close()
if !isInterstitial(body) {
// Not a challenge; hand back a response with a replayable body.
return withBody(resp, body), nil
}
if t.Logger != nil {
t.Logger.Debug("anubis interstitial detected", "url", req.URL.String())
}
// The interstitial sets a cookie-verification cookie that pass-challenge
// requires. The outer http.Client only commits response cookies to the jar
// after RoundTrip returns, so capture them now — before we solve.
t.Jar.SetCookies(req.URL, resp.Cookies())
if err := t.solveFor(req, body); err != nil {
return nil, err
}
// Replay the original request now that we hold the auth cookie. The replay
// goes through the base transport directly, so attach jar cookies manually.
replay := req.Clone(req.Context())
t.setUA(replay)
t.applyCookies(replay)
resp2, err := t.base().RoundTrip(replay)
if err != nil {
return nil, err
}
if maybeHTML(resp2) {
body2, err := io.ReadAll(io.LimitReader(resp2.Body, maxChallengePeek))
resp2.Body.Close()
if err != nil {
return nil, fmt.Errorf("anubis: read replayed body: %w", err)
}
if isInterstitial(body2) {
return nil, fmt.Errorf("anubis: still challenged after passing challenge for %s", req.URL)
}
return withBody(resp2, body2), nil
}
return resp2, nil
}
// solveFor parses the challenge from body, computes the answer, and calls
// pass-challenge to install the auth cookie. Concurrent calls for the same
// host are collapsed via singleflight.
func (t *Transport) solveFor(req *http.Request, body []byte) error {
key := req.URL.Host
_, err, _ := t.sf.Do(key, func() (any, error) {
c, err := parseChallenge(body)
if err != nil {
return nil, err
}
passURL, err := t.answer(req, c)
if err != nil {
return nil, err
}
return nil, t.passChallenge(req, passURL)
})
return err
}
// answer computes the pass-challenge URL for the given challenge, performing
// any required wait or proof-of-work. It supports the "preact" challenge (a
// plain SHA-256 of randomData plus a timing gate) and the "fast"/"slow"
// proof-of-work challenges (nonce search).
func (t *Transport) answer(orig *http.Request, c *challenge) (string, error) {
start := time.Now()
switch c.algorithm {
case "preact":
// Answer is SHA-256(randomData); the server enforces a minimum wait of
// difficulty*80ms. Mirror the frontend's difficulty*125ms delay.
result := sha256Hex(c.randomData)
wait := time.Duration(c.difficulty) * 125 * time.Millisecond
time.Sleep(wait)
u := resolveRef(orig.URL, c.redir)
q := u.Query()
q.Set("result", result)
u.RawQuery = q.Encode()
if t.Logger != nil {
t.Logger.Info("answered anubis preact challenge",
"difficulty", c.difficulty, "waited", wait)
}
return u.String(), nil
case "fast", "slow", "proofofwork", "":
hash, nonce := Solve(c.randomData, c.difficulty)
u := &url.URL{Scheme: orig.URL.Scheme, Host: orig.URL.Host, Path: passChallengePath}
q := url.Values{
"id": {c.id},
"response": {hash},
"nonce": {strconv.FormatUint(nonce, 10)},
"redir": {orig.URL.RequestURI()},
"elapsedTime": {strconv.FormatInt(time.Since(start).Milliseconds(), 10)},
}
u.RawQuery = q.Encode()
if t.Logger != nil {
t.Logger.Info("solved anubis pow challenge",
"difficulty", c.difficulty, "nonce", nonce, "elapsed", time.Since(start))
}
return u.String(), nil
default:
return "", fmt.Errorf("anubis: unsupported challenge algorithm %q", c.algorithm)
}
}
// passChallenge GETs the pass-challenge URL so Anubis sets the auth cookie in
// the shared jar.
func (t *Transport) passChallenge(orig *http.Request, passURL string) error {
preq, err := http.NewRequestWithContext(orig.Context(), http.MethodGet, passURL, nil)
if err != nil {
return fmt.Errorf("anubis: build pass-challenge request: %w", err)
}
t.setUA(preq)
preq.Header.Set("Referer", orig.URL.String())
// Don't follow the post-pass redirect; we only need the Set-Cookie. Use a
// client bound to the shared jar so the cookie is captured.
hc := &http.Client{
Transport: t.base(),
Jar: t.Jar,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: 30 * time.Second,
}
resp, err := hc.Do(preq)
if err != nil {
return fmt.Errorf("anubis: pass-challenge: %w", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, io.LimitReader(resp.Body, maxChallengePeek))
if !t.hasAuthCookie(orig.URL) {
return fmt.Errorf("anubis: pass-challenge did not yield %s cookie (status %d)", AuthCookie, resp.StatusCode)
}
return nil
}
// hasAuthCookie reports whether the jar holds an Anubis auth cookie for u.
// Deployments customize the cookie prefix (default "techaro.lol-anubis-auth",
// root-me uses "anubis-cookie-auth"), so we match any non-empty cookie whose
// name ends in "-auth" while excluding the "-cookie-verification" helper.
func (t *Transport) hasAuthCookie(u *url.URL) bool {
for _, c := range t.Jar.Cookies(u) {
if c.Value == "" || strings.Contains(c.Name, "verification") {
continue
}
if c.Name == AuthCookie || strings.HasSuffix(c.Name, "-auth") {
return true
}
}
return false
}
func (t *Transport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return http.DefaultTransport
}
func (t *Transport) setUA(req *http.Request) {
ua := t.UserAgent
if ua == "" {
ua = DefaultUserAgent
}
req.Header.Set("User-Agent", ua)
if req.Header.Get("Accept-Language") == "" {
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
}
}
+75
View File
@@ -0,0 +1,75 @@
// Package config loads daemon configuration from flags and environment
// variables (flags take precedence; env provides defaults).
package config
import (
"flag"
"fmt"
"os"
"strconv"
"time"
"git.mkz.me/mycroft/root-me-api/internal/anubis"
)
// Config holds all daemon settings.
type Config struct {
HTTPAddr string
GRPCAddr string
RefreshInterval time.Duration
Pages int
UserAgent string
RequestTimeout time.Duration
LogLevel string
}
// Load parses configuration from the given args, falling back to environment
// variables and then built-in defaults.
func Load(args []string) (*Config, error) {
c := &Config{}
fs := flag.NewFlagSet("scoreboard-apid", flag.ContinueOnError)
fs.StringVar(&c.HTTPAddr, "http-addr", env("HTTP_ADDR", ":8080"), "HTTP/JSON listen address")
fs.StringVar(&c.GRPCAddr, "grpc-addr", env("GRPC_ADDR", ":9090"), "gRPC listen address")
fs.DurationVar(&c.RefreshInterval, "refresh-interval", envDuration("REFRESH_INTERVAL", 10*time.Minute), "scoreboard refresh interval")
fs.IntVar(&c.Pages, "pages", envInt("SCOREBOARD_PAGES", 4), "number of scoreboard pages to scrape (50 rows each)")
fs.StringVar(&c.UserAgent, "user-agent", env("USER_AGENT", anubis.DefaultUserAgent), "User-Agent for root-me requests")
fs.DurationVar(&c.RequestTimeout, "request-timeout", envDuration("REQUEST_TIMEOUT", 30*time.Second), "per-request HTTP timeout")
fs.StringVar(&c.LogLevel, "log-level", env("LOG_LEVEL", "info"), "log level: debug, info, warn, error")
if err := fs.Parse(args); err != nil {
return nil, err
}
if c.Pages <= 0 {
return nil, fmt.Errorf("pages must be > 0")
}
if c.RefreshInterval <= 0 {
return nil, fmt.Errorf("refresh-interval must be > 0")
}
return c, nil
}
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return def
}
func envInt(key string, def int) int {
if v, ok := os.LookupEnv(key); ok {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func envDuration(key string, def time.Duration) time.Duration {
if v, ok := os.LookupEnv(key); ok {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return def
}
+20
View File
@@ -0,0 +1,20 @@
// Package scoreboard holds the root-me scoreboard domain type and the
// in-memory store/refresher that serve it to the transports.
package scoreboard
// Entry is a single row of the root-me scoreboard.
//
// Rank is the canonical 1-based position in score-descending order, assigned by
// the scraper. (root-me's own displayed rank is noisy around ties, so we don't
// rely on it.)
type Entry struct {
Rank int `json:"rank"`
Username string `json:"username"`
// ProfilePath is the site-relative profile path, e.g. "/skav".
ProfilePath string `json:"profile_path,omitempty"`
// Country is the two-letter country code from the flag icon, e.g. "fr".
Country string `json:"country,omitempty"`
// Grade is the root-me rank/grade name, e.g. "legend".
Grade string `json:"grade,omitempty"`
Score int `json:"score"`
}
+78
View File
@@ -0,0 +1,78 @@
package scoreboard
import (
"context"
"log/slog"
"sync/atomic"
"time"
)
// FetchFunc retrieves a fresh set of scoreboard entries.
type FetchFunc func(ctx context.Context) ([]Entry, error)
// Refresher periodically refreshes a Store from a FetchFunc, keeping the last
// good snapshot when a refresh fails.
type Refresher struct {
store *Store
fetch FetchFunc
interval time.Duration
logger *slog.Logger
lastErr atomic.Pointer[refreshError]
successN atomic.Int64
failureN atomic.Int64
}
type refreshError struct {
At time.Time
Err error
}
// NewRefresher wires a refresher. interval must be > 0.
func NewRefresher(store *Store, fetch FetchFunc, interval time.Duration, logger *slog.Logger) *Refresher {
if logger == nil {
logger = slog.Default()
}
return &Refresher{store: store, fetch: fetch, interval: interval, logger: logger}
}
// RefreshOnce performs a single refresh, updating the store on success.
func (r *Refresher) RefreshOnce(ctx context.Context) error {
start := time.Now()
entries, err := r.fetch(ctx)
if err != nil {
r.failureN.Add(1)
r.lastErr.Store(&refreshError{At: time.Now(), Err: err})
r.logger.Error("scoreboard refresh failed", "err", err, "serving_stale", r.store.Ready())
return err
}
r.store.Set(entries, time.Now())
r.successN.Add(1)
r.logger.Info("scoreboard refreshed", "entries", len(entries), "took", time.Since(start))
return nil
}
// Run blocks, refreshing on each tick until ctx is cancelled. It does NOT
// refresh immediately; call RefreshOnce first if you want a warm start.
func (r *Refresher) Run(ctx context.Context) {
t := time.NewTicker(r.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
r.logger.Info("scoreboard refresher stopping")
return
case <-t.C:
_ = r.RefreshOnce(ctx)
}
}
}
// Stats reports refresh counters and the last error (nil if none) for /metrics
// or health output.
func (r *Refresher) Stats() (success, failure int64, lastErr error, lastErrAt time.Time) {
if e := r.lastErr.Load(); e != nil {
lastErr, lastErrAt = e.Err, e.At
}
return r.successN.Load(), r.failureN.Load(), lastErr, lastErrAt
}
+112
View File
@@ -0,0 +1,112 @@
package scoreboard
import (
"strings"
"sync"
"time"
)
// Snapshot is an immutable view of the scoreboard at a point in time.
type Snapshot struct {
Entries []Entry
FetchedAt time.Time
byRank map[int]Entry
byUser map[string]Entry // lower-cased username -> entry
}
func newSnapshot(entries []Entry, at time.Time) *Snapshot {
s := &Snapshot{
Entries: entries,
FetchedAt: at,
byRank: make(map[int]Entry, len(entries)),
byUser: make(map[string]Entry, len(entries)),
}
for _, e := range entries {
s.byRank[e.Rank] = e
s.byUser[strings.ToLower(e.Username)] = e
}
return s
}
// Store holds the latest scoreboard snapshot and serves concurrent reads.
// It keeps the last good snapshot when a refresh fails.
type Store struct {
mu sync.RWMutex
snap *Snapshot
}
// NewStore returns an empty store. Ready reports false until Set is called.
func NewStore() *Store { return &Store{} }
// Set atomically replaces the current snapshot with the given entries.
func (s *Store) Set(entries []Entry, at time.Time) {
snap := newSnapshot(entries, at)
s.mu.Lock()
s.snap = snap
s.mu.Unlock()
}
// Ready reports whether a snapshot has been loaded.
func (s *Store) Ready() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.snap != nil
}
// Snapshot returns the current snapshot (nil if none loaded yet).
func (s *Store) Snapshot() *Snapshot {
s.mu.RLock()
defer s.mu.RUnlock()
return s.snap
}
// List returns a page of entries ordered by rank, honoring offset and limit.
// limit <= 0 means "all from offset". It also returns the snapshot time.
func (s *Store) List(offset, limit int) ([]Entry, time.Time) {
snap := s.Snapshot()
if snap == nil {
return nil, time.Time{}
}
if offset < 0 {
offset = 0
}
if offset >= len(snap.Entries) {
return []Entry{}, snap.FetchedAt
}
end := len(snap.Entries)
if limit > 0 && offset+limit < end {
end = offset + limit
}
out := make([]Entry, end-offset)
copy(out, snap.Entries[offset:end])
return out, snap.FetchedAt
}
// ByRank returns the entry at the given canonical rank.
func (s *Store) ByRank(rank int) (Entry, bool) {
snap := s.Snapshot()
if snap == nil {
return Entry{}, false
}
e, ok := snap.byRank[rank]
return e, ok
}
// ByUsername returns the entry for the given username (case-insensitive).
func (s *Store) ByUsername(username string) (Entry, bool) {
snap := s.Snapshot()
if snap == nil {
return Entry{}, false
}
e, ok := snap.byUser[strings.ToLower(strings.TrimSpace(username))]
return e, ok
}
// Len returns the number of entries in the current snapshot.
func (s *Store) Len() int {
snap := s.Snapshot()
if snap == nil {
return 0
}
return len(snap.Entries)
}
+73
View File
@@ -0,0 +1,73 @@
package scoreboard
import (
"context"
"errors"
"testing"
"time"
)
func sample() []Entry {
return []Entry{
{Rank: 1, Username: "skav", Score: 26435},
{Rank: 2, Username: "Kkameleon", Score: 26000},
{Rank: 3, Username: "ENOENT", Score: 25900},
}
}
func TestStoreLookups(t *testing.T) {
s := NewStore()
if s.Ready() {
t.Fatal("empty store should not be ready")
}
s.Set(sample(), time.Now())
if !s.Ready() || s.Len() != 3 {
t.Fatalf("ready=%v len=%d", s.Ready(), s.Len())
}
if e, ok := s.ByRank(2); !ok || e.Username != "Kkameleon" {
t.Errorf("ByRank(2) = %+v ok=%v", e, ok)
}
if e, ok := s.ByUsername("SKAV"); !ok || e.Rank != 1 {
t.Errorf("ByUsername(SKAV) = %+v ok=%v (case-insensitive lookup failed)", e, ok)
}
if _, ok := s.ByUsername("nobody"); ok {
t.Error("ByUsername(nobody) should miss")
}
page, _ := s.List(1, 1)
if len(page) != 1 || page[0].Username != "Kkameleon" {
t.Errorf("List(1,1) = %+v", page)
}
if page, _ := s.List(10, 5); len(page) != 0 {
t.Errorf("List past end should be empty, got %+v", page)
}
}
func TestRefresherKeepsLastGood(t *testing.T) {
s := NewStore()
calls := 0
fetch := func(context.Context) ([]Entry, error) {
calls++
if calls == 1 {
return sample(), nil
}
return nil, errors.New("boom")
}
r := NewRefresher(s, fetch, time.Hour, nil)
if err := r.RefreshOnce(context.Background()); err != nil {
t.Fatalf("first refresh: %v", err)
}
if err := r.RefreshOnce(context.Background()); err == nil {
t.Fatal("second refresh should have failed")
}
// Last good snapshot must still be served.
if s.Len() != 3 {
t.Errorf("expected last good snapshot of 3, got %d", s.Len())
}
ok, fail, lastErr, _ := r.Stats()
if ok != 1 || fail != 1 || lastErr == nil {
t.Errorf("stats: ok=%d fail=%d lastErr=%v", ok, fail, lastErr)
}
}
+124
View File
@@ -0,0 +1,124 @@
package scraper
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/PuerkitoBio/goquery"
"git.mkz.me/mycroft/root-me-api/internal/scoreboard"
)
// The rankings ajax fragment is a single <table> whose rows each have 6 <td>:
//
// [0] "# <rank>"
// [1] avatar <img alt="<username>">
// [2] <a href="/<user>?lang=en" title="profil of <user>"><user></a>
// [3] country flag <img src=".../pays/<cc>.svg">
// [4] grade icon <img src=".../rang/<grade>.svg" alt="<grade>">
// [5] score <a href="<user>?inc=score" title="<big>"><visible-score></a>
//
// Selectors are intentionally lenient (match on structure/attributes rather
// than volatile CSS classes) so minor template changes don't break parsing.
var (
rankRe = regexp.MustCompile(`#?\s*(\d+)`)
flagRe = regexp.MustCompile(`/pays/([a-zA-Z]{2})\.svg`)
gradeRe = regexp.MustCompile(`/rang/([a-zA-Z0-9_-]+)\.svg`)
nonDigits = regexp.MustCompile(`\D+`)
)
// parseRankings parses one rankings fragment into entries.
func parseRankings(html string) ([]scoreboard.Entry, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
return nil, fmt.Errorf("scraper: parse html: %w", err)
}
var entries []scoreboard.Entry
var rowErr error
doc.Find("table tr").Each(func(_ int, tr *goquery.Selection) {
tds := tr.Find("td")
if tds.Length() < 6 {
return // header row (uses <td>Position</td>... but lacks the score link) or layout row
}
e, ok, err := parseRow(tds)
if err != nil {
rowErr = err
return
}
if ok {
entries = append(entries, e)
}
})
if rowErr != nil {
return nil, rowErr
}
if len(entries) == 0 {
return nil, fmt.Errorf("scraper: no rows parsed (fragment layout may have changed)")
}
return entries, nil
}
func parseRow(tds *goquery.Selection) (scoreboard.Entry, bool, error) {
var e scoreboard.Entry
// [0] rank — header row's first cell is "Position" (no digits) → skip.
rankText := strings.TrimSpace(tds.Eq(0).Text())
m := rankRe.FindStringSubmatch(rankText)
if m == nil {
return e, false, nil // not a data row
}
rank, err := strconv.Atoi(m[1])
if err != nil {
return e, false, fmt.Errorf("scraper: bad rank %q: %w", rankText, err)
}
e.Rank = rank
// [2] username + profile path.
link := tds.Eq(2).Find("a").First()
e.Username = strings.TrimSpace(link.Text())
if e.Username == "" {
// Fall back to avatar alt in [1].
e.Username = strings.TrimSpace(tds.Eq(1).Find("img").AttrOr("alt", ""))
}
if href, ok := link.Attr("href"); ok {
if p := strings.SplitN(href, "?", 2)[0]; p != "" {
e.ProfilePath = p
}
}
if e.Username == "" {
return e, false, fmt.Errorf("scraper: row rank %d has no username", rank)
}
// [3] country flag.
if src, ok := tds.Eq(3).Find("img").Attr("src"); ok {
if fm := flagRe.FindStringSubmatch(src); fm != nil {
e.Country = strings.ToLower(fm[1])
}
}
// [4] grade — prefer the icon's alt, else derive from the svg filename.
grade := tds.Eq(4).Find("img").AttrOr("alt", "")
if grade == "" {
if src, ok := tds.Eq(4).Find("img").Attr("src"); ok {
if gm := gradeRe.FindStringSubmatch(src); gm != nil {
grade = gm[1]
}
}
}
e.Grade = strings.TrimSpace(grade)
// [5] score — visible text of the link.
scoreText := nonDigits.ReplaceAllString(tds.Eq(5).Text(), "")
if scoreText != "" {
if e.Score, err = strconv.Atoi(scoreText); err != nil {
return e, false, fmt.Errorf("scraper: bad score %q for rank %d: %w", scoreText, rank, err)
}
}
return e, true, nil
}
+52
View File
@@ -0,0 +1,52 @@
package scraper
import (
"os"
"testing"
)
// TestParseRankingsFixture parses the real rankings fragment captured live
// (internal/scraper/testdata/scoreboard_page1.html) and sanity-checks it.
func TestParseRankingsFixture(t *testing.T) {
html, err := os.ReadFile("testdata/scoreboard_page1.html")
if err != nil {
t.Fatalf("read fixture: %v", err)
}
entries, err := parseRankings(string(html))
if err != nil {
t.Fatalf("parseRankings: %v", err)
}
if len(entries) != pageSize {
t.Errorf("got %d entries, want %d", len(entries), pageSize)
}
first := entries[0]
if first.Username == "" {
t.Error("first username is empty")
}
if first.Score <= 0 {
t.Errorf("first score = %d, want > 0", first.Score)
}
// Every entry must have a username and positive score. The fragment is
// returned in score-descending order (root-me's displayed "# N" rank is
// noisy around ties, so we assert on score, not rank).
prev := first.Score
for i, e := range entries {
if e.Username == "" {
t.Errorf("entry %d has empty username", i)
}
if e.Score <= 0 {
t.Errorf("entry %d (%s) has non-positive score %d", i, e.Username, e.Score)
}
if e.Score > prev {
t.Errorf("entry %d score %d > previous %d (not descending)", i, e.Score, prev)
}
prev = e.Score
}
t.Logf("parsed %d entries; #1 = %s (%s, grade=%s) score=%d",
len(entries), first.Username, first.Country, first.Grade, first.Score)
}
+109
View File
@@ -0,0 +1,109 @@
// Package scraper fetches the root-me.org scoreboard (Rankings) pages through
// an Anubis-solving HTTP client and parses them into scoreboard entries.
package scraper
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"sort"
"git.mkz.me/mycroft/root-me-api/internal/scoreboard"
)
// pageSize is the number of rows per rankings page (the debut_classement step).
const pageSize = 50
// rankingsURL is the public, login-free ajax fragment that renders the
// Rankings table. %d is the debut_classement offset.
const rankingsURL = "https://www.root-me.org/?page=structure&inc=modeles/classement&lang=en&ajah=1&debut_classement=%d"
// Doer is the subset of *http.Client the scraper needs; the anubis.Transport's
// client satisfies it.
type Doer interface {
Do(*http.Request) (*http.Response, error)
}
// Scraper fetches and parses scoreboard pages.
type Scraper struct {
Client Doer
Logger *slog.Logger
}
// New returns a Scraper using the given HTTP client.
func New(client Doer, logger *slog.Logger) *Scraper {
if logger == nil {
logger = slog.Default()
}
return &Scraper{Client: client, Logger: logger}
}
// Fetch retrieves the first n pages of the scoreboard and returns the merged,
// rank-sorted entries. Pages are fetched sequentially to share one Anubis solve
// and to stay gentle on root-me / its rate limiter.
func (s *Scraper) Fetch(ctx context.Context, pages int) ([]scoreboard.Entry, error) {
if pages <= 0 {
pages = 1
}
var all []scoreboard.Entry
for p := range pages {
offset := p * pageSize
entries, err := s.fetchPage(ctx, offset)
if err != nil {
return nil, fmt.Errorf("scraper: page %d (offset %d): %w", p+1, offset, err)
}
s.Logger.Debug("fetched rankings page", "page", p+1, "offset", offset, "rows", len(entries))
all = append(all, entries...)
}
all = dedupeByUsername(all)
// root-me returns rows in score-descending order; sort defensively (stable,
// so ties keep scrape order) and assign canonical 1-based ranks.
sort.SliceStable(all, func(i, j int) bool { return all[i].Score > all[j].Score })
for i := range all {
all[i].Rank = i + 1
}
return all, nil
}
func (s *Scraper) fetchPage(ctx context.Context, offset int) ([]scoreboard.Entry, error) {
u := fmt.Sprintf(rankingsURL, offset)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "text/html, */*; q=0.01")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
resp, err := s.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return parseRankings(string(body))
}
// dedupeByUsername drops duplicate users (defensive against overlapping pages),
// keeping the first occurrence.
func dedupeByUsername(in []scoreboard.Entry) []scoreboard.Entry {
seen := make(map[string]struct{}, len(in))
out := in[:0]
for _, e := range in {
if _, dup := seen[e.Username]; dup {
continue
}
seen[e.Username] = struct{}{}
out = append(out, e)
}
return out
}
+453
View File
@@ -0,0 +1,453 @@
<div class='ajaxbloc' data-ajax-env='iYODfeSg8w3GrshPDtWYvYtlSvssc+OzjOfobTR5c7JF00/3ITSnQ7T8AIv+1NfE3+C7Z4JO8OXTZK1t5VeYYxVLrCKmMLZTQORQwqpeoaSjcCei7+4kFUXdNmSNK9nL2LUBRrknJmKqoeEfdyxdsfvsbuTGCvXbY50HzEcUdYS+esa/bpc5Hi+AHle3z5RyrpeAvSgtctKM3/2dtxxspLq7NjNnkCv8yN9FHL1kyGke86Wa0C/R/BymQ80jVlTSoRZpyhZZoC4vwDU=' data-origin="./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1">
<h1><img src='squelettes/img/classement.svg?1647504561' width='48' height='48' class='vmiddle' itemprop='image' />&nbsp;<span itemprop="headline name">Rankings</span></h1>
<div class="clearfix"></div>
<p class="pagination">
<div class="pagination-centered">
<ul class="pagination">
<li class="current"><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=0#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>1</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=50#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>2</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=100#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>3</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=150#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>4</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=200#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>5</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=250#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>6</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=300#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>7</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=350#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>8</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=400#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>9</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=50#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>&gt;</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=372900#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>...</a></li>
</ul>
</div>
</p>
<table class="text-center" style="width: 100%">
<thead>
<tr class="row_first">
<td>Position</td>
<td class="mauto">Avatar</td>
<td>User&nbsp;&nbsp;<a class="mediabox pageajax" data-box-type="ajax" href="./?page=structure&amp;inc=inclusions%2Flegende&amp;lang=en#compte" title="Account type"><img src="squelettes/img/question_mark.svg" width="16" height="16" alt="Account type"/></a></td>
<td class="show-for-medium-up">Lang</td>
<td class="show-for-medium-up">Rank&nbsp;&nbsp;<a class="mediabox pageajax" data-box-type="ajax" href="./?page=structure&amp;inc=inclusions%2Flegende&amp;lang=en#score" title="Explanation for the scores"><img src="squelettes/img/question_mark.svg" width="16" height="16" alt="Explanation for the scores"/></a></td>
<td>Score</td>
</tr>
</thead>
<tbody>
<tr class="row_even">
<td class="gras"># 1</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton0.png?1637503221" alt="skav" /></td>
<td><a class=" txt_1comite" title="profil of skav" href="/skav?lang=en">skav</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
<td><a href="skav?inc=score&amp;lang=en" title="536559">26435</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 1</td>
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton341626.png?1606567329" alt="Kkameleon" /></td>
<td><a class=" txt_1comite" title="profil of Kkameleon" href="/Kkameleon?lang=en">Kkameleon</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
<td><a href="Kkameleon?inc=score&amp;lang=en" title="341626">26435</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 1</td>
<td><img class="vmiddle logo_auteur logo_0minirezo" width="25" height="25" src="IMG/logo/auton49364.png?1493586230" alt="ENOENT" /></td>
<td><a class=" txt_0minirezo" title="profil of ENOENT" href="/ENOENT?lang=en">ENOENT</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
<td><a href="ENOENT?inc=score&amp;lang=en" title="49364">26435</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 4</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton366289.png?1665089268" alt="CharlB" /></td>
<td><a class=" txt_6forum" title="profil of CharlB" href="/CharlB?lang=en">CharlB</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
<td><a href="CharlB?inc=score&amp;lang=en" title="366289">26105</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 5</td>
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton592353.jpg?1696598810" alt="ToG" /></td>
<td><a class=" txt_1comite" title="profil of ToG" href="/ToG?lang=en">ToG</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
<td><a href="ToG?inc=score&amp;lang=en" title="592353">25880</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 6</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton221019.jpg?1565189393" alt="macz" /></td>
<td><a class=" txt_6forum" title="profil of macz" href="/macz?lang=en">macz</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/en.svg?1637569714' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
<td><a href="macz?inc=score&amp;lang=en" title="221019">25875</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 7</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton14516.jpg?1474128409" alt="blackndoor" /></td>
<td><a class=" txt_6forum" title="profil of blackndoor" href="/blackndoor?lang=en">blackndoor</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
<td><a href="blackndoor?inc=score&amp;lang=en" title="14516">25565</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 8</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton892829.png?1779836646" alt="geky" /></td>
<td><a class=" txt_6forum" title="profil of geky" href="/geky-892829?lang=en">geky</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/legend.svg?1641984641' alt='legend' width='16' height='16' title='legend' /></td>
<td><a href="geky-892829?inc=score&amp;lang=en" title="892829">24830</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 9</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="19" src="IMG/logo/auton317636.gif?1663314648" alt="nikost" /></td>
<td><a class=" txt_6forum" title="profil of nikost" href="/nikost?lang=en">nikost</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="nikost?inc=score&amp;lang=en" title="317636">24510</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 10</td>
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="16" src="IMG/logo/auton24861.jpg?1464908918" alt="k4ndar3c" /></td>
<td><a class=" txt_1comite" title="profil of k4ndar3c" href="/k4ndar3c?lang=en">k4ndar3c</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="k4ndar3c?inc=score&amp;lang=en" title="24861">23775</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 11</td>
<td><img class="vmiddle logo_auteur logo_1comite" width="20" height="25" src="IMG/logo/auton526362.jpg?1714421693" alt="M58" /></td>
<td><a class=" txt_1comite" title="profil of M58" href="/M58_?lang=en">M58</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="M58_?inc=score&amp;lang=en" title="526362">23690</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 12</td>
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton698033.png?1752680949" alt="NearXa" /></td>
<td><a class=" txt_1comite" title="profil of NearXa" href="/NearXa-1337?lang=en">NearXa</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="NearXa-1337?inc=score&amp;lang=en" title="698033">23330</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 13</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="23" height="25" src="IMG/logo/auton27430.png?1440105023" alt="franb" /></td>
<td><a class=" txt_6forum" title="profil of franb" href="/franb?lang=en">franb</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="franb?inc=score&amp;lang=en" title="27430">23175</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 14</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="22" height="25" src="IMG/logo/auton79281.jpg?1712996043" alt="Jrmbt" /></td>
<td><a class=" txt_6forum" title="profil of Jrmbt" href="/Jrmbt?lang=en">Jrmbt</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Jrmbt?inc=score&amp;lang=en" title="79281">22950</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 15</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton218356.png?1596202289" alt="voydstack" /></td>
<td><a class=" txt_6forum" title="profil of voydstack" href="/voydstack?lang=en">voydstack</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="voydstack?inc=score&amp;lang=en" title="218356">22235</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 16</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="18" height="25" src="IMG/logo/auton848814.jpg?1734033298" alt="Adem0" /></td>
<td><a class=" txt_6forum" title="profil of Adem0" href="/Adem0?lang=en">Adem0</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Adem0?inc=score&amp;lang=en" title="848814">22090</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 17</td>
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="17" src="IMG/logo/auton317376.jpg?1619479668" alt="nobodyisnobody" /></td>
<td><a class=" txt_1comite" title="profil of nobodyisnobody" href="/nobodyisnobody?lang=en">nobodyisnobody</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="nobodyisnobody?inc=score&amp;lang=en" title="317376">21865</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 18</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton0.png?1637503221" alt="csg" /></td>
<td><a class=" txt_6forum" title="profil of csg" href="/csg?lang=en">csg</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="csg?inc=score&amp;lang=en" title="703517">21500</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 19</td>
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton385190.jpg?1666646118" alt="_cthulu" /></td>
<td><a class=" txt_1comite" title="profil of _cthulu" href="/_cthulu?lang=en">_cthulu</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="_cthulu?inc=score&amp;lang=en" title="385190">21425</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 20</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton27775.png?1438473128" alt="laxa" /></td>
<td><a class=" txt_6forum" title="profil of laxa" href="/laxa?lang=en">laxa</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="laxa?inc=score&amp;lang=en" title="27775">21115</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 21</td>
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton475204.jpg?1768506059" alt="Ap4sh" /></td>
<td><a class=" txt_1comite" title="profil of Ap4sh" href="/Ap4sh?lang=en">Ap4sh</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Ap4sh?inc=score&amp;lang=en" title="475204">21015</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 22</td>
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="24" src="IMG/logo/auton442429.png?1657666447" alt="Vozec" /></td>
<td><a class=" txt_1comite" title="profil of Vozec" href="/Vozec?lang=en">Vozec</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Vozec?inc=score&amp;lang=en" title="442429">20905</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 23</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton158722.jpg?1639654441" alt="gwel" /></td>
<td><a class=" txt_6forum" title="profil of gwel" href="/gwel?lang=en">gwel</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="gwel?inc=score&amp;lang=en" title="158722">20700</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 24</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton236284.jpg?1589212400" alt="Bdenneu" /></td>
<td><a class=" txt_6forum" title="profil of Bdenneu" href="/Bdenneu?lang=en">Bdenneu</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Bdenneu?inc=score&amp;lang=en" title="236284">20645</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 25</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="14" height="25" src="IMG/logo/auton539628.jpg?1769780598" alt="D0pp3lgang3r" /></td>
<td><a class=" txt_6forum" title="profil of D0pp3lgang3r" href="/D0pp3lgang3r?lang=en">D0pp3lgang3r</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="D0pp3lgang3r?inc=score&amp;lang=en" title="539628">20010</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 25</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="23" src="IMG/logo/auton813990.jpg?1752183601" alt="AlexsB" /></td>
<td><a class=" txt_6forum" title="profil of AlexsB" href="/AlexsB?lang=en">AlexsB</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="AlexsB?inc=score&amp;lang=en" title="813990">19985</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 26</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton106074.png?1773046289" alt="Mr7F" /></td>
<td><a class=" txt_6forum" title="profil of Mr7F" href="/Mr7F?lang=en">Mr7F</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/en.svg?1637569714' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Mr7F?inc=score&amp;lang=en" title="106074">19880</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 26</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="21" height="25" src="IMG/logo/auton93289.png?1602765200" alt="mjuuum" /></td>
<td><a class=" txt_6forum" title="profil of mjuuum" href="/mjuuum?lang=en">mjuuum</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/en.svg?1637569714' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="mjuuum?inc=score&amp;lang=en" title="93289">19875</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 27</td>
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton18343.png?1538216614" alt="Laluka" /></td>
<td><a class=" txt_1comite" title="profil of Laluka" href="/Laluka?lang=en">Laluka</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Laluka?inc=score&amp;lang=en" title="18343">19765</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 30</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="20" height="25" src="IMG/logo/auton613494.jpg?1635643200" alt="Oblivios" /></td>
<td><a class=" txt_5pre" title="profil of Oblivios" href="/Oblivios?lang=en">Oblivios</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Oblivios?inc=score&amp;lang=en" title="613494">19615</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 28</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton0.png?1637503221" alt="kikko" /></td>
<td><a class=" txt_6forum" title="profil of kikko" href="/kikko?lang=en">kikko</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="kikko?inc=score&amp;lang=en" title="36671">19570</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 29</td>
<td><img class="vmiddle logo_auteur logo_5pre" width="25" height="25" src="IMG/logo/auton139707.png?1698081501" alt="Podalirius" /></td>
<td><a class=" txt_5pre" title="profil of Podalirius" href="/Podalirius?lang=en">Podalirius</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Podalirius?inc=score&amp;lang=en" title="139707">19565</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 33</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton49089.png?1716449817" alt="NilDead" /></td>
<td><a class=" txt_6forum" title="profil of NilDead" href="/NilDead?lang=en">NilDead</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="NilDead?inc=score&amp;lang=en" title="49089">19290</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 34</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton147650.png?1775677914" alt="arnaud-sh" /></td>
<td><a class=" txt_6forum" title="profil of LightDiscord" href="/LightDiscord?lang=en">LightDiscord</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="arnaud-sh?inc=score&amp;lang=en" title="147650">19250</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 35</td>
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="24" src="IMG/logo/auton141378.jpg?1779570957" alt="Nyu" /></td>
<td><a class=" txt_1comite" title="profil of Nyu" href="/Nyu?lang=en">Nyu</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Nyu?inc=score&amp;lang=en" title="141378">18985</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 36</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="21" height="25" src="IMG/logo/auton23519.png?1454951124" alt="Alkanor" /></td>
<td><a class=" txt_6forum" title="profil of Alkanor" href="/Alkanor?lang=en">Alkanor</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Alkanor?inc=score&amp;lang=en" title="23519">18760</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 37</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton395842.jpg?1620507281" alt="Express" /></td>
<td><a class=" txt_5pre" title="profil of Express" href="/Express?lang=en">Express</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Express?inc=score&amp;lang=en" title="395842">18740</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 38</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="18" src="IMG/logo/auton365797.jpg?1632567624" alt="Dvorhack" /></td>
<td><a class=" txt_6forum" title="profil of Dvorhack" href="/Viel?lang=en">Dvorhack</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Viel?inc=score&amp;lang=en" title="365797">18725</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 39</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="23" height="25" src="IMG/logo/auton245879.jpg?1663956446" alt="Iroh" /></td>
<td><a class=" txt_6forum" title="profil of Iroh" href="/Iroh?lang=en">Iroh</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Iroh?inc=score&amp;lang=en" title="245879">18470</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 40</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton0.png?1637503221" alt="Rishkov" /></td>
<td><a class=" txt_6forum" title="profil of Rishkov" href="/Rishkov?lang=en">Rishkov</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Rishkov?inc=score&amp;lang=en" title="660750">18330</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 41</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton729201.png?1752959393" alt="Retro16" /></td>
<td><a class=" txt_6forum" title="profil of Retro16" href="/Retro16?lang=en">Retro16</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Retro16?inc=score&amp;lang=en" title="729201">18080</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 42</td>
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton312118.png?1615662453" alt="Pyfu" /></td>
<td><a class=" txt_1comite" title="profil of Pyfu" href="/Pyfu?lang=en">Pyfu</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Pyfu?inc=score&amp;lang=en" title="312118">18070</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 43</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="21" height="25" src="IMG/logo/auton152303.png?1580758659" alt="Slowerzs" /></td>
<td><a class=" txt_6forum" title="profil of Slowerzs" href="/Slowerzs?lang=en">Slowerzs</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Slowerzs?inc=score&amp;lang=en" title="152303">17980</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 44</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton962121.png?1776115782" alt="AESpider" /></td>
<td><a class=" txt_6forum" title="profil of AESpider" href="/AESpider?lang=en">AESpider</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="AESpider?inc=score&amp;lang=en" title="962121">17955</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 44</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton533125.jpg?1738508401" alt="235711131723" /></td>
<td><a class=" txt_6forum" title="profil of 235711131723" href="/235711131723?lang=en">235711131723</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="235711131723?inc=score&amp;lang=en" title="533125">17945</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 45</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="22" height="25" src="IMG/logo/auton47809.gif?1662053394" alt="ohohoh" /></td>
<td><a class=" txt_6forum" title="profil of ohohoh" href="/ohohoh-47809?lang=en">ohohoh</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="ohohoh-47809?inc=score&amp;lang=en" title="47809">17740</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 46</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton284606.jpg?1599208174" alt="face0xff" /></td>
<td><a class=" txt_1comite" title="profil of face0xff" href="/face0xff?lang=en">face0xff</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="face0xff?inc=score&amp;lang=en" title="284606">17660</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 46</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton8738.jpg?1692005697" alt="Tomtombinary" /></td>
<td><a class=" txt_6forum" title="profil of Tomtombinary" href="/Tomtombinary?lang=en">Tomtombinary</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Tomtombinary?inc=score&amp;lang=en" title="8738">17635</a></td>
</tr>
<tr class="row_even">
<td class="gras"># 47</td>
<td><img class="vmiddle logo_auteur logo_1comite" width="25" height="25" src="IMG/logo/auton259266.jpg?1748876096" alt="Globules" /></td>
<td><a class=" txt_1comite" title="profil of Globules" href="/Globules?lang=en">Globules</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Globules?inc=score&amp;lang=en" title="259266">17610</a></td>
</tr>
<tr class="row_odd">
<td class="gras"># 49</td>
<td><img class="vmiddle logo_auteur logo_6forum" width="25" height="25" src="IMG/logo/auton125681.gif?1642179520" alt="Re:Z3R0" /></td>
<td><a class=" txt_6forum" title="profil of Re:Z3R0" href="/Re-Z3R0?lang=en">Re:Z3R0</a></td>
<td class="show-for-medium-up"><img src='squelettes/img/pays/fr.svg?1594376628' width='16' height='16' /></td>
<td class="show-for-medium-up"><img src='squelettes/img/rang/elite.svg?1640075769' alt='elite' width='16' height='16' title='elite' /></td>
<td><a href="Re-Z3R0?inc=score&amp;lang=en" title="125681">17550</a></td>
</tr>
</tbody>
</table>
<p class="pagination">
<div class="pagination-centered">
<ul class="pagination">
<li class="current"><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=0#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>1</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=50#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>2</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=100#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>3</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=150#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>4</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=200#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>5</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=250#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>6</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=300#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>7</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=350#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>8</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=400#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>9</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=50#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>&gt;</a></li>
<li><a href='./?page=structure&amp;inc=modeles%2Fclassement&amp;lang=en&amp;ajah=1&amp;debut_classement=372900#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>...</a></li>
</ul>
</div>
</p>
</div><!--ajaxbloc-->
+73
View File
@@ -0,0 +1,73 @@
// Package server exposes the scoreboard Store over gRPC and HTTP/JSON.
package server
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "git.mkz.me/mycroft/root-me-api/gen/scoreboard/v1"
"git.mkz.me/mycroft/root-me-api/internal/scoreboard"
)
// GRPCServer implements pb.ScoreboardServiceServer over a Store.
type GRPCServer struct {
pb.UnimplementedScoreboardServiceServer
Store *scoreboard.Store
}
// NewGRPC returns a gRPC server backed by the given store.
func NewGRPC(store *scoreboard.Store) *GRPCServer { return &GRPCServer{Store: store} }
func (s *GRPCServer) ListScoreboard(_ context.Context, req *pb.ListScoreboardRequest) (*pb.ListScoreboardResponse, error) {
if !s.Store.Ready() {
return nil, status.Error(codes.Unavailable, "scoreboard not loaded yet")
}
entries, fetchedAt := s.Store.List(int(req.GetOffset()), int(req.GetLimit()))
resp := &pb.ListScoreboardResponse{
Total: int32(s.Store.Len()),
FetchedAt: fetchedAt.Unix(),
}
resp.Entries = make([]*pb.Entry, len(entries))
for i, e := range entries {
resp.Entries[i] = toProto(e)
}
return resp, nil
}
func (s *GRPCServer) GetByRank(_ context.Context, req *pb.GetByRankRequest) (*pb.Entry, error) {
if !s.Store.Ready() {
return nil, status.Error(codes.Unavailable, "scoreboard not loaded yet")
}
e, ok := s.Store.ByRank(int(req.GetRank()))
if !ok {
return nil, status.Errorf(codes.NotFound, "no entry at rank %d", req.GetRank())
}
return toProto(e), nil
}
func (s *GRPCServer) GetByUsername(_ context.Context, req *pb.GetByUsernameRequest) (*pb.Entry, error) {
if !s.Store.Ready() {
return nil, status.Error(codes.Unavailable, "scoreboard not loaded yet")
}
if req.GetUsername() == "" {
return nil, status.Error(codes.InvalidArgument, "username is required")
}
e, ok := s.Store.ByUsername(req.GetUsername())
if !ok {
return nil, status.Errorf(codes.NotFound, "no entry for user %q", req.GetUsername())
}
return toProto(e), nil
}
func toProto(e scoreboard.Entry) *pb.Entry {
return &pb.Entry{
Rank: int32(e.Rank),
Username: e.Username,
ProfilePath: e.ProfilePath,
Country: e.Country,
Grade: e.Grade,
Score: int32(e.Score),
}
}
+139
View File
@@ -0,0 +1,139 @@
package server
import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"time"
"git.mkz.me/mycroft/root-me-api/internal/scoreboard"
)
// StatsProvider exposes refresh health (implemented by *scoreboard.Refresher).
type StatsProvider interface {
Stats() (success, failure int64, lastErr error, lastErrAt time.Time)
}
// HTTPHandler builds the JSON REST mux over the store. stats may be nil.
func HTTPHandler(store *scoreboard.Store, stats StatsProvider, logger *slog.Logger) http.Handler {
if logger == nil {
logger = slog.Default()
}
h := &httpServer{store: store, stats: stats, logger: logger}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", h.healthz)
mux.HandleFunc("GET /readyz", h.readyz)
mux.HandleFunc("GET /v1/scoreboard", h.list)
mux.HandleFunc("GET /v1/scoreboard/rank/{rank}", h.byRank)
mux.HandleFunc("GET /v1/scoreboard/user/{username}", h.byUser)
return mux
}
type httpServer struct {
store *scoreboard.Store
stats StatsProvider
logger *slog.Logger
}
type listResponse struct {
Entries []scoreboard.Entry `json:"entries"`
Total int `json:"total"`
FetchedAt time.Time `json:"fetched_at"`
}
func (h *httpServer) healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok\n"))
}
func (h *httpServer) readyz(w http.ResponseWriter, _ *http.Request) {
body := map[string]any{"ready": h.store.Ready(), "entries": h.store.Len()}
if h.stats != nil {
ok, fail, lastErr, lastErrAt := h.stats.Stats()
body["refresh_success"] = ok
body["refresh_failure"] = fail
if lastErr != nil {
body["last_error"] = lastErr.Error()
body["last_error_at"] = lastErrAt
}
}
code := http.StatusOK
if !h.store.Ready() {
code = http.StatusServiceUnavailable
}
writeJSON(w, code, body)
}
func (h *httpServer) list(w http.ResponseWriter, r *http.Request) {
if !h.store.Ready() {
writeError(w, http.StatusServiceUnavailable, "scoreboard not loaded yet")
return
}
offset, err := intParam(r, "offset", 0)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid offset")
return
}
limit, err := intParam(r, "limit", 0)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid limit")
return
}
entries, fetchedAt := h.store.List(offset, limit)
writeJSON(w, http.StatusOK, listResponse{
Entries: entries,
Total: h.store.Len(),
FetchedAt: fetchedAt,
})
}
func (h *httpServer) byRank(w http.ResponseWriter, r *http.Request) {
if !h.store.Ready() {
writeError(w, http.StatusServiceUnavailable, "scoreboard not loaded yet")
return
}
rank, err := strconv.Atoi(r.PathValue("rank"))
if err != nil || rank < 1 {
writeError(w, http.StatusBadRequest, "rank must be a positive integer")
return
}
e, ok := h.store.ByRank(rank)
if !ok {
writeError(w, http.StatusNotFound, "no entry at that rank")
return
}
writeJSON(w, http.StatusOK, e)
}
func (h *httpServer) byUser(w http.ResponseWriter, r *http.Request) {
if !h.store.Ready() {
writeError(w, http.StatusServiceUnavailable, "scoreboard not loaded yet")
return
}
e, ok := h.store.ByUsername(r.PathValue("username"))
if !ok {
writeError(w, http.StatusNotFound, "user not found in scoreboard")
return
}
writeJSON(w, http.StatusOK, e)
}
func intParam(r *http.Request, name string, def int) (int, error) {
v := r.URL.Query().Get(name)
if v == "" {
return def, nil
}
return strconv.Atoi(v)
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}