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:
@@ -0,0 +1,2 @@
|
||||
.test-token
|
||||
mrktplaats
|
||||
@@ -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 |
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user