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.
262 lines
7.6 KiB
Go
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)
|
|
}
|