package mrktplaats import ( "bytes" "compress/gzip" "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "math/big" "net/http" "net/url" ) const ( defaultBaseURL = "https://app.marktplaats.nl" apiVersion = "3.17" appVersion = "Android15.55.0" userAgent = "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) AppVersion-15.55.0" screenWidth = "62" screenHeight = "110" // anonymousToken is the pre-login bearer token required by the API before authentication. // It must be present on every request, including the login endpoint itself. anonymousToken = "94pq3HEkWiGippif6RkfsXAXxi8rHQsBX7bTaky67fCX6rHqW8DQtEQHUf4ansTo4pu8bKbbWW8Po3XWpeAio422bhFN6DLJz3VH" ) type transport struct { baseURL string httpClient *http.Client accessToken string session string gaClientID string magicNumber string threatMetrixSessionID string userState int // 0 = anonymous, 1 = logged in } func newTransport() *transport { return &transport{ baseURL: defaultBaseURL, accessToken: anonymousToken, userState: 0, session: generateSessionID(), gaClientID: generateGAClientID(), magicNumber: generateMagicNumber(), threatMetrixSessionID: generateThreatMetrixSessionID(), httpClient: &http.Client{ Transport: &http.Transport{ DisableCompression: false, }, }, } } // ── session parameter generation ────────────────────────────────────────────── func generateSessionID() string { b := make([]byte, 16) rand.Read(b) b[6] = (b[6] & 0x0f) | 0x40 b[8] = (b[8] & 0x3f) | 0x80 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) } func generateGAClientID() string { b := make([]byte, 32) rand.Read(b) h := sha256.Sum256(b) return hex.EncodeToString(h[:]) } func generateMagicNumber() string { max := new(big.Int).SetUint64(9_999_999_999_999_999_999) n, _ := rand.Int(rand.Reader, max) return n.String() } func generateThreatMetrixSessionID() string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789" b := make([]byte, 32) rand.Read(b) for i := range b { b[i] = chars[b[i]%byte(len(chars))] } return string(b) } // ── token management ────────────────────────────────────────────────────────── func (t *transport) setAccessToken(token string) { t.accessToken = token if token != "" && token != anonymousToken { t.userState = 1 } else { t.userState = 0 } } func (t *transport) setThreatMetrixSessionID(id string) { t.threatMetrixSessionID = id } // ── request building ────────────────────────────────────────────────────────── func (t *transport) commonParams() url.Values { v := url.Values{} v.Set("api_ver", apiVersion) v.Set("app_ver", appVersion) v.Set("user_state", fmt.Sprintf("%d", t.userState)) v.Set("screenWidth", screenWidth) v.Set("screenHeight", screenHeight) if t.session != "" { v.Set("session", t.session) } if t.gaClientID != "" { v.Set("gaClientId", t.gaClientID) } if t.magicNumber != "" { v.Set("magic_number", t.magicNumber) } return v } func (t *transport) setHeaders(req *http.Request) { req.Header.Set("User-Agent", userAgent) req.Header.Set("Ecg-Tenant", "TWH") req.Header.Set("Ecg-Locale", "nl-BE") req.Header.Set("Ecg-Ui-Lang", "nl") req.Header.Set("Accept-Encoding", "gzip, deflate, br") req.Header.Set("Accept", "application/json") if t.accessToken != "" { req.Header.Set("Authorization", "Bearer "+t.accessToken) } if t.threatMetrixSessionID != "" { req.Header.Set("X-Threatmetrix-Session-Id", t.threatMetrixSessionID) } } func (t *transport) mergeParams(extra url.Values) url.Values { base := t.commonParams() for k, vs := range extra { for _, v := range vs { base.Set(k, v) } } return base } func (t *transport) buildURL(path string, params url.Values) string { return t.baseURL + path + "?" + t.mergeParams(params).Encode() } // ── HTTP methods ────────────────────────────────────────────────────────────── func (t *transport) do(req *http.Request, dst any) error { t.setHeaders(req) resp, err := t.httpClient.Do(req) if err != nil { return fmt.Errorf("mrktplaats: request failed: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("mrktplaats: reading response: %w", err) } // Decompress gzip if the server returned it without a Content-Encoding header. if len(body) >= 2 && body[0] == 0x1f && body[1] == 0x8b { r, err := gzip.NewReader(bytes.NewReader(body)) if err != nil { return fmt.Errorf("mrktplaats: creating gzip reader: %w", err) } defer r.Close() body, err = io.ReadAll(r) if err != nil { return fmt.Errorf("mrktplaats: decompressing response: %w", err) } } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return &APIError{StatusCode: resp.StatusCode, Body: body} } if dst != nil && len(body) > 0 { if err := json.Unmarshal(body, dst); err != nil { return fmt.Errorf("mrktplaats: decoding response: %w", err) } } return nil } func (t *transport) get(ctx context.Context, path string, params url.Values, dst any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.buildURL(path, params), nil) if err != nil { return err } return t.do(req, dst) } // extraHeaders are applied after setHeaders, so they override transport-level headers. func (t *transport) postJSON(ctx context.Context, path string, params url.Values, extraHeaders http.Header, body any, dst any) error { var buf bytes.Buffer if body != nil { if err := json.NewEncoder(&buf).Encode(body); err != nil { return fmt.Errorf("mrktplaats: encoding request: %w", err) } } req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.buildURL(path, params), &buf) if err != nil { return err } req.Header.Set("Content-Type", "application/json; charset=UTF-8") for k, vs := range extraHeaders { for _, v := range vs { req.Header.Set(k, v) } } return t.do(req, dst) } func (t *transport) delete(ctx context.Context, path string, params url.Values, dst any) error { req, err := http.NewRequestWithContext(ctx, http.MethodDelete, t.buildURL(path, params), nil) if err != nil { return err } return t.do(req, dst) } func (t *transport) deleteJSON(ctx context.Context, path string, params url.Values, body any, dst any) error { var buf bytes.Buffer if body != nil { if err := json.NewEncoder(&buf).Encode(body); err != nil { return fmt.Errorf("mrktplaats: encoding request: %w", err) } } req, err := http.NewRequestWithContext(ctx, http.MethodDelete, t.buildURL(path, params), &buf) if err != nil { return err } req.Header.Set("Content-Type", "application/json; charset=UTF-8") return t.do(req, dst) } func (t *transport) postMultipart(ctx context.Context, path string, params url.Values, extraHeaders http.Header, contentType string, body io.Reader, dst any) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.buildURL(path, params), body) if err != nil { return err } req.Header.Set("Content-Type", contentType) for k, vs := range extraHeaders { for _, v := range vs { req.Header.Set(k, v) } } return t.do(req, dst) }