feat: initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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&inc=modeles%2Fclassement&lang=en&ajah=1">
|
||||
<h1><img src='squelettes/img/classement.svg?1647504561' width='48' height='48' class='vmiddle' itemprop='image' /> <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&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=0#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>1</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=50#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>2</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=100#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>3</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=150#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>4</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=200#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>5</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=250#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>6</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=300#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>7</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=350#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>8</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=400#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>9</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=50#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>></a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&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 <a class="mediabox pageajax" data-box-type="ajax" href="./?page=structure&inc=inclusions%2Flegende&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 <a class="mediabox pageajax" data-box-type="ajax" href="./?page=structure&inc=inclusions%2Flegende&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=0#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>1</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=50#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>2</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=100#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>3</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=150#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>4</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=200#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>5</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=250#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>6</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=300#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>7</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=350#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>8</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=400#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>9</a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=50#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>></a></li>
|
||||
<li><a href='./?page=structure&inc=modeles%2Fclassement&lang=en&ajah=1&debut_classement=372900#pagination_debut_classement' class='lien_pagination gris' rel='nofollow'>...</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</p>
|
||||
</div><!--ajaxbloc-->
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user