commit 25410749db2779c09965fca037a998fc6dcf6a24 Author: Joren Date: Wed Apr 15 23:45:49 2026 +0200 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d171635 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.test-token +mrktplaats diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e4d986 --- /dev/null +++ b/README.md @@ -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. diff --git a/TEST/IMG_20240522_182451.jpg b/TEST/IMG_20240522_182451.jpg new file mode 100644 index 0000000..c9adf6a Binary files /dev/null and b/TEST/IMG_20240522_182451.jpg differ diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..4cc7c53 --- /dev/null +++ b/auth.go @@ -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 +} diff --git a/categories.go b/categories.go new file mode 100644 index 0000000..fac145c --- /dev/null +++ b/categories.go @@ -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 +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..78a0e83 --- /dev/null +++ b/config.go @@ -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 +} diff --git a/discovery.go b/discovery.go new file mode 100644 index 0000000..ad05ebb --- /dev/null +++ b/discovery.go @@ -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 +} diff --git a/enquiry.go b/enquiry.go new file mode 100644 index 0000000..5a2a5e3 --- /dev/null +++ b/enquiry.go @@ -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 +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..e1776ff --- /dev/null +++ b/errors.go @@ -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 +} diff --git a/favorites.go b/favorites.go new file mode 100644 index 0000000..2e329ed --- /dev/null +++ b/favorites.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..37b0481 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d683d06 --- /dev/null +++ b/go.sum @@ -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= diff --git a/imageutil.go b/imageutil.go new file mode 100644 index 0000000..e054a8c --- /dev/null +++ b/imageutil.go @@ -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 +} diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..5a51b1a --- /dev/null +++ b/integration_test.go @@ -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) + }) +} diff --git a/listing.go b/listing.go new file mode 100644 index 0000000..ce218cb --- /dev/null +++ b/listing.go @@ -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 +} diff --git a/messaging.go b/messaging.go new file mode 100644 index 0000000..99d476e --- /dev/null +++ b/messaging.go @@ -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 +} diff --git a/models.go b/models.go new file mode 100644 index 0000000..d20496b --- /dev/null +++ b/models.go @@ -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"` +} diff --git a/mrktplaats.go b/mrktplaats.go new file mode 100644 index 0000000..f9f3129 --- /dev/null +++ b/mrktplaats.go @@ -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) + } +} diff --git a/myaccount.go b/myaccount.go new file mode 100644 index 0000000..2bc9b50 --- /dev/null +++ b/myaccount.go @@ -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) +} diff --git a/notifications.go b/notifications.go new file mode 100644 index 0000000..1cf9240 --- /dev/null +++ b/notifications.go @@ -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 +} diff --git a/payments.go b/payments.go new file mode 100644 index 0000000..7ae6f70 --- /dev/null +++ b/payments.go @@ -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 +} diff --git a/relevant.go b/relevant.go new file mode 100644 index 0000000..106c0a5 --- /dev/null +++ b/relevant.go @@ -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 +} diff --git a/saleability.go b/saleability.go new file mode 100644 index 0000000..44dee46 --- /dev/null +++ b/saleability.go @@ -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 +} diff --git a/search.go b/search.go new file mode 100644 index 0000000..01e2811 --- /dev/null +++ b/search.go @@ -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 +} diff --git a/syi.go b/syi.go new file mode 100644 index 0000000..390c452 --- /dev/null +++ b/syi.go @@ -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 +} diff --git a/transport.go b/transport.go new file mode 100644 index 0000000..6ba1cec --- /dev/null +++ b/transport.go @@ -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) +} diff --git a/user.go b/user.go new file mode 100644 index 0000000..6281c4b --- /dev/null +++ b/user.go @@ -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 +}