This commit is contained in:
2026-03-23 00:14:44 +01:00
parent 355274b918
commit a4d01e3700
11 changed files with 180 additions and 135 deletions
+1
View File
@@ -1,3 +1,4 @@
.env .env
data/ data/
dist/ dist/
users.txt
+11 -4
View File
@@ -1,15 +1,22 @@
services: services:
ncore-stats: ncore-stats:
build: . build: .
container_name: ncore-stats container_name: ncore-stats-dev
restart: unless-stopped restart: unless-stopped
user: "1000:1000"
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:
- data:/app/data - ./data:/app/data
configs:
- source: users_config
target: /app/users.txt
environment: environment:
- NICK=${NICK} - NICK=${NICK}
- PASS=${PASS} - PASS=${PASS}
volumes: configs:
data: users_config:
content: |
alice:123
bob:456
+11 -1
View File
@@ -3,13 +3,23 @@ services:
image: ghcr.io/skidoodle/ncore-stats:latest image: ghcr.io/skidoodle/ncore-stats:latest
container_name: ncore-stats container_name: ncore-stats
restart: unless-stopped restart: unless-stopped
user: "1000:1000"
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:
- data:/app/data - data:/app/data
configs:
- source: users_config
target: /app/users.txt
environment: environment:
- NICK=${NICK} - NICK=${NICK}
- PASSWORD=${PASS} - PASS=${PASS}
configs:
users_config:
content: |
alice:123
bob:456
volumes: volumes:
data: data:
+5
View File
@@ -31,6 +31,11 @@ func loadConfig() *Configuration {
cfg.DatabasePath = defaultDbFolder cfg.DatabasePath = defaultDbFolder
} }
cfg.UsersPath = os.Getenv("USERS_PATH")
if cfg.UsersPath == "" {
cfg.UsersPath = "./users.txt"
}
lvl, _ := logrus.ParseLevel(os.Getenv("LOG_LEVEL")) lvl, _ := logrus.ParseLevel(os.Getenv("LOG_LEVEL"))
if lvl == 0 { if lvl == 0 {
lvl = logrus.InfoLevel lvl = logrus.InfoLevel
+94 -23
View File
@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"os" "os"
"strings"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@@ -18,7 +19,12 @@ func initDB(cfg *Configuration) *sql.DB {
db.SetMaxOpenConns(1) db.SetMaxOpenConns(1)
schemas := []string{ schemas := []string{
`CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, display_name TEXT UNIQUE, profile_id TEXT);`, `CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT UNIQUE,
profile_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`,
`CREATE TABLE IF NOT EXISTS profile_history ( `CREATE TABLE IF NOT EXISTS profile_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, user_id INTEGER,
@@ -46,26 +52,19 @@ func initDB(cfg *Configuration) *sql.DB {
} }
func migrate(db *sql.DB) { func migrate(db *sql.DB) {
var columnExists bool var ubExists bool
_ = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('profile_history') WHERE name='upload_bytes'").Scan(&columnExists) _ = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('profile_history') WHERE name='upload_bytes'").Scan(&ubExists)
if !columnExists { if !ubExists {
logrus.Info("Migrating: Adding upload_bytes column...") logrus.Info("Migrating: Adding upload_bytes column...")
_, err := db.Exec("ALTER TABLE profile_history ADD COLUMN upload_bytes INTEGER") _, _ = db.Exec("ALTER TABLE profile_history ADD COLUMN upload_bytes INTEGER")
if err != nil {
logrus.Errorf("Migration failed (add column): %v", err)
return
}
logrus.Info("Migrating: Backfilling upload_bytes from existing strings...")
type updateRow struct { type updateRow struct {
id int64 id int64
bytes int64 bytes int64
} }
var updates []updateRow var updates []updateRow
rows, _ := db.Query("SELECT id, upload FROM profile_history WHERE upload_bytes IS NULL")
rows, err := db.Query("SELECT id, upload FROM profile_history WHERE upload_bytes IS NULL") if rows != nil {
if err == nil {
for rows.Next() { for rows.Next() {
var id int64 var id int64
var upload string var upload string
@@ -75,24 +74,96 @@ func migrate(db *sql.DB) {
} }
rows.Close() rows.Close()
} }
if len(updates) > 0 { if len(updates) > 0 {
tx, err := db.Begin() tx, _ := db.Begin()
if err != nil {
logrus.Errorf("Transaction start failed: %v", err)
return
}
stmt, _ := tx.Prepare("UPDATE profile_history SET upload_bytes = ? WHERE id = ?") stmt, _ := tx.Prepare("UPDATE profile_history SET upload_bytes = ? WHERE id = ?")
for _, up := range updates { for _, up := range updates {
_, _ = stmt.Exec(up.bytes, up.id) _, _ = stmt.Exec(up.bytes, up.id)
} }
stmt.Close() stmt.Close()
if err := tx.Commit(); err != nil { _ = tx.Commit()
logrus.Errorf("Transaction commit failed: %v", err) }
}
var caExists bool
_ = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='created_at'").Scan(&caExists)
if !caExists {
logrus.Info("Migrating: Adding created_at column to users...")
_, err := db.Exec("ALTER TABLE users ADD COLUMN created_at DATETIME")
if err != nil {
logrus.Errorf("User migration failed (add column): %v", err)
} else {
_, _ = db.Exec("UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL")
logrus.Info("User migration complete.")
}
}
}
func (s *State) syncUsers() {
if _, err := os.Stat(s.config.UsersPath); os.IsNotExist(err) {
logrus.Warnf("Users config file not found at %s, skipping sync", s.config.UsersPath)
return
}
content, err := os.ReadFile(s.config.UsersPath)
if err != nil {
logrus.Errorf("Failed to read users file: %v", err)
return
}
type userEntry struct {
Name string
ID string
}
var users []userEntry
lines := strings.Split(string(content), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
users = append(users, userEntry{
Name: strings.TrimSpace(parts[0]),
ID: strings.TrimSpace(parts[1]),
})
}
}
for _, u := range users {
_, err := s.db.Exec(`
INSERT INTO users (display_name, profile_id)
VALUES (?, ?)
ON CONFLICT(display_name) DO UPDATE SET profile_id = excluded.profile_id`,
u.Name, u.ID)
if err != nil {
logrus.Errorf("Sync failed for %s: %v", u.Name, err)
}
}
rows, _ := s.db.Query("SELECT display_name FROM users")
if rows != nil {
defer rows.Close()
for rows.Next() {
var name string
rows.Scan(&name)
found := false
for _, u := range users {
if u.Name == name {
found = true
break
}
}
if !found {
logrus.Infof("Sync: Removing %s (not in config)", name)
_, _ = s.db.Exec("DELETE FROM users WHERE display_name = ?", name)
} }
} }
logrus.Info("Migration complete.")
} }
logrus.Infof("User synchronization complete (%d users)", len(users))
} }
func (s *State) getLatest() ([]ProfileData, error) { func (s *State) getLatest() ([]ProfileData, error) {
+1
View File
@@ -94,6 +94,7 @@ func (s *State) historyModalHandler(w http.ResponseWriter, r *http.Request) {
x-init='renderChart(%s)'> x-init='renderChart(%s)'>
</div>`, string(dataJSON)) </div>`, string(dataJSON))
} }
func (s *State) rootHandler(w http.ResponseWriter, r *http.Request) { func (s *State) rootHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { if r.URL.Path != "/" {
http.NotFound(w, r) http.NotFound(w, r)
+2
View File
@@ -34,6 +34,8 @@ func main() {
client: &http.Client{Timeout: 45 * time.Second}, client: &http.Client{Timeout: 45 * time.Second},
} }
state.syncUsers()
if handleFlags(state) { if handleFlags(state) {
return return
} }
+1
View File
@@ -12,6 +12,7 @@ import (
type Configuration struct { type Configuration struct {
ServerPort string ServerPort string
DatabasePath string DatabasePath string
UsersPath string
LogLevel logrus.Level LogLevel logrus.Level
Ncore struct { Ncore struct {
Nick string Nick string
+41 -86
View File
@@ -1,98 +1,53 @@
# nCore Profile Tracker # nCore Stats
A simple Go project to scrape and track profile statistics (rank, upload, download, points) on nCore, the largest Hungarian BitTorrent tracker. The stats are stored in a SQLite database and displayed on a basic web interface. A tracker for nCore profile statistics.
![image](https://github.com/user-attachments/assets/1496548c-0bb4-4f50-aecd-ead79759ceb0) ![nCore Stats Dashboard](https://github.com/user-attachments/assets/1496548c-0bb4-4f50-aecd-ead79759ceb0)
## Features ## Quick Start
- Scrapes and logs profile stats from nCore for multiple users. 1. Create a `.env` file with your credentials:
- Serves a simple HTML dashboard to display the latest data and historical charts.
- Provides a JSON API to fetch historical profile data.
- Stores all data persistently in a SQLite database.
- Automatically updates data every 24 hours.
## Setup ```ini
NICK=your_nick
PASS=your_password
```
1. **Clone the repo:** 2. Save this `compose.yaml`:
```bash ```yaml
git clone https://github.com/skidoodle/ncore-stats services:
cd ncore-stats ncore-stats:
``` image: ghcr.io/skidoodle/ncore-stats:latest
container_name: ncore-stats
restart: unless-stopped
user: "1000:1000"
ports:
- "3000:3000"
volumes:
- data:/app/data
configs:
- source: users_config
target: /app/users.txt
environment:
- NICK=${NICK}
- PASS=${PASS}
2. **Create a `.env` file with your nCore credentials:** configs:
users_config:
content: |
alice:123
bob:456
```ini volumes:
NICK=your_nick data:
PASS=your_password ```
```
3. **Add Users to Track** 3. Run `docker compose up -d`.
You can add users via the `--add-user` command-line flag. Run this command for each user you want to track. ### How to get NICK and PASS
The format is a single string: `'DisplayName,ProfileID'`. 1. Log in to nCore in your browser using **"lower security"** mode.
2. Open Developer Tools (F12) and go to the **Network** tab.
```bash 3. Refresh, find any request to `ncore.pro`.
# Example for running from source 4. Check the **Cookie** request header for `nick=...; pass=...`.
go run . --add-user 'Alice,69'
go run . --add-user 'Bob,420'
```
> **How to find a Profile ID?**
> Navigate to a user's profile on nCore. The URL will be `https://ncore.pro/profile.php?id=12345`. The `ProfileID` is the number at the end.
### How to obtain `NICK` and `PASS`
- Open the developer tools in your browser (F12), go to the "Network" tab.
- Log in to nCore using "lower security" mode.
- Find the `login.php` request in the network activity.
- In the response headers, locate the `Set-Cookie` header, which will contain `nick=` and `pass=` values.
- Copy those values and add them to your `.env` file.
## Running with Docker Compose
1. **Create the following `docker-compose.yml` file:**
```yaml
services:
ncore-stats:
image: ghcr.io/skidoodle/ncore-stats:latest
container_name: ncore-stats
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./data:/app/data
environment:
- NICK=${NICK}
- PASS=${PASS}
```
2. **Add Users using Docker**
When the container is already running, you can use `docker exec`:
```bash
# The executable inside the container is named 'ncore-stats'
docker exec ncore-stats ./ncore-stats --add-user 'Charlie,1337'
```
3. **Run the Docker Compose setup:**
Once you have added your users, start the service.
```bash
docker compose up -d
```
4. Open `http://localhost:3000` to view your stats.
### Updating
To pull the latest image and restart the service:
```bash
docker compose pull
docker compose up -d
```
+3 -4
View File
@@ -33,7 +33,7 @@
<article class="card"> <article class="card">
<div class="card-header"> <div class="card-header">
<h3>{{.Owner}}</h3> <h3>{{.Owner}}</h3>
<span class="rank-badge">RANK #{{.Rank}}</span> <span class="rank-badge">#{{.Rank}}</span>
</div> </div>
<div class="stats-group"> <div class="stats-group">
@@ -66,9 +66,8 @@
</button> </button>
</article> </article>
{{else}} {{else}}
<div <div class="empty-state">
style="grid-column: 1 / -1; padding: 4rem; text-align: center; color: var(--muted); border: 1px dashed var(--border);"> No profiles tracked. Mount users.txt to start.
No profiles found. Use the CLI to add users.
</div> </div>
{{end}} {{end}}
</div> </div>
+10 -17
View File
@@ -73,7 +73,17 @@ main {
gap: 1.5rem; gap: 1.5rem;
} }
.empty-state {
grid-column: 1 / -1;
padding: 4rem;
text-align: center;
color: var(--muted);
border: 1px dashed var(--border);
border-radius: 12px;
}
.card { .card {
position: relative;
background-color: var(--card-bg); background-color: var(--card-bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
@@ -168,23 +178,6 @@ main {
border-color: var(--accent); border-color: var(--accent);
} }
.btn-range {
background: var(--bg);
border: 1px solid var(--border);
color: var(--muted);
padding: 0.4rem 0.8rem;
font-size: 0.7rem;
font-weight: 700;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-range:hover {
color: #fff;
border-color: var(--muted);
}
.spinner-container { .spinner-container {
display: flex; display: flex;
justify-content: center; justify-content: center;