onesvm-browser-server/server/internal/store/session.go
chii 9b689b2476 feat: 落地节点指纹与 Cookie 罐,并按现网能力更新消费/接手文档
公开页抓取改为 Chrome 136 自洽身份 + 每节点 SQLite 养罐,Trafilatura 走 curl_cffi;MCP/README/接手说明与 09-02 现网复测对齐,避免消费方继续抄过期的站点三分表。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 17:04:25 +08:00

340 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package store
import (
"context"
"database/sql"
"fmt"
"time"
"onesvm.com/onesvm/browser-server/internal/config"
"onesvm.com/onesvm/browser-server/internal/contract"
"onesvm.com/onesvm/browser-server/internal/fingerprint"
)
// schemaV2 每节点模版 + Cookie 罐(P0/P1)。幂等 CREATE。
var schemaV2 = []string{
`CREATE TABLE IF NOT EXISTS fp_templates (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
impersonate TEXT NOT NULL,
user_agent TEXT NOT NULL,
sec_ch_ua TEXT NOT NULL,
sec_ch_ua_platform TEXT NOT NULL,
accept_language TEXT NOT NULL,
os TEXT NOT NULL,
chrome_major INTEGER NOT NULL,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_fp_templates_node ON fp_templates(node_id)`,
`CREATE TABLE IF NOT EXISTS fp_state (
node_id TEXT PRIMARY KEY,
active_id TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS session_cookies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_id TEXT NOT NULL,
adapter_class TEXT NOT NULL,
egress TEXT NOT NULL,
etld TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
domain TEXT NOT NULL,
path TEXT NOT NULL DEFAULT '/',
expires_at TEXT NOT NULL,
last_used_at TEXT NOT NULL,
bytes INTEGER NOT NULL,
UNIQUE(template_id, adapter_class, egress, etld, name, domain, path)
)`,
`CREATE INDEX IF NOT EXISTS idx_session_cookies_lookup
ON session_cookies(template_id, adapter_class, egress, etld)`,
`CREATE INDEX IF NOT EXISTS idx_session_cookies_expiry ON session_cookies(expires_at)`,
`CREATE INDEX IF NOT EXISTS idx_session_cookies_lru ON session_cookies(last_used_at)`,
}
func migrateV2(d *DB) error {
for _, stmt := range schemaV2 {
if _, err := d.raw.Exec(stmt); err != nil {
return fmt.Errorf("store: migration v2: %w (stmt=%s)", err, firstLine(stmt))
}
}
return nil
}
// EnsureNodeTemplates 首次为节点写入 TemplateCount 套模版并选定 active。
func (d *DB) EnsureNodeTemplates(ctx context.Context, nodeID string) (fingerprint.Profile, error) {
if err := ctx.Err(); err != nil {
return fingerprint.Profile{}, err
}
var n int
if err := d.raw.QueryRowContext(ctx,
`SELECT COUNT(*) FROM fp_templates WHERE node_id=?`, nodeID).Scan(&n); err != nil {
return fingerprint.Profile{}, fmt.Errorf("store: 数模版: %w", err)
}
now := config.FormatTime(config.Now())
if n == 0 {
set := fingerprint.AllocateSet(nodeID, fingerprint.TemplateCount)
for _, p := range set {
if _, err := d.raw.ExecContext(ctx, `INSERT INTO fp_templates
(id, node_id, impersonate, user_agent, sec_ch_ua, sec_ch_ua_platform,
accept_language, os, chrome_major, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
p.ID, nodeID, p.Impersonate, p.UserAgent, p.SecCHUA, p.SecCHUAPlatform,
p.AcceptLanguage, p.OS, p.ChromeMajor, now); err != nil {
return fingerprint.Profile{}, fmt.Errorf("store: 写模版: %w", err)
}
}
active := fingerprint.PickActive(nodeID, set)
if _, err := d.raw.ExecContext(ctx,
`INSERT INTO fp_state (node_id, active_id, updated_at) VALUES (?,?,?)`,
nodeID, active.ID, now); err != nil {
return fingerprint.Profile{}, fmt.Errorf("store: 写 active: %w", err)
}
return active, nil
}
var activeID string
err := d.raw.QueryRowContext(ctx, `SELECT active_id FROM fp_state WHERE node_id=?`, nodeID).Scan(&activeID)
if err == sql.ErrNoRows || activeID == "" {
set, err := d.ListTemplates(ctx, nodeID)
if err != nil {
return fingerprint.Profile{}, err
}
active := fingerprint.PickActive(nodeID, set)
if _, err := d.raw.ExecContext(ctx,
`INSERT INTO fp_state (node_id, active_id, updated_at) VALUES (?,?,?)
ON CONFLICT(node_id) DO UPDATE SET active_id=excluded.active_id, updated_at=excluded.updated_at`,
nodeID, active.ID, now); err != nil {
return fingerprint.Profile{}, fmt.Errorf("store: 补 active: %w", err)
}
return active, nil
}
if err != nil {
return fingerprint.Profile{}, fmt.Errorf("store: 读 active: %w", err)
}
return d.GetTemplate(ctx, activeID)
}
// ListTemplates 节点全部模版。
func (d *DB) ListTemplates(ctx context.Context, nodeID string) ([]fingerprint.Profile, error) {
rows, err := d.raw.QueryContext(ctx,
`SELECT id, impersonate, user_agent, sec_ch_ua, sec_ch_ua_platform,
accept_language, os, chrome_major
FROM fp_templates WHERE node_id=? ORDER BY id`, nodeID)
if err != nil {
return nil, fmt.Errorf("store: 列模版: %w", err)
}
defer rows.Close()
var out []fingerprint.Profile
for rows.Next() {
var p fingerprint.Profile
if err := rows.Scan(&p.ID, &p.Impersonate, &p.UserAgent, &p.SecCHUA, &p.SecCHUAPlatform,
&p.AcceptLanguage, &p.OS, &p.ChromeMajor); err != nil {
return nil, fmt.Errorf("store: 扫模版: %w", err)
}
p.SecCHUAMobile = "?0"
out = append(out, p)
}
return out, rows.Err()
}
// GetTemplate 按 id 取模版。
func (d *DB) GetTemplate(ctx context.Context, id string) (fingerprint.Profile, error) {
var p fingerprint.Profile
err := d.raw.QueryRowContext(ctx,
`SELECT id, impersonate, user_agent, sec_ch_ua, sec_ch_ua_platform,
accept_language, os, chrome_major
FROM fp_templates WHERE id=?`, id).Scan(
&p.ID, &p.Impersonate, &p.UserAgent, &p.SecCHUA, &p.SecCHUAPlatform,
&p.AcceptLanguage, &p.OS, &p.ChromeMajor)
if err != nil {
return p, fmt.Errorf("store: 读模版 %s: %w", id, err)
}
p.SecCHUAMobile = "?0"
return p, nil
}
// SwitchActive 快速切到已落库的另一套模版。
func (d *DB) SwitchActive(ctx context.Context, nodeID, templateID string) error {
now := config.FormatTime(config.Now())
_, err := d.raw.ExecContext(ctx,
`UPDATE fp_state SET active_id=?, updated_at=? WHERE node_id=?`,
templateID, now, nodeID)
if err != nil {
return fmt.Errorf("store: 切模版: %w", err)
}
return nil
}
// CookieKey 罐主键(模版 × 适配器档 × 出口 × 域)。
type CookieKey struct {
TemplateID string
AdapterClass string
Egress string
ETLD string
}
// LoadCookies 读一罐(未过期)。
func (d *DB) LoadCookies(ctx context.Context, k CookieKey) ([]contract.Cookie, error) {
now := config.FormatTime(config.Now())
rows, err := d.raw.QueryContext(ctx,
`SELECT name, value, domain, path, expires_at FROM session_cookies
WHERE template_id=? AND adapter_class=? AND egress=? AND etld=? AND expires_at>?`,
k.TemplateID, k.AdapterClass, k.Egress, k.ETLD, now)
if err != nil {
return nil, fmt.Errorf("store: 读罐: %w", err)
}
defer rows.Close()
var out []contract.Cookie
for rows.Next() {
var c contract.Cookie
var exp string
if err := rows.Scan(&c.Name, &c.Value, &c.Domain, &c.Path, &exp); err != nil {
return nil, fmt.Errorf("store: 扫罐: %w", err)
}
if t, err := config.ParseRFC3339(exp); err == nil {
c.Expires = t.Unix()
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(out) > 0 {
_, _ = d.raw.ExecContext(ctx,
`UPDATE session_cookies SET last_used_at=?
WHERE template_id=? AND adapter_class=? AND egress=? AND etld=?`,
now, k.TemplateID, k.AdapterClass, k.Egress, k.ETLD)
}
return out, nil
}
// ReplaceCookies 覆盖一域的罐(先删后插)。调用方已 Sanitize。
func (d *DB) ReplaceCookies(ctx context.Context, k CookieKey, cookies []contract.Cookie) error {
nowS := config.FormatTime(config.Now())
tx, err := d.raw.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: 开事务: %w", err)
}
defer tx.Rollback() // Commit 后幂等
if _, err := tx.ExecContext(ctx,
`DELETE FROM session_cookies WHERE template_id=? AND adapter_class=? AND egress=? AND etld=?`,
k.TemplateID, k.AdapterClass, k.Egress, k.ETLD); err != nil {
return fmt.Errorf("store: 清罐: %w", err)
}
for _, c := range cookies {
expS := nowS
if c.Expires > 0 {
expS = config.FormatTime(time.Unix(c.Expires, 0))
}
bytes := len(c.Name) + len(c.Value) + len(c.Domain) + len(c.Path)
if _, err := tx.ExecContext(ctx, `INSERT INTO session_cookies
(template_id, adapter_class, egress, etld, name, value, domain, path,
expires_at, last_used_at, bytes)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
k.TemplateID, k.AdapterClass, k.Egress, k.ETLD,
c.Name, c.Value, c.Domain, c.Path, expS, nowS, bytes); err != nil {
return fmt.Errorf("store: 插 Cookie: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("store: 提交罐: %w", err)
}
return nil
}
// DropCookies 整域丢罐(投毒 / 换出口)。
func (d *DB) DropCookies(ctx context.Context, k CookieKey) error {
_, err := d.raw.ExecContext(ctx,
`DELETE FROM session_cookies WHERE template_id=? AND adapter_class=? AND egress=? AND etld=?`,
k.TemplateID, k.AdapterClass, k.Egress, k.ETLD)
if err != nil {
return fmt.Errorf("store: 丢罐: %w", err)
}
return nil
}
// ReapCookies 过期删除 + 总字节/域数硬顶(LRU)。
func (d *DB) ReapCookies(ctx context.Context, maxETLD, maxBytes int) (int, error) {
now := config.FormatTime(config.Now())
res, err := d.raw.ExecContext(ctx, `DELETE FROM session_cookies WHERE expires_at<=?`, now)
if err != nil {
return 0, fmt.Errorf("store: 收过期罐: %w", err)
}
n, _ := res.RowsAffected()
cut, err := d.enforceJarCaps(ctx, maxETLD, maxBytes)
return int(n) + cut, err
}
func (d *DB) enforceJarCaps(ctx context.Context, maxETLD, maxBytes int) (int, error) {
cut := 0
// 按 (template,adapter,egress) 组限制域数。
rows, err := d.raw.QueryContext(ctx,
`SELECT template_id, adapter_class, egress, etld, MAX(last_used_at) AS lu
FROM session_cookies
GROUP BY template_id, adapter_class, egress, etld
ORDER BY template_id, adapter_class, egress, lu ASC`)
if err != nil {
return 0, fmt.Errorf("store: 域统计: %w", err)
}
type grp struct{ tpl, cls, eg, etld string }
counts := map[string][]grp{}
for rows.Next() {
var g grp
var lu string
if err := rows.Scan(&g.tpl, &g.cls, &g.eg, &g.etld, &lu); err != nil {
rows.Close()
return cut, err
}
key := g.tpl + "\x00" + g.cls + "\x00" + g.eg
counts[key] = append(counts[key], g)
}
if err := rows.Err(); err != nil {
rows.Close()
return cut, err
}
rows.Close()
for _, list := range counts {
if len(list) <= maxETLD {
continue
}
overflow := list[:len(list)-maxETLD] // 已按 lu ASC,最旧在前
for _, g := range overflow {
r, err := d.raw.ExecContext(ctx,
`DELETE FROM session_cookies WHERE template_id=? AND adapter_class=? AND egress=? AND etld=?`,
g.tpl, g.cls, g.eg, g.etld)
if err != nil {
return cut, fmt.Errorf("store: LRU 域: %w", err)
}
c, _ := r.RowsAffected()
cut += int(c)
}
}
var total int
if err := d.raw.QueryRowContext(ctx, `SELECT COALESCE(SUM(bytes),0) FROM session_cookies`).Scan(&total); err != nil {
return cut, fmt.Errorf("store: 字节合计: %w", err)
}
for total > maxBytes {
var tpl, cls, eg, etld string
err := d.raw.QueryRowContext(ctx,
`SELECT template_id, adapter_class, egress, etld FROM session_cookies
ORDER BY last_used_at ASC LIMIT 1`).Scan(&tpl, &cls, &eg, &etld)
if err == sql.ErrNoRows {
break
}
if err != nil {
return cut, fmt.Errorf("store: 找最旧域: %w", err)
}
r, err := d.raw.ExecContext(ctx,
`DELETE FROM session_cookies WHERE template_id=? AND adapter_class=? AND egress=? AND etld=?`,
tpl, cls, eg, etld)
if err != nil {
return cut, err
}
c, _ := r.RowsAffected()
cut += int(c)
if err := d.raw.QueryRowContext(ctx, `SELECT COALESCE(SUM(bytes),0) FROM session_cookies`).Scan(&total); err != nil {
return cut, err
}
}
return cut, nil
}