189 lines
5.1 KiB
Go
189 lines
5.1 KiB
Go
package analytics
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Config struct {
|
|
Mode string
|
|
APIURL string
|
|
Credential string
|
|
WebsiteID string
|
|
}
|
|
|
|
type Stats struct {
|
|
Pageviews int
|
|
Visitors int
|
|
Visits int
|
|
Bounces int
|
|
TotalTime int64
|
|
}
|
|
|
|
type Point struct {
|
|
X string `json:"x"`
|
|
Y int `json:"y"`
|
|
}
|
|
|
|
type Client struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewClient() *Client {
|
|
return &Client{httpClient: &http.Client{Timeout: 5 * time.Second}}
|
|
}
|
|
|
|
func (c *Client) GetStats(ctx context.Context, cfg Config, startAt, endAt time.Time) (Stats, error) {
|
|
var response struct {
|
|
Pageviews json.RawMessage `json:"pageviews"`
|
|
Visitors json.RawMessage `json:"visitors"`
|
|
Visits json.RawMessage `json:"visits"`
|
|
Bounces json.RawMessage `json:"bounces"`
|
|
TotalTime json.RawMessage `json:"totaltime"`
|
|
}
|
|
if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/stats", startAt, endAt, "", &response); err != nil {
|
|
return Stats{}, err
|
|
}
|
|
return Stats{
|
|
Pageviews: rawNumber(response.Pageviews),
|
|
Visitors: rawNumber(response.Visitors),
|
|
Visits: rawNumber(response.Visits),
|
|
Bounces: rawNumber(response.Bounces),
|
|
TotalTime: int64(rawNumber(response.TotalTime)),
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) GetActive(ctx context.Context, cfg Config) (int, error) {
|
|
var response struct {
|
|
Visitors int `json:"visitors"`
|
|
}
|
|
if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/active", time.Time{}, time.Time{}, "", &response); err != nil {
|
|
return 0, err
|
|
}
|
|
return response.Visitors, nil
|
|
}
|
|
|
|
func (c *Client) GetPageviews(ctx context.Context, cfg Config, startAt, endAt time.Time) ([]Point, error) {
|
|
var response struct {
|
|
Pageviews []Point `json:"pageviews"`
|
|
}
|
|
if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/pageviews", startAt, endAt, "day", &response); err != nil {
|
|
return nil, err
|
|
}
|
|
return response.Pageviews, nil
|
|
}
|
|
|
|
func (c *Client) GetMetrics(ctx context.Context, cfg Config, startAt, endAt time.Time, metricType string) ([]Point, error) {
|
|
var response []Point
|
|
if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/metrics", startAt, endAt, metricType, &response); err != nil {
|
|
return nil, err
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func (c *Client) Test(ctx context.Context, cfg Config) error {
|
|
if strings.TrimSpace(cfg.WebsiteID) == "" {
|
|
return errors.New("Umami Website ID 不能为空")
|
|
}
|
|
if strings.TrimSpace(cfg.Credential) == "" {
|
|
return errors.New("Umami API 凭据未配置")
|
|
}
|
|
_, err := c.GetStats(ctx, cfg, time.Now().Add(-24*time.Hour), time.Now())
|
|
return err
|
|
}
|
|
|
|
func (c *Client) get(ctx context.Context, cfg Config, path string, startAt, endAt time.Time, unitOrType string, target any) error {
|
|
baseURL, err := normalizeBaseURL(cfg.Mode, cfg.APIURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
requestURL, err := url.Parse(baseURL + path)
|
|
if err != nil {
|
|
return errors.New("Umami API 地址无效")
|
|
}
|
|
query := requestURL.Query()
|
|
if !startAt.IsZero() {
|
|
query.Set("startAt", strconv.FormatInt(startAt.UnixMilli(), 10))
|
|
}
|
|
if !endAt.IsZero() {
|
|
query.Set("endAt", strconv.FormatInt(endAt.UnixMilli(), 10))
|
|
}
|
|
if strings.HasSuffix(path, "/pageviews") && unitOrType != "" {
|
|
query.Set("unit", unitOrType)
|
|
}
|
|
if strings.HasSuffix(path, "/metrics") && unitOrType != "" {
|
|
query.Set("type", unitOrType)
|
|
}
|
|
requestURL.RawQuery = query.Encode()
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil)
|
|
if err != nil {
|
|
return errors.New("创建 Umami 请求失败")
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
if cfg.Mode == "cloud" {
|
|
request.Header.Set("x-umami-api-key", cfg.Credential)
|
|
} else {
|
|
request.Header.Set("Authorization", "Bearer "+cfg.Credential)
|
|
}
|
|
response, err := c.httpClient.Do(request)
|
|
if err != nil {
|
|
return fmt.Errorf("Umami 请求失败: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return fmt.Errorf("Umami 返回 HTTP %d", response.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
|
|
if err != nil {
|
|
return errors.New("读取 Umami 响应失败")
|
|
}
|
|
if err := json.Unmarshal(body, target); err != nil {
|
|
return errors.New("解析 Umami 响应失败")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeBaseURL(mode, raw string) (string, error) {
|
|
base := strings.TrimRight(strings.TrimSpace(raw), "/")
|
|
if base == "" && mode == "cloud" {
|
|
base = "https://api.umami.is/v1"
|
|
}
|
|
if base == "" {
|
|
return "", errors.New("Umami API 地址不能为空")
|
|
}
|
|
parsed, err := url.Parse(base)
|
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
|
return "", errors.New("Umami API 地址必须是有效的 http/https 地址")
|
|
}
|
|
if mode == "cloud" && !strings.HasSuffix(parsed.Path, "/v1") {
|
|
base += "/v1"
|
|
}
|
|
return strings.TrimRight(base, "/"), nil
|
|
}
|
|
|
|
func rawNumber(raw json.RawMessage) int {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return 0
|
|
}
|
|
var number float64
|
|
if json.Unmarshal(raw, &number) == nil {
|
|
return int(number)
|
|
}
|
|
var wrapped struct {
|
|
Value float64 `json:"value"`
|
|
}
|
|
if json.Unmarshal(raw, &wrapped) == nil {
|
|
return int(wrapped.Value)
|
|
}
|
|
return 0
|
|
}
|