// Package gzev collects Guangzhou platform daily mileage without browser automation. package gzev import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "regexp" "strings" "time" ) const Origin = "https://gzev.d.lnoneos.com" const MileagePath = "/evdata-web/api/operation/queryHyVehicleMileageStatistics" var ErrAuth = errors.New("GZEV session expired; replace session file after interactive login") var decimalPattern = regexp.MustCompile(`^[0-9]{1,11}(\.[0-9]{1,3})?$`) // Decimal preserves source decimal precision and distinguishes missing values from zero. type Decimal struct { Value string Valid bool } func (d *Decimal) UnmarshalJSON(b []byte) error { *d = Decimal{} if string(b) == "null" { return nil } s := string(b) if len(s) > 0 && s[0] == '"' { if err := json.Unmarshal(b, &s); err != nil { return err } } if !decimalPattern.MatchString(s) { return errors.New("invalid nonnegative mileage decimal") } d.Value = s d.Valid = true return nil } func (d Decimal) MarshalJSON() ([]byte, error) { if !d.Valid { return []byte("null"), nil } return json.Marshal(d.Value) } func (d Decimal) SQLValue() any { if !d.Valid { return nil } return d.Value } type Row struct { VIN string `json:"vin"` Plate string `json:"plateNumber"` VehicleSeq string `json:"vehicleSeq"` ProductionCompany string `json:"productionCompany"` OperationCompany string `json:"operationCompany"` Brand string `json:"brandName"` Model string `json:"brandModel"` DateRange string `json:"dateRange"` Total Decimal `json:"runMileage"` Hydrogen Decimal `json:"hyRunMileage"` Charge Decimal `json:"chargeRunMileage"` Raw json.RawMessage `json:"-"` } type Day struct { Date string `json:"date"` Rows []Row `json:"rows"` Pages []json.RawMessage `json:"pages"` FetchedAt time.Time `json:"fetched_at"` } type SessionCookie struct { Name string `json:"name"` Value string `json:"value"` } type Session struct { Authorization string `json:"authorization"` Cookies []SessionCookie `json:"cookies"` } func ReadSession(path string) (Session, error) { var s Session info, err := os.Stat(path) if err != nil { return s, errors.New("session file unavailable") } if info.Mode().Perm()&0077 != 0 { return s, errors.New("session file must have permissions 0600") } b, err := os.ReadFile(path) if err != nil { return s, errors.New("cannot read session file") } if json.Unmarshal(b, &s) != nil { return s, errors.New("invalid session JSON") } return s, ValidateSession(s) } func ValidateSession(s Session) error { if !strings.HasPrefix(s.Authorization, "Bearer ") || len(s.Authorization) <= 7 || strings.ContainsAny(s.Authorization, "\r\n") { return errors.New("invalid session authorization") } seenCookies := map[string]bool{} for _, cookie := range s.Cookies { if seenCookies[cookie.Name] { return errors.New("duplicate session cookie") } seenCookies[cookie.Name] = true if cookie.Name == "token" && "Bearer "+cookie.Value != s.Authorization { return errors.New("token cookie and bearer mismatch") } if cookie.Name != "token" && cookie.Name != "bigs" { return errors.New("session contains unsupported cookie") } if strings.ContainsAny(cookie.Value, "\r\n;") { return errors.New("invalid session cookie") } } if !seenCookies["token"] { return errors.New("token cookie is required") } return nil } func ShanghaiToday(now time.Time) time.Time { loc := time.FixedZone("Asia/Shanghai", 8*3600) n := now.In(loc) return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc) } func ParseDate(s string) (time.Time, error) { return time.ParseInLocation("2006-01-02", s, time.FixedZone("Asia/Shanghai", 8*3600)) } func ValidateDate(s string, now time.Time) error { d, e := ParseDate(s) if e != nil || !d.Before(ShanghaiToday(now)) { return errors.New("date must be a completed Asia/Shanghai calendar day") } return nil } type Client struct { HTTP *http.Client Session Session baseURL string // Only tests override this; production credentials stay on the fixed HTTPS origin. PageSize int Delay time.Duration } func NewClient(s Session) *Client { return &Client{HTTP: &http.Client{Timeout: 45 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}, Session: s, baseURL: Origin, PageSize: 500, Delay: 500 * time.Millisecond} } func wait(ctx context.Context, d time.Duration) error { t := time.NewTimer(d) defer t.Stop() select { case <-ctx.Done(): return ctx.Err() case <-t.C: return nil } } func (c *Client) page(ctx context.Context, date string, page int) (json.RawMessage, error) { q := url.Values{"productionCompany": {""}, "operationCompany": {""}, "vin": {""}, "plateNumber": {""}, "brandModel": {""}, "queryBeginDate": {date}, "queryEndDate": {date}, "dateRange[0]": {date}, "dateRange[1]": {date}, "queryType": {"2"}, "showMonthlyColumn": {"false"}, "pageNum": {fmt.Sprint(page)}, "pageSize": {fmt.Sprint(c.PageSize)}} for attempt := 0; attempt < 3; attempt++ { if attempt > 0 { if err := wait(ctx, time.Duration(attempt*attempt)*time.Second); err != nil { return nil, err } } req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+MileagePath+"?"+q.Encode(), nil) if err != nil { return nil, err } req.Header.Set("Authorization", c.Session.Authorization) req.Header.Set("Accept", "application/json") for _, cookie := range c.Session.Cookies { req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value}) } resp, err := c.HTTP.Do(req) if err != nil { if ctx.Err() != nil { return nil, ctx.Err() } if attempt < 2 { continue } return nil, errors.New("GZEV network request failed") } b, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) resp.Body.Close() if resp.StatusCode == 401 || resp.StatusCode >= 300 && resp.StatusCode < 400 { return nil, ErrAuth } if resp.StatusCode == 429 || resp.StatusCode >= 500 { if attempt < 2 { continue } return nil, fmt.Errorf("GZEV temporary HTTP failure: %d", resp.StatusCode) } if resp.StatusCode != 200 { return nil, fmt.Errorf("GZEV HTTP failure: %d", resp.StatusCode) } if readErr != nil { return nil, errors.New("GZEV response read failed") } var envelope struct { Code json.RawMessage `json:"code"` } if json.Unmarshal(b, &envelope) != nil { return nil, errors.New("GZEV non-JSON response; login or upstream failure") } code := strings.Trim(string(envelope.Code), `"`) if code == "401" { return nil, ErrAuth } if code != "0" { if code == "00000004" || code == "403" { return nil, errors.New("GZEV account lacks endpoint permission") } return nil, errors.New("GZEV non-success business response") } return b, nil } return nil, errors.New("GZEV retries exhausted") } func (c *Client) FetchDay(ctx context.Context, date string) (Day, error) { day := Day{Date: date, FetchedAt: time.Now().UTC()} if err := wait(ctx, c.Delay); err != nil { return day, err } if err := ValidateDate(date, time.Now()); err != nil { return day, err } if c.PageSize < 1 || c.PageSize > 500 { return day, errors.New("page size must be 1..500") } seen := map[string]bool{} expected := -1 for page := 1; page <= 10000; page++ { if page > 1 { if err := wait(ctx, c.Delay); err != nil { return day, err } } b, err := c.page(ctx, date, page) if err != nil { return day, err } var env struct { Data *struct { PageNum int `json:"pageNum"` Total *int `json:"total"` List []json.RawMessage `json:"list"` } `json:"data"` } if json.Unmarshal(b, &env) != nil || env.Data == nil || env.Data.Total == nil || env.Data.PageNum != page { return day, errors.New("invalid pagination schema") } p := env.Data if expected < 0 { expected = *p.Total } if expected < 0 || expected > 1000000 || *p.Total != expected { return day, errors.New("source total changed or is invalid; retry day") } for _, raw := range p.List { var row Row if err := json.Unmarshal(raw, &row); err != nil { return day, fmt.Errorf("invalid mileage row: %w", err) } if len(row.VIN) != 17 || seen[row.VIN] || row.DateRange != date+" - "+date { return day, errors.New("invalid VIN, duplicate VIN, or non-daily date range") } row.Raw = raw seen[row.VIN] = true day.Rows = append(day.Rows, row) } day.Pages = append(day.Pages, b) if len(day.Rows) == expected { return day, nil } if len(day.Rows) > expected || len(p.List) == 0 { return day, errors.New("incomplete or inconsistent pagination") } } return day, errors.New("pagination safety limit exceeded") } // VerifyAccount prevents an operator importing another account's valid session into this dataset. func (c *Client) VerifyAccount(ctx context.Context, account string) error { q := url.Values{"token": {strings.TrimPrefix(c.Session.Authorization, "Bearer ")}} req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/evdata-sso/api/base/getUserInfo?"+q.Encode(), nil) if err != nil { return errors.New("cannot create identity request") } req.Header.Set("Authorization", c.Session.Authorization) for _, cookie := range c.Session.Cookies { req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value}) } resp, err := c.HTTP.Do(req) if err != nil { return errors.New("GZEV identity request failed") } defer resp.Body.Close() if resp.StatusCode == 401 || resp.StatusCode >= 300 && resp.StatusCode < 400 { return ErrAuth } if resp.StatusCode != 200 { return errors.New("GZEV identity HTTP failure") } var body struct { Code json.RawMessage `json:"code"` Data struct { LoginName string `json:"loginname"` } `json:"data"` } if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body) != nil { return errors.New("invalid identity response") } code := strings.Trim(string(body.Code), `"`) if code == "401" { return ErrAuth } if code != "0" || body.Data.LoginName != account { return errors.New("GZEV session account does not match configured account, or identity check denied") } return nil }