Files
Joren 25410749db Initial Marktplaats SDK scaffold
Clone the twdehands SDK into mrktplaats and retarget naming and defaults for app.marktplaats.nl while keeping the same request/response bodies and endpoint behavior.
2026-04-15 23:45:49 +02:00

262 lines
7.6 KiB
Go

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)
}