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:
2026-04-15 23:45:49 +02:00
commit 25410749db
27 changed files with 3481 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
.test-token
mrktplaats
+413
View File
@@ -0,0 +1,413 @@
# mrktplaats
Go SDK for the [marktplaats.nl](https://www.marktplaats.nl) mobile API.
## Installation
```bash
go get github.com/joren/mrktplaats
```
## Quick start
```go
package main
import (
"context"
"fmt"
"log"
"github.com/joren/mrktplaats"
)
func main() {
ctx := context.Background()
// Unauthenticated client (search, browse)
anon := mrktplaats.NewClient()
results, err := anon.Search.Fetch(ctx, &mrktplaats.SearchRequest{
Query: "fiets", Page: 1, Size: 10, ShowListings: true,
}, nil)
if err != nil {
log.Fatal(err)
}
for _, item := range results.Items {
if l := item.Listing(); l != nil {
fmt.Printf("%s — %s\n", l.AdCore.URN, l.AdCore.Title)
}
}
// Authenticated client
client := mrktplaats.NewClient()
login, err := client.Auth.Login(ctx, "email@example.com", "password")
if err != nil {
log.Fatal(err)
}
// handle 2FA if needed (login.Verification != nil)
_ = login
// Or use a saved token directly
authed := mrktplaats.NewClient(mrktplaats.WithAccessToken("your-token"))
me, err := authed.Users.Me(ctx)
fmt.Printf("Logged in as %s\n", me.Name)
}
```
## Client options
| Option | Description |
|--------|-------------|
| `WithAccessToken(token)` | Set a pre-existing access token |
| `WithHTTPClient(hc)` | Provide a custom `*http.Client` |
| `WithBaseURL(url)` | Override the base URL (default: `https://app.marktplaats.nl`) |
| `WithSession(id)` | Set the `x-adevinta-session-id` header |
| `WithGAClientID(id)` | Set the `x-adevinta-ga-client-id` header |
---
## Services
### Auth — `client.Auth`
#### `Login(ctx, email, password) (*LoginResponse, error)`
Authenticates with email + password. If 2FA is required the response has `Verification != nil`.
```go
login, err := client.Auth.Login(ctx, "email", "pass")
if login.Verification != nil {
// prompt for SMS code
auth, err := client.Auth.VerifyCode(ctx, login.Verification.RequestID, "123456")
client.SetAccessToken(auth.Auth.AccessToken)
}
```
#### `VerifyCode(ctx, requestID, code) (*AuthResponse, error)`
Completes SMS 2FA. `AuthResponse.Auth.AccessToken` is the bearer token.
---
### Search — `client.Search` / `anon.Search`
#### `Fetch(ctx, req, supportedTypes) (*SearchResponse, error)`
Full listing search with filters.
```go
resp, err := anon.Search.Fetch(ctx, &mrktplaats.SearchRequest{
Query: "laptop",
CategoryID: intPtr(377), // optional
Page: 1,
Size: 20,
SortBy: "SORT_INDEX", // or "DATE_DESC", "PRICE_ASC", "PRICE_DESC"
AllowCorrection: true,
SearchOnTitleAndDescription: true,
ShowListings: true,
Languages: []string{"nl-BE"},
}, nil)
for _, item := range resp.Items {
if l := item.Listing(); l != nil {
fmt.Printf("%s €%.2f %s\n",
l.AdCore.URN, float64(l.AdCore.Price.PriceAmount)/100, l.AdCore.Title)
}
}
```
`SearchItem.Listing()` returns the listing or `nil` for non-listing items (ads, banners).
#### `KeywordSuggestions(ctx, prefix, categoryID) (*KeywordSuggestionsResponse, error)`
Auto-complete suggestions for search input.
#### `DisplayTargeting(ctx, req) ([]DisplayTargetingResult, error)`
Fetch ad targeting slots for the search results page.
---
### Listings — `client.Listings` / `anon.Listings`
#### `Get(ctx, urn) (*ListingDetail, error)`
Fetch full listing details (title, description, pictures, seller, bids, attributes).
```go
detail, err := client.Listings.Get(ctx, "m2372861012")
fmt.Printf("Seller: %s (ID %d)\n", detail.SellerInformation.Name, detail.SellerInformation.ID)
fmt.Printf("Min bid: €%.2f\n", float64(detail.CurrentMinimumBid)/100)
```
Key fields:
- `detail.AdCore.URN` — listing identifier
- `detail.AdCore.Title` / `.Description` — listing text
- `detail.AdCore.Price.PriceType` — `"FIXED"`, `"MIN_BID"`, `"FAST_BID"`, `"FREE"`, `"ON_REQUEST"`
- `detail.AdCore.Price.PriceAmount` — price in euro cents
- `detail.AdCore.Pictures` — slice of `Picture` with URLs at various sizes
- `detail.SellerInformation.ID` — seller's user ID (needed for `Relevant.Get`)
- `detail.Bids` — current bids
- `detail.CurrentMinimumBid` — minimum next bid in cents
#### `DisplayTargeting(ctx, req) ([]DisplayTargetingResult, error)`
Fetch ad targeting slots for the listing detail page.
---
### Discovery — `anon.Discovery`
#### `Feeds(ctx, desiredCount) (*FeedsResponse, error)`
Returns homepage discovery feed metadata (feed IDs, titles).
#### `FeedListings(ctx, feedID, opts) (*FeedListingsResponse, error)`
Returns listings for a discovery feed (e.g. `"f8"` = "Voor jou").
```go
resp, err := anon.Discovery.FeedListings(ctx, "f8", &mrktplaats.FeedListingsOptions{
Size: 40, PageLocation: "HOMEPAGE_DISCOVERY",
})
```
---
### Categories — `anon.Categories`
#### `All(ctx) (*CategoriesResponse, error)`
Returns the full category tree.
#### `Buckets(ctx, categoryID) ([]CategoryBucket, error)`
Returns sub-category buckets for a category ID.
---
### Messaging — `client.Messaging`
#### `Conversations(ctx, opts) (*ConversationsResponse, error)`
Lists all conversations for the authenticated user.
```go
resp, err := client.Messaging.Conversations(ctx, &mrktplaats.ConversationsOptions{Limit: 50})
for _, conv := range resp.Conversations {
fmt.Printf("[%s] %q with=%s unread=%d\n",
conv.ID, conv.Title, conv.OtherParticipant.Name, conv.UnreadMessagesCount)
}
```
`conv.ItemID` is the listing URN the conversation is about.
`conv.LatestMessage()` returns the most recent `*Message` or nil.
#### `Messages(ctx, conversationID, offset, limit) (*MessagesResponse, error)`
Fetches messages in a conversation.
#### `StartConversation(ctx, itemURN, text) (string, error)`
Starts a new conversation about a listing. Returns the conversation ID.
#### `SendMessage(ctx, conversationID, text) (string, error)`
Sends a message in an existing conversation. Returns the message ID.
#### `ServerTime(ctx) (time.Time, error)` / `EmailSubscriptions(ctx) (json.RawMessage, error)`
---
### Favorites — `client.Favorites`
#### `List(ctx, limit) (*FavoritesResponse, error)`
Lists saved/favourite listings. Each `FavoriteItem` has `ItemID` and `CreationDate`.
#### `Add(ctx, urns...) ([]FavoriteItem, error)`
Adds one or more listings to favourites.
#### `Remove(ctx, urns...) error`
Removes one or more listings from favourites.
---
### Users — `client.Users`
#### `Me(ctx) (*User, error)`
Returns the authenticated user's profile (ID, name, email, postcode).
#### `Reviews(ctx, userID, role) (*ReviewsResponse, error)`
Returns reviews for a user.
```go
resp, err := client.Users.Reviews(ctx, userID, mrktplaats.ReviewRoleReviewee)
fmt.Printf("%.1f / 5 (%d reviews)\n", resp.Summary.AverageScore, resp.Summary.NumberOfReviews)
```
`role` is one of `ReviewRoleAll`, `ReviewRoleReviewee`.
---
### MyAccount — `client.MyAccount`
#### `MyAds(ctx, opts) (*MyAdsResponse, error)`
Lists the authenticated user's own listings.
```go
resp, err := client.MyAccount.MyAds(ctx, &mrktplaats.MyAdsOptions{
Status: "active", // "active", "inactive", "sold"
Limit: 50,
Counts: true,
})
```
#### `DeleteAds(ctx, itemIDs, reason) error`
Removes listings. `reason` is one of:
- `DeleteReasonSoldOnTweedehands` (1)
- `DeleteReasonSoldElsewhere` (2)
- `DeleteReasonNotSelling` (3)
- `DeleteReasonOther` (4)
#### `UpdateProfile(ctx, req) error`
Updates name, phone number, postcode, or NDFC declaration status.
---
### SYI (Sell Your Item) — `client.SYI`
#### `Form(ctx, categoryID) (*SYIFormResponse, error)`
Returns the listing creation form (attributes, supported values) for a category.
#### `UploadImage(ctx, img, filename) (string, error)`
Uploads an image and returns its `pictureId`. **Large images are automatically compressed** to stay within the API's 900 KB size limit.
```go
f, _ := os.Open("photo.jpg")
defer f.Close()
pictureID, err := client.SYI.UploadImage(ctx, f, "photo.jpg")
```
#### `Create(ctx, req) (*CreateAdResponse, error)`
Publishes a new listing. Returns `CreateAdResponse`; use `.URN()` to get the new listing's ID.
```go
resp, err := client.SYI.Create(ctx, &mrktplaats.CreateAdRequest{
CategoryID: 1831, // Horloges | Heren
Translations: []mrktplaats.Translation{{
Locale: "nl-BE",
Title: "Seiko SARV001",
Description: "Automatisch horloge in goede staat.",
}},
PriceInCents: 25000, // €250
PriceType: "FIXED",
PictureIDs: []string{pictureID},
DeliveryMethod: "Ophalen",
ShippingConfig: mrktplaats.ShippingConfig{Carriers: []string{}},
SellerName: "Jonathan",
Postcode: "8000",
SelectedBundle: "FREE",
SYISessionID: fmt.Sprintf("session-%d", time.Now().UnixMilli()),
IntegratedShippingForTWHEnabled: true,
})
fmt.Println(resp.URN()) // "m2373048475"
```
#### `PriceSuggestion(ctx, categoryID, title) (*PriceSuggestionResponse, error)`
Returns price range suggestions (low/mid/high) based on similar listings.
#### `AttributeSuggestions(ctx, categoryID, text) (json.RawMessage, error)`
Suggests attribute values (brand, condition, etc.) from a description.
#### `SuspiciousKeywordsWarnings(ctx, categoryID, text) (json.RawMessage, error)`
Checks a description for policy-violating keywords.
---
### Saleability — `client.Saleability`
#### `Recognize(ctx, img, filename) (*RecognizeResponse, error)`
Runs image recognition to predict the category and attributes for a photo. **Large images are automatically compressed.**
```go
f, _ := os.Open("photo.jpg")
resp, err := client.Saleability.Recognize(ctx, f, "photo.jpg")
for _, p := range resp.Predictions {
fmt.Printf("%s (%.0f%% confidence)\n", p.CategoryName, p.Confidence*100)
}
```
---
### Enquiry (Bidding) — `client.Enquiry`
#### `PlaceBid(ctx, itemURN, req) (*BidsResponse, error)`
Places a bid. Value is in **euro cents**.
```go
resp, err := client.Enquiry.PlaceBid(ctx, "m2372861012", &mrktplaats.PlaceBidRequest{
Value: 2000, // €20.00
PersonalMessage: "Graag een bod van €20",
})
// resp.Bids contains all bids, resp.CurrentMinimumBid is the new minimum
```
#### `RemoveBid(ctx, bidID) (*BidsResponse, error)`
Removes a bid by its numeric ID.
---
### Relevant — `anon.Relevant`
#### `Get(ctx, listingURN, sellerID, categoryID) (*SimilarAdsResponse, error)`
Returns similar/related listings. `sellerID` is required (use `detail.SellerInformation.ID` from `Listings.Get`).
---
### Notifications — `client.Notifications`
#### `UnreadCount(ctx) (*UnreadCountResponse, error)`
Returns the number of unread notifications.
---
### Payments — `client.Payments`
#### `CartItemCount(ctx) (*PaymentCartCountResponse, error)`
Returns the number of items in the payment cart.
---
### Config — `client.Config`
#### `Labs(ctx) (*LabsConfig, error)`
Returns A/B and feature switch configuration.
---
## Error handling
```go
detail, err := client.Listings.Get(ctx, urn)
if err != nil {
if mrktplaats.IsNotFound(err) {
// 404 — listing doesn't exist
}
if mrktplaats.IsUnauthorized(err) {
// 401 — token expired or invalid
}
// generic: err contains status code and response body
}
```
`*APIError` exposes `StatusCode int` and `Body []byte` for custom handling.
---
## Image compression
Both `SYI.UploadImage` and `Saleability.Recognize` automatically compress images that exceed 900 KB before uploading. The compression:
1. Resizes the image (starting at 1200 px max dimension, stepping down by 25% each iteration)
2. Re-encodes as JPEG at quality 80
3. Stops when the result is under 900 KB
Unsupported image formats are passed through unchanged.
---
## Running integration tests
```bash
# First run — performs a full login with 2FA prompt
go test -v -tags integration -run TestIntegration -timeout 180s
# Subsequent runs — reuses the saved token from .test-token
go test -v -tags integration -run TestIntegration -timeout 180s
```
Tests cover all 16 service areas against the live API.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 MiB

+115
View File
@@ -0,0 +1,115 @@
package mrktplaats
import (
"context"
"fmt"
)
// AuthService handles login and two-factor authentication.
type AuthService struct {
t *transport
}
// LoginRequest is the payload for initiating a login.
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Type string `json:"type"`
VerificationParameters verificationParameters `json:"verificationParameters"`
}
type verificationParameters struct {
MobileTokenForSMS *string `json:"mobileTokenForSms"`
}
// LoginResponse is returned from a login attempt. If 2FA is required,
// Verification is populated. If login succeeds immediately, Auth is populated.
type LoginResponse struct {
Verification *Verification `json:"verification,omitempty"`
Auth *AuthToken `json:"auth,omitempty"`
User *User `json:"user,omitempty"`
}
// Verification contains the 2FA challenge details.
type Verification struct {
RequestID string `json:"requestId"`
Method string `json:"method"`
Message string `json:"message"`
}
// AuthToken contains the access and refresh tokens.
type AuthToken struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresIn string `json:"expiresIn"`
}
// AuthResponse is the full response after successful authentication.
type AuthResponse struct {
Auth AuthToken `json:"auth"`
User User `json:"user"`
}
// Login initiates authentication with email and password.
// If 2FA is required, the response contains a Verification with a RequestID
// that must be passed to VerifyCode.
func (s *AuthService) Login(ctx context.Context, email, password string) (*LoginResponse, error) {
body := LoginRequest{
Email: email,
Password: password,
Type: "user-credentials",
VerificationParameters: verificationParameters{MobileTokenForSMS: nil},
}
var resp LoginResponse
if err := s.t.postJSON(ctx, "/app/identity/v3/login", nil, nil, body, &resp); err != nil {
return nil, err
}
if resp.Auth != nil {
s.t.setAccessToken(resp.Auth.AccessToken)
}
return &resp, nil
}
// RefreshToken exchanges a refresh token for a new access token without requiring
// the user to re-enter their credentials. The response contains a fresh AuthToken
// with a new accessToken, refreshToken, and expiresIn.
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*AuthToken, error) {
body := struct {
RefreshToken string `json:"refreshToken"`
}{RefreshToken: refreshToken}
var resp struct {
Auth AuthToken `json:"auth"`
}
if err := s.t.postJSON(ctx, "/app/identity/v3/refreshtoken", nil, nil, body, &resp); err != nil {
return nil, err
}
s.t.setAccessToken(resp.Auth.AccessToken)
return &resp.Auth, nil
}
// VerifyCode completes 2FA by submitting the SMS verification code.
// On success the access token is automatically set on the client.
func (s *AuthService) VerifyCode(ctx context.Context, requestID, code string) (*AuthResponse, error) {
path := fmt.Sprintf("/app/identity/v3/two-factor-auth/verification/requests/%s/verify-code", requestID)
body := struct {
Key *string `json:"key"`
MagicToken *string `json:"magicToken"`
VerificationCode string `json:"verificationCode"`
VerificationParameters verificationParameters `json:"verificationParameters"`
}{
VerificationCode: code,
}
var resp AuthResponse
if err := s.t.postJSON(ctx, path, nil, nil, body, &resp); err != nil {
return nil, err
}
s.t.setAccessToken(resp.Auth.AccessToken)
return &resp, nil
}
+64
View File
@@ -0,0 +1,64 @@
package mrktplaats
import (
"context"
"fmt"
)
// CategoryService handles category browsing.
type CategoryService struct {
t *transport
}
// Category represents a node in the category tree (snake_case API3 format).
type Category struct {
CategoryID int `json:"category_id"`
Name string `json:"name"`
Children []Category `json:"children"`
PlaceAdAllowed bool `json:"place_ad_allowed"`
NumberOfImages int `json:"number_of_images"`
RunningSubscriptions []any `json:"running_subscriptions"`
FeaturesDuration map[string]int `json:"features_duration"`
SYIAttributes []any `json:"syi_attributes"`
}
// CategoriesResponse is the full category tree.
type CategoriesResponse struct {
Categories []Category `json:"categories"`
}
// CategoryBucket groups related subcategories.
type CategoryBucket struct {
ID int `json:"id"`
Name string `json:"name"`
Categories []BucketCategory `json:"categories"`
}
// BucketCategory is a leaf category within a bucket.
type BucketCategory struct {
ID int `json:"id"`
Key string `json:"key"`
ParentID int `json:"parentId"`
ParentKey string `json:"parentKey"`
Name string `json:"name"`
ShortName string `json:"shortName"`
}
// All returns the full category tree.
func (s *CategoryService) All(ctx context.Context) (*CategoriesResponse, error) {
var resp CategoriesResponse
if err := s.t.get(ctx, "/api3/categories.json", nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// Buckets returns the subcategory buckets for a parent category.
func (s *CategoryService) Buckets(ctx context.Context, categoryID int) ([]CategoryBucket, error) {
path := fmt.Sprintf("/app/l1/v1/category-buckets/%d", categoryID)
var resp []CategoryBucket
if err := s.t.get(ctx, path, nil, &resp); err != nil {
return nil, err
}
return resp, nil
}
+23
View File
@@ -0,0 +1,23 @@
package mrktplaats
import "context"
// ConfigService handles A/B tests and feature flags.
type ConfigService struct {
t *transport
}
// LabsConfig contains A/B test assignments and feature flags.
type LabsConfig struct {
ABSwitches map[string]string `json:"abSwitches"`
FeatureSwitches map[string]bool `json:"featureSwitches"`
}
// Labs returns the current A/B test and feature flag configuration.
func (s *ConfigService) Labs(ctx context.Context) (*LabsConfig, error) {
var resp LabsConfig
if err := s.t.get(ctx, "/app/v1/labs/config", nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
+99
View File
@@ -0,0 +1,99 @@
package mrktplaats
import (
"context"
"fmt"
"net/url"
)
// DiscoveryService handles home feed discovery.
type DiscoveryService struct {
t *transport
}
// Feed describes an available content feed.
type Feed struct {
FeedID string `json:"feed_id"`
DisplayTitle string `json:"display_title"`
LoginRequired bool `json:"login_required"`
LocationRequired bool `json:"location_required"`
PageLocation string `json:"page_location"`
AnalyticsLabel string `json:"analytics_label"`
TargetingConfigurations []TargetingConfiguration `json:"targeting_configurations"`
}
// TargetingConfiguration defines ad targeting for a feed position.
type TargetingConfiguration struct {
Position string `json:"position"`
AdUnitID string `json:"ad_unit_id"`
AdditionalParameters map[string][]string `json:"additional_parameters"`
}
// FeedsResponse is the response from listing available feeds.
type FeedsResponse struct {
Feeds []Feed `json:"feeds"`
}
// DiscoveryListing is a listing within a discovery feed (uses snake_case API3 format).
type DiscoveryListing struct {
URN string `json:"urn"`
Title string `json:"title"`
CategoryID int `json:"category_id"`
Price DiscoveryPrice `json:"price"`
Picture DiscoveryPicture `json:"picture"`
}
// FeedListingsResponse is the response from fetching a feed's listings.
type FeedListingsResponse struct {
Listings []DiscoveryListing `json:"listings"`
}
// Feeds returns the list of available content feeds.
func (s *DiscoveryService) Feeds(ctx context.Context, desiredCount int) (*FeedsResponse, error) {
params := url.Values{}
if desiredCount > 0 {
params.Set("desiredFeedCount", fmt.Sprintf("%d", desiredCount))
}
var resp FeedsResponse
if err := s.t.get(ctx, "/api3/discovery/feeds.json", params, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// FeedListingsOptions configures a feed listing request.
type FeedListingsOptions struct {
Offset int
Size int
PageLocation string
Latitude float64
Longitude float64
}
// FeedListings returns the listings for a specific feed.
func (s *DiscoveryService) FeedListings(ctx context.Context, feedID string, opts *FeedListingsOptions) (*FeedListingsResponse, error) {
path := fmt.Sprintf("/api3/discovery/feed/%s.json", feedID)
offset := 0
params := url.Values{}
if opts != nil {
offset = opts.Offset
if opts.Size > 0 {
params.Set("size", fmt.Sprintf("%d", opts.Size))
}
if opts.PageLocation != "" {
params.Set("page_location", opts.PageLocation)
}
if opts.Latitude != 0 {
params.Set("requestLatitude", fmt.Sprintf("%f", opts.Latitude))
}
if opts.Longitude != 0 {
params.Set("requestLongitude", fmt.Sprintf("%f", opts.Longitude))
}
}
params.Set("offset", fmt.Sprintf("%d", offset))
var resp FeedListingsResponse
if err := s.t.get(ctx, path, params, &resp); err != nil {
return nil, err
}
return &resp, nil
}
+94
View File
@@ -0,0 +1,94 @@
package mrktplaats
import (
"context"
"fmt"
)
// EnquiryService handles bidding on listings.
type EnquiryService struct {
t *transport
}
// PlaceBidRequest defines the payload for placing a bid.
type PlaceBidRequest struct {
AdURN string `json:"adUrn"`
Value int `json:"value"` // euro cents
PersonalMessage string `json:"personalMessage,omitempty"`
Phone string `json:"phone,omitempty"`
}
// Bid represents a bid on a listing.
type Bid struct {
ID int `json:"id"`
Value int `json:"value"`
Date string `json:"date"`
User BidUser `json:"user"`
}
// BidUser identifies the bidder.
type BidUser struct {
ID int `json:"id"`
Nickname string `json:"nickname"`
}
// BidsResponse contains the bids on a listing.
type BidsResponse struct {
Bids []Bid `json:"bids"`
CurrentMinimumBid int `json:"currentMinimumBid"`
}
// AskQuestionBody is the payload for contacting a seller about a listing.
type AskQuestionBody struct {
AdTitle *string `json:"adTitle"`
AdURN string `json:"adUrn"`
BidID *int `json:"bidId"`
BidValue float64 `json:"bidValue"`
Body string `json:"body"`
BuyerLocation *string `json:"buyerLocation"`
BuyerName *string `json:"buyerName"`
CallbackRequest *string `json:"callbackRequest"`
CategoryID int `json:"categoryId"`
FinancingInfoRequest bool `json:"financingInfoRequest"`
Phone *string `json:"phone"`
RecipientID string `json:"recipientId"`
RecipientName *string `json:"recipientName"`
TradeInRequest *string `json:"tradeInRequest"`
VisitOrTestDriveRequest *string `json:"visitOrTestDriveRequest"`
TradeInRequestAllowed bool `json:"tradeInRequestAllowed"`
}
// AskQuestion sends a message to the seller of a listing (the "Ask Seller a Question" / ASQ flow).
// sellerID is the numeric ID of the seller, available as detail.SellerInformation.ID from Listings.Get.
// Returns nil on success (the API responds with 204 No Content).
func (s *EnquiryService) AskQuestion(ctx context.Context, adURN, sellerID, text string) error {
body := AskQuestionBody{
AdURN: adURN,
BidValue: -1.0,
Body: text,
RecipientID: sellerID,
TradeInRequestAllowed: false,
}
return s.t.postJSON(ctx, "/app/enquiry/v1/question", nil, nil, body, nil)
}
// PlaceBid places a bid on a listing.
func (s *EnquiryService) PlaceBid(ctx context.Context, itemURN string, req *PlaceBidRequest) (*BidsResponse, error) {
path := fmt.Sprintf("/app/enquiry/v1/bids/item/%s", itemURN)
req.AdURN = itemURN
var resp BidsResponse
if err := s.t.postJSON(ctx, path, nil, nil, req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// RemoveBid deletes a bid by its ID.
func (s *EnquiryService) RemoveBid(ctx context.Context, bidID int) (*BidsResponse, error) {
path := fmt.Sprintf("/app/enquiry/v1/bids/bid/%d", bidID)
var resp BidsResponse
if err := s.t.delete(ctx, path, nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
+28
View File
@@ -0,0 +1,28 @@
package mrktplaats
import (
"errors"
"fmt"
)
// APIError is returned when the API responds with a non-2xx status code.
type APIError struct {
StatusCode int
Body []byte
}
func (e *APIError) Error() string {
return fmt.Sprintf("mrktplaats: API error %d: %s", e.StatusCode, string(e.Body))
}
// IsNotFound reports whether err is an API 404 response.
func IsNotFound(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && apiErr.StatusCode == 404
}
// IsUnauthorized reports whether err is an API 401 response.
func IsUnauthorized(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && apiErr.StatusCode == 401
}
+62
View File
@@ -0,0 +1,62 @@
package mrktplaats
import (
"context"
"fmt"
"net/url"
)
// FavoritesService handles saved/favorite ads.
type FavoritesService struct {
t *transport
}
// FavoriteItem is a saved ad.
type FavoriteItem struct {
ItemID string `json:"itemId"`
CreationDate string `json:"creationDate"`
}
// FavoritesResponse is the response from listing favorite ads.
type FavoritesResponse struct {
Items []FavoriteItem `json:"items"`
MoreItemsAvailable bool `json:"moreItemsAvailable"`
Total int `json:"total"`
}
type modifyFavoritesRequest struct {
AdURNs []string `json:"adUrns"`
}
type modifyFavoritesResponse struct {
Items []FavoriteItem `json:"items"`
}
// List returns the authenticated user's favorite ads.
func (s *FavoritesService) List(ctx context.Context, limit int) (*FavoritesResponse, error) {
params := url.Values{}
if limit > 0 {
params.Set("limit", fmt.Sprintf("%d", limit))
}
var resp FavoritesResponse
if err := s.t.get(ctx, "/app/favorites/v1/favorite-ads", params, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// Add saves one or more ads to favorites by their URNs.
func (s *FavoritesService) Add(ctx context.Context, urns ...string) ([]FavoriteItem, error) {
body := modifyFavoritesRequest{AdURNs: urns}
var resp modifyFavoritesResponse
if err := s.t.postJSON(ctx, "/app/favorites/v2/favorite-ads", nil, nil, body, &resp); err != nil {
return nil, err
}
return resp.Items, nil
}
// Remove deletes one or more ads from favorites by their URNs.
func (s *FavoritesService) Remove(ctx context.Context, urns ...string) error {
body := modifyFavoritesRequest{AdURNs: urns}
return s.t.postJSON(ctx, "/app/favorites/v2/favorite-ads/delete", nil, nil, body, nil)
}
+7
View File
@@ -0,0 +1,7 @@
module github.com/joren/mrktplaats
go 1.25.0
require golang.org/x/net v0.51.0
require golang.org/x/text v0.34.0 // indirect
+4
View File
@@ -0,0 +1,4 @@
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+79
View File
@@ -0,0 +1,79 @@
package mrktplaats
import (
"bytes"
"image"
"image/jpeg"
_ "image/jpeg" // register JPEG decoder for image.Decode
"io"
)
// maxImageUploadBytes is the threshold above which we attempt to compress an
// image before sending it to the API. The marktplaats endpoints reject payloads
// that are too large (HTTP 413 / LIMIT_UNEXPECTED_FILE).
const maxImageUploadBytes = 900 * 1024 // 900 KB
// autoCompressImage reads all bytes from r and, if the result exceeds
// maxImageUploadBytes, decodes it as an image and re-encodes it as a JPEG at
// progressively smaller dimensions until it fits. If the data cannot be decoded
// as a recognised image format it is returned unchanged (the server will then
// return whatever error it normally would). The original data is also returned
// unchanged if it is already within the size limit.
func autoCompressImage(r io.Reader) ([]byte, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
if len(data) <= maxImageUploadBytes {
return data, nil
}
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
// Not a format we can handle — return as-is.
return data, nil
}
for maxDim := 1200; maxDim >= 300; maxDim = maxDim * 3 / 4 {
scaled := scaleImageNN(src, maxDim)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, scaled, &jpeg.Options{Quality: 80}); err != nil {
return data, nil // encoding failed, return original
}
if buf.Len() <= maxImageUploadBytes {
return buf.Bytes(), nil
}
}
return data, nil // could not get small enough, let the server handle it
}
// scaleImageNN scales src down so that neither dimension exceeds maxDim,
// preserving the aspect ratio using nearest-neighbour sampling.
// Returns src unchanged if it already fits within maxDim×maxDim.
func scaleImageNN(src image.Image, maxDim int) image.Image {
bounds := src.Bounds()
w, h := bounds.Dx(), bounds.Dy()
dstW, dstH := w, h
if dstW > maxDim {
dstH = dstH * maxDim / dstW
dstW = maxDim
}
if dstH > maxDim {
dstW = dstW * maxDim / dstH
dstH = maxDim
}
if dstW == w && dstH == h {
return src
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for y := 0; y < dstH; y++ {
srcY := bounds.Min.Y + y*h/dstH
for x := 0; x < dstW; x++ {
srcX := bounds.Min.X + x*w/dstW
dst.Set(x, y, src.At(srcX, srcY))
}
}
return dst
}
+838
View File
@@ -0,0 +1,838 @@
//go:build integration
package mrktplaats_test
// Run with:
// go test -v -tags integration -run TestIntegration -timeout 120s
//
// On first run, enter the SMS 2FA code when prompted.
// The access token is saved to .test-token and reused on subsequent runs.
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/joren/mrktplaats"
)
// ── credentials & known test data ────────────────────────────────────────────
const (
testEmail = "beekeam1@gmail.com"
testPassword = "Sh8^y2B05H"
testListingURN = "m2372861012" // "NVIDEA 100000 V2" — Mattia's test listing
tokenFile = ".test-token"
)
// ── token persistence ─────────────────────────────────────────────────────────
type savedToken struct {
AccessToken string `json:"accessToken"`
UserID int `json:"userId"`
UserName string `json:"userName"`
}
func loadSavedToken() (string, bool) {
data, err := os.ReadFile(tokenFile)
if err != nil {
return "", false
}
var tok savedToken
if err := json.Unmarshal(data, &tok); err != nil || tok.AccessToken == "" {
return "", false
}
return tok.AccessToken, true
}
func saveToken(t *testing.T, token string, userID int, name string) {
t.Helper()
data, _ := json.MarshalIndent(savedToken{
AccessToken: token,
UserID: userID,
UserName: name,
}, "", " ")
if err := os.WriteFile(tokenFile, data, 0600); err != nil {
t.Logf("warning: could not save token: %v", err)
} else {
t.Logf("token saved → %s", tokenFile)
}
}
// ── client setup ──────────────────────────────────────────────────────────────
// authedClient returns a client with a valid access token.
// It loads a saved token if available; otherwise it performs a full login with
// optional interactive 2FA (reads the code from stdin).
func authedClient(t *testing.T) *mrktplaats.Client {
t.Helper()
if token, ok := loadSavedToken(); ok {
t.Logf("reusing saved token from %s", tokenFile)
return mrktplaats.NewClient(mrktplaats.WithAccessToken(token))
}
client := mrktplaats.NewClient()
ctx := context.Background()
t.Logf("logging in as %s ...", testEmail)
login, err := client.Auth.Login(ctx, testEmail, testPassword)
if err != nil {
t.Fatalf("Login: %v", err)
}
if login.Auth != nil {
// no 2FA required
saveToken(t, login.Auth.AccessToken, 0, "")
return client
}
if login.Verification == nil {
t.Fatal("Login: expected auth or verification in response")
}
t.Logf("2FA required via %s", login.Verification.Method)
t.Logf("message: %s", login.Verification.Message)
fmt.Fprint(os.Stderr, "\n>>> Enter 2FA code: ")
reader := bufio.NewReader(os.Stdin)
code, _ := reader.ReadString('\n')
code = strings.TrimSpace(code)
auth, err := client.Auth.VerifyCode(ctx, login.Verification.RequestID, code)
if err != nil {
t.Fatalf("VerifyCode: %v", err)
}
saveToken(t, auth.Auth.AccessToken, auth.User.ID, auth.User.Name)
return client
}
// anonClient returns an unauthenticated client for tests that do not require auth.
func anonClient() *mrktplaats.Client {
return mrktplaats.NewClient()
}
// ── helpers ───────────────────────────────────────────────────────────────────
func ctx() context.Context {
c, _ := context.WithTimeout(context.Background(), 20*time.Second)
return c
}
func check(t *testing.T, name string, err error) bool {
t.Helper()
if err != nil {
t.Errorf("%s: %v", name, err)
return false
}
return true
}
// ── helpers ───────────────────────────────────────────────────────────────────
// openTestImage opens the Seiko SARV001 test photo.
// The SDK's UploadImage / Recognize automatically compress large images.
func openTestImage(t *testing.T) *os.File {
t.Helper()
f, err := os.Open("./TEST/IMG_20240522_182451.jpg")
if err != nil {
t.Fatalf("open test image: %v", err)
}
return f
}
// ═══════════════════════════════════════════════════════════════════════════════
// THE INTEGRATION TEST
// ═══════════════════════════════════════════════════════════════════════════════
func TestIntegration(t *testing.T) {
client := authedClient(t)
anon := anonClient()
// ── 1. Config / Labs ──────────────────────────────────────────────────────
t.Run("Config_Labs", func(t *testing.T) {
cfg, err := client.Config.Labs(ctx())
if !check(t, "Labs", err) {
return
}
if len(cfg.ABSwitches) == 0 {
t.Error("expected at least one AB switch, got none")
}
t.Logf("AB switches: %d feature switches: %d", len(cfg.ABSwitches), len(cfg.FeatureSwitches))
})
// ── 2. Categories ─────────────────────────────────────────────────────────
t.Run("Categories_All", func(t *testing.T) {
resp, err := anon.Categories.All(ctx())
if !check(t, "All", err) {
return
}
if len(resp.Categories) == 0 {
t.Error("expected categories, got none")
}
t.Logf("top-level categories: %d (first: %q)", len(resp.Categories), resp.Categories[0].Name)
})
t.Run("Categories_Buckets", func(t *testing.T) {
// category 1 = Antiek en Kunst
buckets, err := anon.Categories.Buckets(ctx(), 1)
if !check(t, "Buckets", err) {
return
}
if len(buckets) == 0 {
t.Error("expected at least one bucket for category 1")
}
t.Logf("buckets for cat 1: %d (first: %q with %d subcats)",
len(buckets), buckets[0].Name, len(buckets[0].Categories))
})
// ── 3. Discovery ──────────────────────────────────────────────────────────
t.Run("Discovery_Feeds", func(t *testing.T) {
resp, err := anon.Discovery.Feeds(ctx(), 3)
if !check(t, "Feeds", err) {
return
}
if len(resp.Feeds) == 0 {
t.Error("expected feeds, got none")
}
t.Logf("feeds: %d", len(resp.Feeds))
for _, f := range resp.Feeds {
t.Logf(" feed_id=%q title=%q login_required=%v", f.FeedID, f.DisplayTitle, f.LoginRequired)
}
})
t.Run("Discovery_FeedListings", func(t *testing.T) {
resp, err := anon.Discovery.FeedListings(ctx(), "f8", &mrktplaats.FeedListingsOptions{
Size: 40,
PageLocation: "HOMEPAGE_DISCOVERY",
})
if !check(t, "FeedListings f8", err) {
return
}
if len(resp.Listings) == 0 {
t.Error("expected listings in feed f8")
}
t.Logf("feed f8 listings: %d (first: %q %s)",
len(resp.Listings), resp.Listings[0].Title, resp.Listings[0].Price.PriceTypeLabel)
})
// ── 4. Search (LRP) ───────────────────────────────────────────────────────
t.Run("Search_KeywordSuggestions", func(t *testing.T) {
resp, err := anon.Search.KeywordSuggestions(ctx(), "fiets", 0)
if !check(t, "KeywordSuggestions", err) {
return
}
if len(resp.Suggestions) == 0 {
t.Error("expected suggestions for 'fiets'")
}
t.Logf("suggestions for %q: %v", resp.Keyword, resp.Suggestions)
})
// Track a URN from search to reuse in later tests.
var searchURN string
t.Run("Search_Fetch", func(t *testing.T) {
resp, err := anon.Search.Fetch(ctx(), &mrktplaats.SearchRequest{
Query: "fiets",
Page: 1,
Size: 10,
SortBy: "SORT_INDEX",
Languages: []string{"nl-BE"},
AllowCorrection: true,
SearchOnTitleAndDescription: true,
ShowListings: true,
SupportsReservedFlag: true,
}, nil)
if !check(t, "Fetch", err) {
return
}
var listings int
for _, item := range resp.Items {
if l := item.Listing(); l != nil {
listings++
if searchURN == "" {
searchURN = l.AdCore.URN
}
}
}
if listings == 0 {
t.Error("expected at least one listing in search results")
}
t.Logf("total items: %d listings: %d numFound: %d firstURN: %s",
len(resp.Items), listings, resp.SearchHistograms.NumFound, searchURN)
})
t.Run("Search_DisplayTargeting", func(t *testing.T) {
results, err := anon.Search.DisplayTargeting(ctx(), &mrktplaats.DisplayTargetingRequest{
Positions: []string{"srpHeaderNativeAd", "srpListNativeAd"},
Query: "fiets",
})
if !check(t, "DisplayTargeting", err) {
return
}
t.Logf("targeting slots: %d", len(results))
})
// ── 5. Listing detail (VIP) ───────────────────────────────────────────────
urn := testListingURN // fall back to the designated test listing
if searchURN != "" {
urn = searchURN
}
// detailSellerID is Mattia's seller ID, captured from Listing_TestListingDetails.
// Required by Relevant_Get — populated before that test runs.
var detailSellerID string
t.Run("Listing_Get", func(t *testing.T) {
detail, err := client.Listings.Get(ctx(), urn)
if !check(t, "Get "+urn, err) {
return
}
if detail.AdCore.URN == "" {
t.Error("listing URN is empty in response")
}
if detail.AdCore.Title == "" {
t.Error("listing Title is empty in response")
}
t.Logf("listing: %q price: %s city: %s seller: %s",
detail.AdCore.Title,
detail.AdCore.Price.PriceTypeLabel,
detail.AdCore.AdAddress.City,
detail.SellerInformation.Name)
t.Logf("pictures: %d attributes: %d",
len(detail.AdCore.Pictures), len(detail.AdCore.Attributes))
})
t.Run("Listing_DisplayTargeting", func(t *testing.T) {
results, err := client.Listings.DisplayTargeting(ctx(), &mrktplaats.VIPDisplayTargetingRequest{
AdURN: urn,
Positions: mrktplaats.DefaultVIPPositions,
})
if !check(t, "VIP DisplayTargeting", err) {
return
}
t.Logf("VIP targeting slots: %d", len(results))
})
// ── 7. User ───────────────────────────────────────────────────────────────
var myUserID int
var myUserName string
var myPostcode string
t.Run("User_Me", func(t *testing.T) {
user, err := client.Users.Me(ctx())
if !check(t, "Me", err) {
return
}
if user.ID == 0 {
t.Error("user ID is 0")
}
myUserID = user.ID
myUserName = user.Name
myPostcode = user.ZipCode
t.Logf("user: id=%d name=%q email=%q zipCode=%q ndfc=%q",
user.ID, user.Name, user.Email, user.ZipCode, user.NdfcDeclarationStatus)
})
t.Run("User_Reviews_Received", func(t *testing.T) {
if myUserID == 0 {
t.Skip("user ID unknown (User_Me failed)")
}
resp, err := client.Users.Reviews(ctx(), myUserID, mrktplaats.ReviewRoleReviewee)
if !check(t, "Reviews(reviewee)", err) {
return
}
t.Logf("reviews received: %d avg score: %.1f", resp.Summary.NumberOfReviews, resp.Summary.AverageScore)
})
// ── 8. Notifications ──────────────────────────────────────────────────────
t.Run("Notifications_UnreadCount", func(t *testing.T) {
resp, err := client.Notifications.UnreadCount(ctx())
if !check(t, "UnreadCount", err) {
return
}
t.Logf("unread notifications: %d", resp.UnreadNotificationsCount)
})
// ── 9. Payments ───────────────────────────────────────────────────────────
t.Run("Payments_CartItemCount", func(t *testing.T) {
resp, err := client.Payments.CartItemCount(ctx())
if !check(t, "CartItemCount", err) {
return
}
t.Logf("payment cart: userID=%q items=%d", resp.UserID, resp.NumberOfItems)
})
// ── 10. Messaging ─────────────────────────────────────────────────────────
t.Run("Messaging_ServerTime", func(t *testing.T) {
ts, err := client.Messaging.ServerTime(ctx())
if !check(t, "ServerTime", err) {
return
}
if ts.IsZero() {
t.Error("server time is zero")
}
diff := time.Since(ts).Abs()
if diff > 5*time.Minute {
t.Errorf("server time differs from local time by %s (more than 5m)", diff)
}
t.Logf("server time: %s (diff from local: %s)", ts.Format(time.RFC3339), diff)
})
// testListingConvID is the conversation about testListingURN with Mattia.
// It may already exist (from a previous bid/message); we capture it from
// the conversations list and reuse it throughout the messaging tests.
var testListingConvID string
t.Run("Messaging_Conversations", func(t *testing.T) {
resp, err := client.Messaging.Conversations(ctx(), &mrktplaats.ConversationsOptions{
Limit: 50,
})
if !check(t, "Conversations", err) {
return
}
t.Logf("conversations: total=%d unread=%d", resp.TotalCount, resp.UnreadMessagesCount)
for i, conv := range resp.Conversations {
lm := conv.LatestMessage()
lastText := "(none)"
if lm != nil {
lastText = lm.Text
if len(lastText) > 50 {
lastText = lastText[:50] + "…"
}
}
t.Logf(" [%d] id=%q itemId=%q title=%q with=%q unread=%d latest=%q",
i, conv.ID, conv.ItemID, conv.Title, conv.OtherParticipant.Name, conv.UnreadMessagesCount, lastText)
// Identify existing conversation about the test listing
if conv.ItemID == testListingURN && testListingConvID == "" {
testListingConvID = conv.ID
t.Logf(" → found existing test listing conversation: %s", conv.ID)
}
}
})
t.Run("Messaging_Messages", func(t *testing.T) {
convID := testListingConvID
if convID == "" {
t.Skip("no conversation about test listing found yet")
}
resp, err := client.Messaging.Messages(ctx(), convID, 0, 20)
if !check(t, "Messages", err) {
return
}
t.Logf("messages in test listing conv %q: total=%d (fetched %d)", convID, resp.TotalCount, len(resp.Messages))
for _, msg := range resp.Messages {
text := msg.Text
if len(text) > 60 {
text = text[:60] + "…"
}
t.Logf(" [%s] sender=%d type=%s read=%v %q",
msg.ReceivedDate.Format("2006-01-02 15:04"), msg.SenderID, msg.MessageType, msg.IsRead, text)
}
})
t.Run("Messaging_EmailSubscriptions", func(t *testing.T) {
raw, err := client.Messaging.EmailSubscriptions(ctx())
if !check(t, "EmailSubscriptions", err) {
return
}
t.Logf("email subscriptions: %s", string(raw))
})
// ── 11. My Account ────────────────────────────────────────────────────────
t.Run("MyAccount_MyAds_Active", func(t *testing.T) {
resp, err := client.MyAccount.MyAds(ctx(), &mrktplaats.MyAdsOptions{
Status: "active",
Counts: true,
Limit: 25,
})
if !check(t, "MyAds(active)", err) {
return
}
t.Logf("my active ads: %d total", resp.MyAdsTotalCount)
for _, tab := range resp.Tabs {
t.Logf(" tab %q: %d ads", tab.Title, tab.Count)
}
for _, ad := range resp.MyAds {
t.Logf(" ad: id=%s title=%q price=%d ct", ad.ItemID, ad.Title, ad.PriceInCents)
}
})
// ── 12. Favorites ─────────────────────────────────────────────────────────
//
// All favorite operations are tested against testListingURN (the NVIDEA
// listing on the second test account). We first ensure a clean slate by
// removing it if it's already present, then add it, verify it appears, then
// remove it and verify it's gone.
t.Run("Favorites_List", func(t *testing.T) {
resp, err := client.Favorites.List(ctx(), 50)
if !check(t, "List", err) {
return
}
t.Logf("favorites: total=%d more=%v", resp.Total, resp.MoreItemsAvailable)
for _, item := range resp.Items {
t.Logf(" fav: %s (saved: %s)", item.ItemID, item.CreationDate)
}
})
t.Run("Favorites_Add", func(t *testing.T) {
// Ensure clean state — remove if already present (ignore errors)
_ = client.Favorites.Remove(ctx(), testListingURN)
items, err := client.Favorites.Add(ctx(), testListingURN)
if !check(t, "Add "+testListingURN, err) {
return
}
t.Logf("Add response: %d item(s)", len(items))
// Verify the item actually appears in the favorites list
list, err := client.Favorites.List(ctx(), 50)
if !check(t, "List after Add", err) {
return
}
found := false
for _, item := range list.Items {
if item.ItemID == testListingURN {
found = true
}
}
if !found {
t.Errorf("%s not found in favorites after Add", testListingURN)
} else {
t.Logf("%s confirmed in favorites (total: %d)", testListingURN, list.Total)
}
})
t.Run("Favorites_Remove", func(t *testing.T) {
err := client.Favorites.Remove(ctx(), testListingURN)
if !check(t, "Remove "+testListingURN, err) {
return
}
// Verify it's really gone
list, err := client.Favorites.List(ctx(), 50)
if !check(t, "List after Remove", err) {
return
}
for _, item := range list.Items {
if item.ItemID == testListingURN {
t.Errorf("%s still present in favorites after Remove", testListingURN)
return
}
}
t.Logf("%s confirmed removed from favorites (total: %d)", testListingURN, list.Total)
})
// ── 13. SYI helpers ───────────────────────────────────────────────────────
t.Run("SYI_Form", func(t *testing.T) {
// category 1831 = Horloges | Heren
resp, err := client.SYI.Form(ctx(), 1831)
if !check(t, "Form(1831)", err) {
return
}
t.Logf("SYI form cat=%d attributes: %d", resp.CategoryID, len(resp.SYIAttributes))
for _, attr := range resp.SYIAttributes {
t.Logf(" attr: key=%q label=%q type=%q mandatory=%v values=%d",
attr.Key, attr.Label, attr.AttributeType, attr.Mandatory, len(attr.SupportedValues))
}
})
t.Run("SYI_PriceSuggestion", func(t *testing.T) {
resp, err := client.SYI.PriceSuggestion(ctx(), 1831, "Seiko SARV001")
if !check(t, "PriceSuggestion", err) {
return
}
t.Logf("price segments: %d", len(resp.Segments))
for _, seg := range resp.Segments {
t.Logf(" %s: €%.2f – €%.2f (%d similar ads)",
seg.Title, float64(seg.MinPrice)/100, float64(seg.MaxPrice)/100, seg.TotalSimilarAdsCount)
}
})
t.Run("SYI_AttributeSuggestions", func(t *testing.T) {
raw, err := client.SYI.AttributeSuggestions(ctx(), 1831, "Seiko SARV001 automatisch horloge")
if !check(t, "AttributeSuggestions", err) {
return
}
t.Logf("attribute suggestions: %s", string(raw))
})
t.Run("SYI_SuspiciousKeywords", func(t *testing.T) {
raw, err := client.SYI.SuspiciousKeywordsWarnings(ctx(), 1831, "Seiko horloge te koop")
if !check(t, "SuspiciousKeywordsWarnings", err) {
return
}
t.Logf("keyword warnings: %s", string(raw))
})
t.Run("SYI_UploadImage", func(t *testing.T) {
f := openTestImage(t)
defer f.Close()
pictureID, err := client.SYI.UploadImage(ctx(), f, "seiko_sarv001.jpg")
if !check(t, "UploadImage", err) {
return
}
if pictureID == "" {
t.Error("expected a non-empty pictureId")
}
t.Logf("uploaded image pictureId: %q", pictureID)
})
// ── 14. Saleability / Image Recognition ───────────────────────────────────
t.Run("Saleability_Recognize", func(t *testing.T) {
f := openTestImage(t)
defer f.Close()
resp, err := client.Saleability.Recognize(ctx(), f, "seiko_sarv001.jpg")
if !check(t, "Recognize", err) {
return
}
t.Logf("predictions: %d", len(resp.Predictions))
for _, p := range resp.Predictions {
t.Logf(" catID=%d %q confidence=%.3f", p.CategoryID, p.CategoryName, p.Confidence)
}
})
// ── 16. Test listing (m2372861012 "NVIDEA 100000 V2") ────────────────────
t.Run("Search_TestListing", func(t *testing.T) {
resp, err := anon.Search.Fetch(ctx(), &mrktplaats.SearchRequest{
Query: "NVIDEA 100000 V2",
Page: 1,
Size: 5,
SortBy: "SORT_INDEX",
AllowCorrection: false,
SearchOnTitleAndDescription: true,
ShowListings: true,
SupportsReservedFlag: true,
}, nil)
if !check(t, "Fetch TestListing", err) {
return
}
found := false
for _, item := range resp.Items {
l := item.Listing()
if l == nil {
continue
}
if l.AdCore.URN == testListingURN {
found = true
t.Logf("found test listing: %q price: %s city: %s",
l.AdCore.Title, l.AdCore.Price.PriceTypeLabel, l.AdCore.AdAddress.City)
}
}
if !found {
t.Errorf("test listing %s not found in search results for 'NVIDEA 100000 V2'", testListingURN)
}
})
t.Run("Listing_TestListingDetails", func(t *testing.T) {
detail, err := client.Listings.Get(ctx(), testListingURN)
if !check(t, "Get "+testListingURN, err) {
return
}
if !strings.Contains(detail.AdCore.Title, "NVIDEA") {
t.Errorf("expected title to contain 'NVIDEA', got %q", detail.AdCore.Title)
}
detailSellerID = fmt.Sprintf("%d", detail.SellerInformation.ID)
t.Logf("test listing: %q price: %s seller: %s (id=%s) bids: %d minBid: %d ct",
detail.AdCore.Title, detail.AdCore.Price.PriceTypeLabel,
detail.SellerInformation.Name, detailSellerID, len(detail.Bids), detail.CurrentMinimumBid)
})
// ── 6. Similar/Relevant ads ───────────────────────────────────────────────
t.Run("Relevant_Get", func(t *testing.T) {
if detailSellerID == "" {
t.Skip("seller ID not available (Listing_TestListingDetails failed)")
}
resp, err := anon.Relevant.Get(ctx(), testListingURN, detailSellerID, 0)
if !check(t, "Get", err) {
return
}
t.Logf("result_type: %q similar items: %d", resp.ResultType, len(resp.Items))
if len(resp.Items) > 0 {
t.Logf(" first: %q", resp.Items[0].AdCore.Title)
}
})
// ── 17. Create and delete a listing ──────────────────────────────────────
t.Run("SYI_CreateAndDelete", func(t *testing.T) {
if myPostcode == "" || myUserName == "" {
t.Skip("user info not available (User_Me failed)")
}
// Upload the real Seiko SARV001 test image (SDK auto-compresses if needed)
imgFile := openTestImage(t)
defer imgFile.Close()
pictureID, err := client.SYI.UploadImage(ctx(), imgFile, "seiko_sarv001.jpg")
if !check(t, "UploadImage for create", err) {
return
}
t.Logf("uploaded picture: %s", pictureID)
// Create listing — Seiko SARV001, category 1831 (Horloges | Heren)
createResp, err := client.SYI.Create(ctx(), &mrktplaats.CreateAdRequest{
CategoryID: 1831,
Translations: []mrktplaats.Translation{{
Locale: "nl-BE",
Title: "Seiko SARV001 - SDK integratietest",
Description: "Seiko SARV001 automatisch horloge. SDK integratietest — gelieve te negeren.",
}},
PriceInCents: 25000, // €250
PriceType: "FIXED",
BiddingEnabled: false,
BuyItNowEnabled: false,
BuyerProtectionAllowed: false,
Attributes: []any{},
PictureIDs: []string{pictureID},
DeliveryMethod: "Ophalen",
ShippingConfig: mrktplaats.ShippingConfig{Carriers: []string{}},
SellerName: myUserName,
Postcode: myPostcode,
AddToPaymentCart: false,
SelectedBundle: "FREE",
SYISessionID: fmt.Sprintf("sdk-test-%d", time.Now().UnixMilli()),
IntegratedShippingForTWHEnabled: true,
FeatureSource: "SYI",
FeatureTypes: []any{},
UseDynamicPricing: false,
})
if !check(t, "Create listing", err) {
return
}
createdURN := createResp.URN()
if createdURN == "" {
t.Error("created listing has no URN in response")
return
}
t.Logf("created listing: %s", createdURN)
// Verify we can fetch the new listing
detail, err := client.Listings.Get(ctx(), createdURN)
if !check(t, "Get created listing", err) {
return
}
if detail.AdCore.URN == "" {
t.Error("listing URN empty after create")
}
t.Logf("verified: %q price: %d ct", detail.AdCore.Title, detail.AdCore.Price.PriceAmount)
// Verify it appears in MyAds (may have a short propagation delay)
myAds, err := client.MyAccount.MyAds(ctx(), &mrktplaats.MyAdsOptions{Status: "active", Counts: true, Limit: 50})
if !check(t, "MyAds after create", err) {
return
}
found := false
for _, ad := range myAds.MyAds {
if ad.ItemID == createdURN {
found = true
}
}
if !found {
t.Logf("note: %s not yet visible in MyAds (API indexing lag — listing was created successfully)", createdURN)
} else {
t.Logf("listing confirmed in MyAds (total active: %d)", myAds.MyAdsTotalCount)
}
// Delete it
err = client.MyAccount.DeleteAds(ctx(), []string{createdURN}, mrktplaats.DeleteReasonOther)
if !check(t, "DeleteAds", err) {
return
}
t.Logf("deleted listing %s", createdURN)
})
// ── 18. Bidding — only against our own test listing ──────────────────────
//
// We only ever touch testListingURN (our own listing) so no random sellers
// are affected. The API will reject bids from the listing owner, so we
// expect a graceful skip in normal operation.
t.Run("Enquiry_PlaceBid_TestListing", func(t *testing.T) {
// Fetch testListingURN to inspect its bid configuration
detail, err := client.Listings.Get(ctx(), testListingURN)
if !check(t, "Get test listing for bid", err) {
return
}
pt := detail.AdCore.Price.PriceType
if pt != "FAST_BID" && pt != "MIN_BID" {
t.Skipf("test listing %s is not bid-enabled (priceType=%s) — update the listing to use MIN_BID to exercise this path", testListingURN, pt)
return
}
bidAmount := detail.CurrentMinimumBid
if bidAmount == 0 {
bidAmount = 100
}
t.Logf("test listing price type: %s minimum bid: %d ct (€%.2f)", pt, bidAmount, float64(bidAmount)/100)
// Attempt to place a bid — the API should reject it because we own the listing
bidResp, err := client.Enquiry.PlaceBid(ctx(), testListingURN, &mrktplaats.PlaceBidRequest{
Value: bidAmount,
})
if err != nil {
// Expected: "cannot bid on own listing" or similar
t.Logf("PlaceBid returned expected error for own listing: %v", err)
t.Skip("PlaceBid endpoint reachable — own-listing rejection confirmed")
return
}
t.Logf("placed bid: %d ct total bids: %d", bidAmount, len(bidResp.Bids))
// If for some reason it succeeded, clean up immediately
var bidID int
for _, b := range bidResp.Bids {
if b.User.ID == myUserID {
bidID = b.ID
}
}
if bidID == 0 {
t.Error("our bid not found in PlaceBid response")
return
}
removeResp, err := client.Enquiry.RemoveBid(ctx(), bidID)
if !check(t, "RemoveBid", err) {
return
}
t.Logf("removed bid %d remaining bids: %d new minBid: %d ct",
bidID, len(removeResp.Bids), removeResp.CurrentMinimumBid)
})
// ── 19. Messaging — write operations against the test listing ────────────
//
// testListingURN (m2372861012 "NVIDEA 100000 V2") is owned by a separate
// test account (Mattia). The authenticated account (Jonathan) can contact
// that seller freely. We start a conversation if one does not exist yet,
// or reuse the existing one, then send a test message.
t.Run("Messaging_StartConversation", func(t *testing.T) {
if detailSellerID == "" {
t.Skip("seller ID not available (Listing_TestListingDetails failed)")
}
// AskQuestion / StartConversation always succeeds even if a conversation
// already exists — the API returns 204 No Content in both cases.
err := client.Messaging.StartConversation(ctx(), testListingURN, detailSellerID,
"SDK integration test — gelieve te negeren / please ignore this automated test message")
if !check(t, "StartConversation", err) {
return
}
t.Logf("StartConversation OK (ASQ sent to seller %s about listing %s)", detailSellerID, testListingURN)
})
t.Run("Messaging_SendMessage", func(t *testing.T) {
if testListingConvID == "" {
t.Skip("no conversation with test listing seller available")
}
msgID, err := client.Messaging.SendMessage(ctx(), testListingConvID,
"SDK integration test — gelieve te negeren / please ignore this automated test message")
if !check(t, "SendMessage", err) {
return
}
t.Logf("sent message %q to conversation %s (NVIDEA 100000 V2 / Mattia)", msgID, testListingConvID)
})
}
+113
View File
@@ -0,0 +1,113 @@
package mrktplaats
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
)
// ListingService handles viewing individual listing details (VIP).
type ListingService struct {
t *transport
}
// ListingDetail is the full detail view of a single listing.
type ListingDetail struct {
AdCore AdCore `json:"adCore"`
SellerInformation SellerInformation `json:"sellerInformation"`
Bids []Bid `json:"bids,omitempty"`
CurrentMinimumBid int `json:"currentMinimumBid,omitempty"`
ShowBanner bool `json:"showBanner,omitempty"`
Traits json.RawMessage `json:"traits,omitempty"`
BuyersProtectionAllowed bool `json:"buyersProtectionAllowed,omitempty"`
IsBuyerProtectionApplicableForDefaultOn bool `json:"isBuyerProtectionApplicableForDefaultOn,omitempty"`
LargeItemShippingLogicalAllowed bool `json:"largeItemShippingLogicalAllowed,omitempty"`
}
// DefaultCTATypes is the standard set of CTA types the client declares support for.
var DefaultCTATypes = []string{"bid", "asq", "website", "phone", "buyNow"}
type viewItemRequest struct {
SupportedCTATypes []string `json:"supportedCTATypes"`
SupportsReservedFlag bool `json:"supportsReservedFlag"`
}
// Get retrieves the full details of a listing by its URN.
// An optional correlationID (from a prior search) can be passed for analytics.
func (s *ListingService) Get(ctx context.Context, urn string, correlationID ...string) (*ListingDetail, error) {
path := fmt.Sprintf("/app/vip/v4/item/%s", urn)
var params url.Values
if len(correlationID) > 0 && correlationID[0] != "" {
params = url.Values{"correlation_id": {correlationID[0]}}
}
body := viewItemRequest{
SupportedCTATypes: DefaultCTATypes,
SupportsReservedFlag: true,
}
var resp ListingDetail
if err := s.t.postJSON(ctx, path, params, nil, body, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// VIPDisplayTargetingRequest defines parameters for item page ad targeting.
// Positions is serialized as a comma-separated string; Postcode and SellerID
// are sent as JSON null when empty.
type VIPDisplayTargetingRequest struct {
AdURN string
Attr json.RawMessage
Positions []string
Postcode string
SellerID string
}
// MarshalJSON serializes in the format the marktplaats API expects.
func (r VIPDisplayTargetingRequest) MarshalJSON() ([]byte, error) {
attr := r.Attr
if len(attr) == 0 {
attr = json.RawMessage(`{}`)
}
type wire struct {
AdURN string `json:"adUrn"`
Attr json.RawMessage `json:"attr"`
Positions string `json:"positions"`
Postcode *string `json:"postcode"`
SellerID *string `json:"sellerId"`
}
w := wire{
AdURN: r.AdURN,
Attr: attr,
Positions: strings.Join(r.Positions, ", "),
}
if r.Postcode != "" {
w.Postcode = &r.Postcode
}
if r.SellerID != "" {
w.SellerID = &r.SellerID
}
return json.Marshal(w)
}
// DefaultVIPPositions are the standard ad positions for an item detail page.
var DefaultVIPPositions = []string{
"VIP_LEADERBOARD_MID1",
"vip_native",
"vip_native_2",
"VIP_GALLERY_BANNER",
"VIP_DESCRIPTION_BANNER",
}
// DisplayTargeting retrieves ad targeting configuration for a listing detail page.
func (s *ListingService) DisplayTargeting(ctx context.Context, req *VIPDisplayTargetingRequest) ([]DisplayTargetingResult, error) {
var resp []DisplayTargetingResult
if err := s.t.postJSON(ctx, "/app/vip/v4/display-targeting", nil, nil, req, &resp); err != nil {
return nil, err
}
return resp, nil
}
+219
View File
@@ -0,0 +1,219 @@
package mrktplaats
import (
"context"
"encoding/json"
"fmt"
"net/url"
"time"
)
// MessagingService handles conversations and messages.
type MessagingService struct {
t *transport
}
// Conversation represents a messaging thread.
type Conversation struct {
ID string `json:"id"`
ItemID string `json:"itemId"`
SellerID int `json:"sellerId"`
OtherParticipant Participant `json:"otherParticipant"`
UnreadMessagesCount int `json:"unreadMessagesCount"`
Title string `json:"title"`
ImageURL string `json:"imageUrl"`
Embedded conversationEmbed `json:"_embedded"`
}
// LatestMessage returns the latest message in the conversation.
func (c *Conversation) LatestMessage() *Message {
return c.Embedded.LatestMessage
}
type conversationEmbed struct {
LatestMessage *Message `json:"mc:latest-message"`
}
// Participant is the other user in a conversation.
type Participant struct {
ID int `json:"id"`
Name string `json:"name"`
IsReviewable bool `json:"isReviewable"`
}
// Message represents a single message in a conversation.
type Message struct {
ID string `json:"id"`
SenderID int `json:"senderId"`
Text string `json:"text"`
ReceivedDate time.Time `json:"receivedDate"`
IsRead bool `json:"isRead"`
MessageType string `json:"messageType"`
IsMarkedAsFraud bool `json:"isMarkedAsFraud"`
Actions []any `json:"actions"`
}
// ConversationsResponse is the response from listing conversations.
type ConversationsResponse struct {
ConversationsWithUnreadMessagesCount int `json:"conversationsWithUnreadMessagesCount"`
Limit int `json:"limit"`
Offset int `json:"offset"`
TotalCount int `json:"totalCount"`
UnreadMessagesCount int `json:"unreadMessagesCount"`
Conversations []Conversation
}
// UnmarshalJSON handles the HAL _embedded format for conversations.
func (r *ConversationsResponse) UnmarshalJSON(data []byte) error {
type plain struct {
ConversationsWithUnreadMessagesCount int `json:"conversationsWithUnreadMessagesCount"`
Limit int `json:"limit"`
Offset int `json:"offset"`
TotalCount int `json:"totalCount"`
UnreadMessagesCount int `json:"unreadMessagesCount"`
Embedded struct {
Conversations []Conversation `json:"mc:conversations"`
} `json:"_embedded"`
}
var p plain
if err := json.Unmarshal(data, &p); err != nil {
return err
}
r.ConversationsWithUnreadMessagesCount = p.ConversationsWithUnreadMessagesCount
r.Limit = p.Limit
r.Offset = p.Offset
r.TotalCount = p.TotalCount
r.UnreadMessagesCount = p.UnreadMessagesCount
r.Conversations = p.Embedded.Conversations
return nil
}
// MessagesResponse is the response from fetching a conversation's messages.
type MessagesResponse struct {
Offset int `json:"offset"`
Limit int `json:"limit"`
TotalCount int `json:"totalCount"`
Messages []Message
}
// UnmarshalJSON handles the deeply nested HAL format for messages.
func (r *MessagesResponse) UnmarshalJSON(data []byte) error {
var raw struct {
Embedded struct {
Messages struct {
Offset int `json:"offset"`
Limit int `json:"limit"`
TotalCount int `json:"totalCount"`
Embedded struct {
Message []Message `json:"mc:message"`
} `json:"_embedded"`
} `json:"mc:messages"`
} `json:"_embedded"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
r.Offset = raw.Embedded.Messages.Offset
r.Limit = raw.Embedded.Messages.Limit
r.TotalCount = raw.Embedded.Messages.TotalCount
r.Messages = raw.Embedded.Messages.Embedded.Message
return nil
}
// ServerTime returns the current server datetime.
func (s *MessagingService) ServerTime(ctx context.Context) (time.Time, error) {
var resp struct {
DateTime time.Time `json:"dateTime"`
}
if err := s.t.get(ctx, "/app/messaging/v1/datetime", nil, &resp); err != nil {
return time.Time{}, err
}
return resp.DateTime, nil
}
// ConversationsOptions configures a conversations list request.
type ConversationsOptions struct {
Offset int
Limit int
Latitude float64
Longitude float64
}
// Conversations lists the authenticated user's conversations.
func (s *MessagingService) Conversations(ctx context.Context, opts *ConversationsOptions) (*ConversationsResponse, error) {
params := url.Values{}
if opts != nil {
if opts.Offset > 0 {
params.Set("offset", fmt.Sprintf("%d", opts.Offset))
}
if opts.Limit > 0 {
params.Set("limit", fmt.Sprintf("%d", opts.Limit))
}
if opts.Latitude != 0 {
params.Set("latitude", fmt.Sprintf("%f", opts.Latitude))
}
if opts.Longitude != 0 {
params.Set("longitude", fmt.Sprintf("%f", opts.Longitude))
}
}
var resp ConversationsResponse
if err := s.t.get(ctx, "/app/messaging/v1/conversations/android", params, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// Messages retrieves the messages in a conversation.
func (s *MessagingService) Messages(ctx context.Context, conversationID string, offset, limit int) (*MessagesResponse, error) {
path := fmt.Sprintf("/app/messaging/v1/conversations/android/%s/messages", conversationID)
params := url.Values{
"offset": {fmt.Sprintf("%d", offset)},
"limit": {fmt.Sprintf("%d", limit)},
}
var resp MessagesResponse
if err := s.t.get(ctx, path, params, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// StartConversation sends an initial message to the seller of a listing
// using the ASQ (Ask Seller a Question) enquiry endpoint.
// sellerID is the numeric seller ID from ListingDetail.SellerInformation.ID.
// Returns nil on success (the API responds with 204 No Content).
func (s *MessagingService) StartConversation(ctx context.Context, itemID, sellerID, text string) error {
body := AskQuestionBody{
AdURN: itemID,
BidValue: -1.0,
Body: text,
RecipientID: sellerID,
}
return s.t.postJSON(ctx, "/app/enquiry/v1/question", nil, nil, body, nil)
}
// SendMessage sends a text message in an existing conversation.
func (s *MessagingService) SendMessage(ctx context.Context, conversationID, text string) (string, error) {
path := fmt.Sprintf("/app/messaging/v1/conversations/android/%s/text", conversationID)
body := struct {
Text string `json:"text"`
Actions any `json:"actions"`
}{
Text: text,
}
var resp struct {
ID string `json:"id"`
}
if err := s.t.postJSON(ctx, path, nil, nil, body, &resp); err != nil {
return "", err
}
return resp.ID, nil
}
// EmailSubscriptions returns the user's messaging email notification settings.
func (s *MessagingService) EmailSubscriptions(ctx context.Context) (json.RawMessage, error) {
var resp json.RawMessage
if err := s.t.get(ctx, "/app/messaging/v1/subscriptions/email", nil, &resp); err != nil {
return nil, err
}
return resp, nil
}
+186
View File
@@ -0,0 +1,186 @@
package mrktplaats
import (
"encoding/json"
"fmt"
"time"
)
// Price represents a listing price. All amounts are in euro cents.
type Price struct {
PriceType string `json:"priceType"`
PriceTypeLabel string `json:"priceTypeLabel"`
PriceAmount int `json:"priceAmount"`
}
// Picture represents an image with CDN URLs at various sizes.
type Picture struct {
ID int `json:"id"`
ExtraSmall string `json:"extraSmall"`
Medium string `json:"medium"`
Large string `json:"large"`
ExtraExtraLarge string `json:"extraExtraLarge,omitempty"`
AspectRatio interface{} `json:"aspectRatio,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
// AdAddress represents the geographic location of a listing.
type AdAddress struct {
City string `json:"city"`
Country string `json:"country"`
CountryAbbreviation string `json:"countryAbbreviation"`
ZipCode string `json:"zipCode,omitempty"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
// Attribute represents a listing attribute (e.g. condition, brand).
type Attribute struct {
ID int `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Values []AttributeValue `json:"values"`
}
// AttributeValue represents a single value of an attribute.
type AttributeValue struct {
ID int `json:"id"`
Name string `json:"name"`
}
// AdCore contains the core fields shared across search results and item detail views.
type AdCore struct {
URN string `json:"urn"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
CategoryID int `json:"categoryId"`
Price Price `json:"price"`
Picture *Picture `json:"picture,omitempty"`
Pictures []Picture `json:"pictures,omitempty"`
AdAddress AdAddress `json:"adAddress"`
Attributes []Attribute `json:"attributes,omitempty"`
Link string `json:"link,omitempty"`
}
// SellerInformation identifies the seller of a listing.
type SellerInformation struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type,omitempty"`
Phone string `json:"phone,omitempty"`
PhoneHidden bool `json:"phoneNumberHidden,omitempty"`
AllowASQ bool `json:"allowAsq,omitempty"`
ActiveSince json.RawMessage `json:"activeSince,omitempty"`
SavedForUser bool `json:"savedForUser,omitempty"`
KYCState json.RawMessage `json:"kycState,omitempty"`
MerchantState string `json:"merchantState,omitempty"`
DealerPackage string `json:"dealerPackage,omitempty"`
}
// User represents an authenticated user account.
type User struct {
ID int `json:"id"`
EncryptedID string `json:"encryptedId"`
Name string `json:"name"`
Email string `json:"email"`
Phone string `json:"phone"`
ZipCode string `json:"zipCode"`
ActivationDate string `json:"activationDate"`
RegistrationDate string `json:"registrationDate"`
OneClickOptedIn bool `json:"oneClickOptedIn"`
OneClickBlocked bool `json:"oneClickBlocked"`
ProfilePicture string `json:"profilePicture"`
ShowMapOnVipEnabled bool `json:"showMapOnVipEnabled"`
NdfcDeclarationStatus string `json:"ndfcDeclarationStatus"`
}
// DiscoveryPrice is the snake_case price used by older API3 endpoints.
type DiscoveryPrice struct {
PriceType string `json:"price_type"`
PriceTypeLabel string `json:"price_type_label"`
PriceAmount int `json:"price_amount"`
}
// DiscoveryPicture is the snake_case picture used by older API3 endpoints.
type DiscoveryPicture struct {
ID int `json:"id"`
ExtraSmall string `json:"extra_small"`
Medium string `json:"medium"`
Large string `json:"large"`
}
// FlexTime is a time.Time that accepts dates without a timezone suffix
// (e.g. "2025-10-18T14:50:53") in addition to standard RFC3339.
type FlexTime struct{ time.Time }
// UnmarshalJSON parses RFC3339 dates, falling back to local (UTC) parsing
// when the timezone suffix is absent.
func (ft *FlexTime) UnmarshalJSON(data []byte) error {
if len(data) < 2 || data[0] != '"' {
return nil
}
s := string(data[1 : len(data)-1])
t, err := time.Parse(time.RFC3339, s)
if err != nil {
t, err = time.ParseInLocation("2006-01-02T15:04:05", s, time.UTC)
if err != nil {
return fmt.Errorf("FlexTime: cannot parse %q", s)
}
}
ft.Time = t
return nil
}
// Review represents a user review.
type Review struct {
ID int `json:"id"`
Reviewer ReviewUser `json:"reviewer"`
Reviewee ReviewUser `json:"reviewee"`
Direction string `json:"direction"`
CreationDate FlexTime `json:"creationDate"`
Score int `json:"score"`
ItemID string `json:"itemId"`
Advertisement ReviewAd `json:"advertisement"`
Details []ReviewDetail `json:"details"`
Content ReviewContent `json:"content"`
ReviewSubject string `json:"reviewSubject"`
}
// ReviewUser is a participant in a review.
type ReviewUser struct {
ID int `json:"id"`
Nickname string `json:"nickname"`
}
// ReviewAd is the listing associated with a review.
type ReviewAd struct {
ID string `json:"id"`
Title string `json:"title"`
CategoryName string `json:"categoryName"`
}
// ReviewDetail contains category-level review scoring.
type ReviewDetail struct {
Category string `json:"category"`
Score int `json:"score"`
Characteristics []ReviewCharacteristic `json:"characteristics"`
}
// ReviewCharacteristic is an individual review trait.
type ReviewCharacteristic struct {
ID int `json:"id"`
Text string `json:"text"`
IsPositive bool `json:"isPositive"`
}
// ReviewContent holds the textual content of a review.
type ReviewContent struct {
Subject string `json:"subject"`
}
// ReviewSummary provides aggregate review statistics.
type ReviewSummary struct {
NumberOfReviews int `json:"numberOfReviews"`
AverageScore float64 `json:"averageScore"`
}
+133
View File
@@ -0,0 +1,133 @@
package mrktplaats
import "net/http"
// Client is the top-level API client for marktplaats.nl.
// Use NewClient to create one, then access sub-services via the exported fields.
type Client struct {
Auth *AuthService
Search *SearchService
Listings *ListingService
Discovery *DiscoveryService
Categories *CategoryService
Messaging *MessagingService
Favorites *FavoritesService
Users *UserService
MyAccount *MyAccountService
SYI *SYIService
Saleability *SaleabilityService
Enquiry *EnquiryService
Relevant *RelevantService
Notifications *NotificationService
Payments *PaymentService
Config *ConfigService
transport *transport
}
// NewClient creates a new marktplaats API client.
func NewClient(opts ...Option) *Client {
t := newTransport()
c := &Client{transport: t}
for _, opt := range opts {
opt(c)
}
c.Auth = &AuthService{t: t}
c.Search = &SearchService{t: t}
c.Listings = &ListingService{t: t}
c.Discovery = &DiscoveryService{t: t}
c.Categories = &CategoryService{t: t}
c.Messaging = &MessagingService{t: t}
c.Favorites = &FavoritesService{t: t}
c.Users = &UserService{t: t}
c.MyAccount = &MyAccountService{t: t}
c.SYI = &SYIService{t: t}
c.Saleability = &SaleabilityService{t: t}
c.Enquiry = &EnquiryService{t: t}
c.Relevant = &RelevantService{t: t}
c.Notifications = &NotificationService{t: t}
c.Payments = &PaymentService{t: t}
c.Config = &ConfigService{t: t}
return c
}
// SetAccessToken sets the bearer token for authenticated requests.
// This is called automatically after a successful Auth.VerifyCode.
func (c *Client) SetAccessToken(token string) {
c.transport.setAccessToken(token)
}
// SessionParams contains the per-session identifiers that the API expects to
// remain constant across related requests (e.g. login → 2FA verify-code).
type SessionParams struct {
Session string `json:"session"`
GAClientID string `json:"gaClientId"`
MagicNumber string `json:"magicNumber"`
ThreatMetrixSessionID string `json:"threatMetrixSessionId"`
}
// SessionParams returns the current session identifiers used by the client.
// Useful for persisting them between process invocations (e.g. CLI 2FA flow).
func (c *Client) SessionParams() SessionParams {
return SessionParams{
Session: c.transport.session,
GAClientID: c.transport.gaClientID,
MagicNumber: c.transport.magicNumber,
ThreatMetrixSessionID: c.transport.threatMetrixSessionID,
}
}
// Option configures a Client.
type Option func(*Client)
// WithHTTPClient sets a custom http.Client for all requests.
func WithHTTPClient(hc *http.Client) Option {
return func(c *Client) {
c.transport.httpClient = hc
}
}
// WithAccessToken sets an initial bearer token.
func WithAccessToken(token string) Option {
return func(c *Client) {
c.transport.setAccessToken(token)
}
}
// WithSession sets the session UUID sent with every request.
func WithSession(id string) Option {
return func(c *Client) {
c.transport.session = id
}
}
// WithGAClientID sets the Google Analytics client ID.
func WithGAClientID(id string) Option {
return func(c *Client) {
c.transport.gaClientID = id
}
}
// WithMagicNumber sets the magic_number query parameter.
func WithMagicNumber(n string) Option {
return func(c *Client) {
c.transport.magicNumber = n
}
}
// WithBaseURL overrides the default base URL (useful for testing).
func WithBaseURL(u string) Option {
return func(c *Client) {
c.transport.baseURL = u
}
}
// WithThreatMetrixSessionID sets the X-Threatmetrix-Session-Id header.
func WithThreatMetrixSessionID(id string) Option {
return func(c *Client) {
c.transport.setThreatMetrixSessionID(id)
}
}
+102
View File
@@ -0,0 +1,102 @@
package mrktplaats
import (
"context"
"fmt"
"net/url"
)
// MyAccountService handles the seller dashboard and profile updates.
type MyAccountService struct {
t *transport
}
// MyAd is a listing owned by the authenticated user.
type MyAd struct {
ItemID string `json:"itemId"`
Title string `json:"title"`
Status string `json:"status"`
PriceInCents int `json:"priceInCents"`
}
// MyAdTab describes a tab in the seller's ad dashboard.
type MyAdTab struct {
Title string `json:"title"`
Count int `json:"count"`
Status string `json:"status"`
}
// MyAdsResponse is the response from listing the seller's own ads.
type MyAdsResponse struct {
MyAds []MyAd `json:"myAds"`
MyAdsTotalCount int `json:"myAdsTotalCount"`
Tabs []MyAdTab `json:"tabs"`
}
// MyAdsOptions configures a my-ads list request.
type MyAdsOptions struct {
Status string // "active", "inactive", "sold"
Offset int
Limit int
Counts bool
}
// MyAds returns the authenticated user's own listings.
func (s *MyAccountService) MyAds(ctx context.Context, opts *MyAdsOptions) (*MyAdsResponse, error) {
params := url.Values{}
if opts != nil {
if opts.Status != "" {
params.Set("status", opts.Status)
}
if opts.Offset > 0 {
params.Set("offset", fmt.Sprintf("%d", opts.Offset))
}
if opts.Limit > 0 {
params.Set("limit", fmt.Sprintf("%d", opts.Limit))
}
if opts.Counts {
params.Set("counts", "true")
}
}
var resp MyAdsResponse
if err := s.t.get(ctx, "/app/my-account/v3/myads", params, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// UpdateProfileRequest defines fields to update on the user profile.
type UpdateProfileRequest struct {
Name string `json:"name"`
PhoneNumber string `json:"phoneNumber"`
Postcode string `json:"postcode"`
NdfcDeclarationStatus string `json:"ndfcDeclarationStatus"`
}
// UpdateProfile updates the authenticated user's profile.
func (s *MyAccountService) UpdateProfile(ctx context.Context, req *UpdateProfileRequest) error {
return s.t.postJSON(ctx, "/app/my-account/profile/v2/user-info/user", nil, nil, req, nil)
}
// DeleteAdReason indicates why the authenticated user is removing a listing.
type DeleteAdReason int
const (
DeleteReasonSoldOnTweedehands DeleteAdReason = 1
DeleteReasonSoldElsewhere DeleteAdReason = 2
DeleteReasonNotSelling DeleteAdReason = 3
DeleteReasonOther DeleteAdReason = 4
)
// DeleteAds removes one or more of the authenticated user's listings.
// reason 4 (DeleteReasonOther) is the generic deletion reason.
func (s *MyAccountService) DeleteAds(ctx context.Context, itemIDs []string, reason DeleteAdReason) error {
body := struct {
ItemIDs []string `json:"itemIds"`
Reason int `json:"reason"`
}{
ItemIDs: itemIDs,
Reason: int(reason),
}
return s.t.deleteJSON(ctx, "/app/my-account/v3/myads", nil, body, nil)
}
+31
View File
@@ -0,0 +1,31 @@
package mrktplaats
import "context"
// NotificationService handles notification counts.
type NotificationService struct {
t *transport
}
// NotificationEvent is an analytics event associated with notifications.
type NotificationEvent struct {
EventType string `json:"eventType"`
Category string `json:"category"`
Action string `json:"action"`
Label string `json:"label"`
}
// UnreadCountResponse contains the unread notification count.
type UnreadCountResponse struct {
UnreadNotificationsCount int `json:"unreadNotificationsCount"`
Events []NotificationEvent `json:"events"`
}
// UnreadCount returns the number of unread notifications.
func (s *NotificationService) UnreadCount(ctx context.Context) (*UnreadCountResponse, error) {
var resp UnreadCountResponse
if err := s.t.get(ctx, "/app/notification-center/v1/unreadCount", nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
+23
View File
@@ -0,0 +1,23 @@
package mrktplaats
import "context"
// PaymentService handles payment cart operations.
type PaymentService struct {
t *transport
}
// PaymentCartCountResponse contains the number of items in the payment cart.
type PaymentCartCountResponse struct {
UserID string `json:"userId"`
NumberOfItems int `json:"numberOfItems"`
}
// CartItemCount returns the number of items in the payment cart.
func (s *PaymentService) CartItemCount(ctx context.Context) (*PaymentCartCountResponse, error) {
var resp PaymentCartCountResponse
if err := s.t.get(ctx, "/app/payments/v3/payment-cart/number-of-items", nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
+47
View File
@@ -0,0 +1,47 @@
package mrktplaats
import (
"context"
"fmt"
"net/url"
)
// RelevantService retrieves similar/related listings.
type RelevantService struct {
t *transport
}
// SimilarAdCore is the ad core from the older API3 format (snake_case).
type SimilarAdCore struct {
ID string `json:"id"`
Title string `json:"title"`
Picture DiscoveryPicture `json:"picture"`
Price DiscoveryPrice `json:"price"`
}
// SimilarItem is a related listing.
type SimilarItem struct {
AdCore SimilarAdCore `json:"ad_core"`
PageLocation string `json:"page_location"`
}
// SimilarAdsResponse contains similar listings.
type SimilarAdsResponse struct {
Items []SimilarItem `json:"items"`
ResultType string `json:"result_type"`
}
// Get returns similar/related listings for a given listing.
func (s *RelevantService) Get(ctx context.Context, listingID string, sellerID string, categoryID int) (*SimilarAdsResponse, error) {
path := fmt.Sprintf("/api3/ads/relevant/%s.json", listingID)
params := url.Values{}
if sellerID != "" {
params.Set("seller_id", sellerID)
}
params.Set("category_id", fmt.Sprintf("%d", categoryID))
var resp SimilarAdsResponse
if err := s.t.get(ctx, path, params, &resp); err != nil {
return nil, err
}
return &resp, nil
}
+53
View File
@@ -0,0 +1,53 @@
package mrktplaats
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
)
// SaleabilityService handles image recognition for listings.
type SaleabilityService struct {
t *transport
}
// Prediction is an image recognition result.
type Prediction struct {
CategoryID int `json:"categoryId"`
CategoryName string `json:"categoryName"`
Confidence float64 `json:"confidence"`
Attributes []any `json:"attributes"`
}
// RecognizeResponse contains image recognition predictions.
type RecognizeResponse struct {
Predictions []Prediction `json:"predictions"`
}
// Recognize uploads an image and returns category/attribute predictions.
// Large images are automatically compressed to stay within the API size limit.
func (s *SaleabilityService) Recognize(ctx context.Context, img io.Reader, filename string) (*RecognizeResponse, error) {
data, err := autoCompressImage(img)
if err != nil {
return nil, fmt.Errorf("mrktplaats: reading image: %w", err)
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, err := w.CreateFormFile("picture", filename)
if err != nil {
return nil, fmt.Errorf("mrktplaats: creating form file: %w", err)
}
if _, err := io.Copy(part, bytes.NewReader(data)); err != nil {
return nil, fmt.Errorf("mrktplaats: copying image: %w", err)
}
w.Close()
var resp RecognizeResponse
if err := s.t.postMultipart(ctx, "/app/saleability/v4/recognize", nil, nil, w.FormDataContentType(), &buf, &resp); err != nil {
return nil, err
}
return &resp, nil
}
+222
View File
@@ -0,0 +1,222 @@
package mrktplaats
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
)
// SearchService handles listing search and keyword suggestions.
type SearchService struct {
t *transport
}
// DefaultSupportedTypes is the standard set of item types the client declares support for.
var DefaultSupportedTypes = []string{
"defaultListing",
"icasTopBlockListing",
"icasSiblingListing",
"sellerHeaderInformation",
"srpHeaderNativeAd",
"srpListNativeAd",
"displayBanner",
"similarItems",
"srpSaveSearchFooter",
"lrpDisclaimer",
"shippingPromotion",
"dac7banner",
"knnInfoBox",
}
// SearchRequest defines the parameters for a listing search.
type SearchRequest struct {
CategoryID *int `json:"categoryId,omitempty"`
Query string `json:"q"`
Page int `json:"page"`
Size int `json:"size"`
SortBy string `json:"sortBy"`
Languages []string `json:"languages,omitempty"`
Latitude float64 `json:"latitude,omitempty"`
Longitude float64 `json:"longitude,omitempty"`
AllowCorrection bool `json:"allowCorrection"`
SearchOnTitleAndDescription bool `json:"searchOnTitleAndDescription"`
ShowListings bool `json:"showListings"`
ShowSimilarItems bool `json:"showSimilarItems"`
SupportsReservedFlag bool `json:"supportsReservedFlag"`
LrpDisclaimerDismissTime int `json:"lrpDisclaimerDismissTime"`
AvailableTilesInLastRow int `json:"availableTilesInLastRow"`
SellerID string `json:"sellerId,omitempty"`
AttributesById []AttributeFilter `json:"attributesById,omitempty"`
}
// AttributeFilter filters search results by a specific attribute key-value pair.
type AttributeFilter struct {
AttributeKeyID string `json:"attributeKeyId"`
AttributeValueID string `json:"attributeValueId"`
}
type searchRequestEnvelope struct {
SearchRequest SearchRequest `json:"searchRequest"`
SupportedTypes []string `json:"supportedTypes"`
}
// SearchResponse contains the search results.
type SearchResponse struct {
Items []SearchItem `json:"items"`
SearchHistograms *SearchHistograms `json:"searchHistograms,omitempty"`
CorrelationID string `json:"correlationId"`
HasErrors bool `json:"hasErrors"`
CategoryID int `json:"categoryId"`
BigViewAllowed bool `json:"bigViewAllowed"`
}
// SearchHistograms contains facet/filter information from a search.
type SearchHistograms struct {
NumFound int `json:"numFound"`
Attributes json.RawMessage `json:"attributes,omitempty"`
Categories json.RawMessage `json:"categories,omitempty"`
SortableFields json.RawMessage `json:"sortableFields,omitempty"`
}
// SearchItem is a single item in search results. The ItemType field
// determines which field is populated.
type SearchItem struct {
ItemType string `json:"itemType"`
DefaultListing *DefaultListing `json:"defaultListing,omitempty"`
IcasTopBlockListing *DefaultListing `json:"icasTopBlockListing,omitempty"`
IcasSiblingListing *DefaultListing `json:"icasSiblingListing,omitempty"`
}
// Listing returns the first non-nil listing from the item, regardless of type.
// Returns nil for non-listing item types (banners, disclaimers, etc.).
func (i *SearchItem) Listing() *DefaultListing {
switch {
case i.DefaultListing != nil:
return i.DefaultListing
case i.IcasTopBlockListing != nil:
return i.IcasTopBlockListing
case i.IcasSiblingListing != nil:
return i.IcasSiblingListing
default:
return nil
}
}
// DefaultListing represents a standard listing in search results.
type DefaultListing struct {
AdCore AdCore `json:"adCore"`
DistanceInMeter int `json:"distanceInMeter,omitempty"`
LocationDescription string `json:"locationDescription,omitempty"`
SellerInformation SellerInformation `json:"sellerInformation"`
URL string `json:"url,omitempty"`
ExtraImages json.RawMessage `json:"extraImages,omitempty"`
Thumbnails json.RawMessage `json:"thumbnails,omitempty"`
TotalPictures int `json:"totalPictures,omitempty"`
AdType string `json:"adType,omitempty"`
SortDateTime string `json:"sortDateTime,omitempty"`
Flags json.RawMessage `json:"flags,omitempty"`
SavedAdForUser bool `json:"savedAdForUser,omitempty"`
Shippable bool `json:"shippable,omitempty"`
Reserved bool `json:"reserved,omitempty"`
PageLocation string `json:"pageLocation,omitempty"`
}
// KeywordSuggestionsResponse is the response from keyword autocomplete.
type KeywordSuggestionsResponse struct {
Keyword string `json:"keyword"`
Category int `json:"category"`
Suggestions []string `json:"suggestions"`
}
// KeywordSuggestions returns autocomplete suggestions for a search prefix.
func (s *SearchService) KeywordSuggestions(ctx context.Context, prefix string, category int) (*KeywordSuggestionsResponse, error) {
params := url.Values{
"prefix": {prefix},
"category": {fmt.Sprintf("%d", category)},
}
var resp KeywordSuggestionsResponse
if err := s.t.get(ctx, "/app/lrp/v1/keyword-suggestions", params, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// Fetch performs a listing search with the given request.
// If supportedTypes is nil, DefaultSupportedTypes is used.
func (s *SearchService) Fetch(ctx context.Context, req *SearchRequest, supportedTypes []string) (*SearchResponse, error) {
if supportedTypes == nil {
supportedTypes = DefaultSupportedTypes
}
envelope := searchRequestEnvelope{
SearchRequest: *req,
SupportedTypes: supportedTypes,
}
var resp SearchResponse
if err := s.t.postJSON(ctx, "/app/lrp/v1/items/fetch", nil, nil, envelope, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// DisplayTargetingRequest defines parameters for search ad targeting.
// Positions is a slice of position strings that will be serialized as a
// comma-separated string as required by the API.
// Postcode and SellerID are sent as JSON null when empty.
type DisplayTargetingRequest struct {
Attr json.RawMessage
Positions []string
Query string
SearchCategoryID int
Postcode string
SellerID string
}
// MarshalJSON serializes the request in the format the marktplaats API expects:
// positions as a comma-separated string, attr as {} when nil, and
// postcode/sellerId as null when empty.
func (r DisplayTargetingRequest) MarshalJSON() ([]byte, error) {
attr := r.Attr
if len(attr) == 0 {
attr = json.RawMessage(`{}`)
}
type wire struct {
Attr json.RawMessage `json:"attr"`
Positions string `json:"positions"`
Query string `json:"q"`
SearchCategoryID int `json:"searchCategoryId"`
Postcode *string `json:"postcode"`
SellerID *string `json:"sellerId"`
}
w := wire{
Attr: attr,
Positions: strings.Join(r.Positions, ", "),
Query: r.Query,
SearchCategoryID: r.SearchCategoryID,
}
if r.Postcode != "" {
w.Postcode = &r.Postcode
}
if r.SellerID != "" {
w.SellerID = &r.SellerID
}
return json.Marshal(w)
}
// DisplayTargetingResult is a single ad slot targeting result.
type DisplayTargetingResult struct {
Position string `json:"position"`
AdUnitID string `json:"adUnitId"`
AdditionalParameters map[string][]string `json:"additionalParameters"`
}
// DisplayTargeting retrieves ad targeting configuration for search results.
func (s *SearchService) DisplayTargeting(ctx context.Context, req *DisplayTargetingRequest) ([]DisplayTargetingResult, error) {
var resp []DisplayTargetingResult
if err := s.t.postJSON(ctx, "/app/lrp/v1/display-targeting", nil, nil, req, &resp); err != nil {
return nil, err
}
return resp, nil
}
+209
View File
@@ -0,0 +1,209 @@
package mrktplaats
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/url"
)
// SYIService handles creating listings (Sell Your Item).
type SYIService struct {
t *transport
}
// SYIAttribute describes a form field for creating a listing in a category.
type SYIAttribute struct {
ID string `json:"id"`
Key string `json:"key"`
Label string `json:"label"`
Name string `json:"name"`
AttributeType string `json:"attributeType"`
Mandatory bool `json:"mandatory"`
SupportedValues []SYIAttributeValue `json:"supportedValues"`
}
// SYIAttributeValue is a selectable value for an SYI attribute.
type SYIAttributeValue struct {
ID string `json:"id"`
Key string `json:"key"`
Label string `json:"label"`
Name string `json:"name"`
}
// SYIFormResponse is returned when fetching the SYI form for a category.
type SYIFormResponse struct {
CategoryID int `json:"categoryId"`
SYIAttributes []SYIAttribute `json:"syiAttributes"`
}
// Form retrieves the listing creation form for a category.
func (s *SYIService) Form(ctx context.Context, categoryID int) (*SYIFormResponse, error) {
path := fmt.Sprintf("/app/syi/v7/%d", categoryID)
var resp SYIFormResponse
if err := s.t.get(ctx, path, nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// Translation holds locale-specific title and description.
type Translation struct {
Locale string `json:"locale"`
Title string `json:"title"`
Description string `json:"description"`
}
// ShippingConfig defines shipping carrier configuration.
type ShippingConfig struct {
Carriers []string `json:"carriers"`
DIYPriceInCent *int `json:"diyPriceInCent,omitempty"`
PackageID *string `json:"packageId,omitempty"`
}
// CreateAdRequest defines the payload for creating a new listing.
type CreateAdRequest struct {
CategoryID int `json:"categoryId"`
Translations []Translation `json:"translations"`
PriceInCents int `json:"priceInCents"`
PriceType string `json:"priceType"`
BiddingEnabled bool `json:"biddingEnabled"`
BuyItNowEnabled bool `json:"buyItNowEnabled"`
BuyerProtectionAllowed bool `json:"buyerProtectionAllowed"`
InitialMinimumBid int `json:"initialMinimumBid,omitempty"`
Attributes []any `json:"attributes"`
PictureIDs []string `json:"pictureIds"`
DeliveryMethod string `json:"deliveryMethod"`
ShippingConfig ShippingConfig `json:"shippingConfig"`
SellerName string `json:"sellerName"`
Postcode string `json:"postcode"`
AddToPaymentCart bool `json:"addToPaymentCart"`
SelectedBundle string `json:"selectedBundle"`
SYISessionID string `json:"syiSessionId"`
IntegratedShippingForTWHEnabled bool `json:"integratedShippingForTWHEnabled"`
FeatureSource string `json:"featureSource,omitempty"`
FeatureTypes []any `json:"featureTypes,omitempty"`
URL *string `json:"url"`
LicensePlate *string `json:"licensePlate"`
MicroTip *string `json:"microTip"`
OriginalAd *string `json:"originalAd"`
PhoneNumber *string `json:"phoneNumber"`
GenAIVersion *string `json:"genAiVersion"`
UseDynamicPricing bool `json:"useDynamicPricing"`
}
// CreateAdData holds the ad and feature data returned after creating a listing.
type CreateAdData struct {
AdCore json.RawMessage `json:"adCore"`
Features json.RawMessage `json:"features"`
}
// CreateAdResponse is the response from creating a listing.
type CreateAdResponse struct {
Ad CreateAdData `json:"ad"`
}
// URN parses and returns the URN of the newly created listing.
func (r *CreateAdResponse) URN() string {
var core struct {
URN string `json:"urn"`
}
if err := json.Unmarshal(r.Ad.AdCore, &core); err != nil {
return ""
}
return core.URN
}
// Create publishes a new listing.
func (s *SYIService) Create(ctx context.Context, req *CreateAdRequest) (*CreateAdResponse, error) {
params := url.Values{"syiType": {"shortFlow"}}
var resp CreateAdResponse
if err := s.t.postJSON(ctx, "/app/syi/v7/ads", params, nil, req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// UploadImage uploads an image for use in a listing.
// Returns the picture ID to include in CreateAdRequest.PictureIDs.
// Large images are automatically compressed to stay within the API size limit.
func (s *SYIService) UploadImage(ctx context.Context, img io.Reader, filename string) (string, error) {
data, err := autoCompressImage(img)
if err != nil {
return "", fmt.Errorf("mrktplaats: reading image: %w", err)
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, err := w.CreateFormFile("image_file", filename)
if err != nil {
return "", fmt.Errorf("mrktplaats: creating form file: %w", err)
}
if _, err := io.Copy(part, bytes.NewReader(data)); err != nil {
return "", fmt.Errorf("mrktplaats: copying image: %w", err)
}
w.Close()
var resp struct {
PictureID string `json:"pictureId"`
}
if err := s.t.postMultipart(ctx, "/app/syi/v7/images/upload", nil, nil, w.FormDataContentType(), &buf, &resp); err != nil {
return "", err
}
return resp.PictureID, nil
}
// PriceSegment represents a price range suggestion.
type PriceSegment struct {
Title string `json:"title"`
MinPrice int `json:"minPrice"`
MaxPrice int `json:"maxPrice"`
TotalSimilarAdsCount int `json:"totalSimilarAdsCount"`
}
// PriceSuggestionResponse contains price range suggestions.
type PriceSuggestionResponse struct {
Segments []PriceSegment `json:"segments"`
}
// PriceSuggestion returns price range suggestions for a title in a category.
func (s *SYIService) PriceSuggestion(ctx context.Context, categoryID int, title string) (*PriceSuggestionResponse, error) {
params := url.Values{
"category_id": {fmt.Sprintf("%d", categoryID)},
"title": {title},
}
var resp PriceSuggestionResponse
if err := s.t.get(ctx, "/app/syi/v7/price-suggestion", params, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// AttributeSuggestions returns suggested attribute values for a description.
func (s *SYIService) AttributeSuggestions(ctx context.Context, categoryID int, text string) (json.RawMessage, error) {
body := struct {
CategoryID int `json:"categoryId"`
Text string `json:"text"`
}{CategoryID: categoryID, Text: text}
var resp json.RawMessage
if err := s.t.postJSON(ctx, "/app/syi/v7/attribute-suggestions", nil, nil, body, &resp); err != nil {
return nil, err
}
return resp, nil
}
// SuspiciousKeywordsWarnings checks a listing description for suspicious content.
func (s *SYIService) SuspiciousKeywordsWarnings(ctx context.Context, categoryID int, text string) (json.RawMessage, error) {
body := struct {
CategoryID int `json:"categoryId"`
Text string `json:"text"`
}{CategoryID: categoryID, Text: text}
var resp json.RawMessage
if err := s.t.postJSON(ctx, "/app/syi/v7/category/suspicious-keywords-warnings", nil, nil, body, &resp); err != nil {
return nil, err
}
return resp, nil
}
+261
View File
@@ -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)
}
+54
View File
@@ -0,0 +1,54 @@
package mrktplaats
import (
"context"
"fmt"
"net/url"
)
// UserService handles user profiles and reviews.
type UserService struct {
t *transport
}
// UserInfoResponse wraps the user profile.
type UserInfoResponse struct {
User User `json:"user"`
}
// Me returns the authenticated user's profile.
func (s *UserService) Me(ctx context.Context) (*User, error) {
var resp UserInfoResponse
if err := s.t.get(ctx, "/app/my-account/profile/v2/user-info/user", nil, &resp); err != nil {
return nil, err
}
return &resp.User, nil
}
// ReviewsResponse contains reviews and summary statistics.
type ReviewsResponse struct {
Summary ReviewSummary `json:"summary"`
Reviews []Review `json:"reviews"`
}
// ReviewRole determines which reviews to fetch.
type ReviewRole string
const (
ReviewRoleAll ReviewRole = ""
ReviewRoleReviewee ReviewRole = "reviewee"
)
// Reviews returns reviews for a user, optionally filtered by role.
func (s *UserService) Reviews(ctx context.Context, userID int, role ReviewRole) (*ReviewsResponse, error) {
path := fmt.Sprintf("/app/user-review/v1/reviews/%d", userID)
params := url.Values{}
if role != "" {
params.Set("role", string(role))
}
var resp ReviewsResponse
if err := s.t.get(ctx, path, params, &resp); err != nil {
return nil, err
}
return &resp, nil
}