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.
This commit is contained in:
+261
@@ -0,0 +1,261 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user