Create mrktplaats-cli from twdehands-cli, updating module/import names, command branding, and token paths while preserving command behavior and payload formats.
1712 lines
49 KiB
Go
1712 lines
49 KiB
Go
// mrktplaats — command-line interface to the marktplaats.nl API
|
|
//
|
|
// Designed for use by both humans and LLM agents.
|
|
// All output is JSON on stdout. Errors go to stderr with exit code 1.
|
|
//
|
|
// Token resolution order:
|
|
// 1. MRKTPLAATS_TOKEN environment variable
|
|
// 2. ~/.config/mrktplaats/token (JSON: {accessToken, refreshToken, expiresIn})
|
|
// 3. ./.mrktplaats-token in the current directory
|
|
//
|
|
// After login the token is written to ~/.config/mrktplaats/token automatically.
|
|
// The token expires after ~12 hours; re-login when prompted.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/joren/mrktplaats"
|
|
)
|
|
|
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
func fatalf(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...)
|
|
os.Exit(1)
|
|
}
|
|
|
|
func out(v any) {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
if err := enc.Encode(v); err != nil {
|
|
fatalf("encoding output: %v", err)
|
|
}
|
|
}
|
|
|
|
func ctx() context.Context {
|
|
c, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
// cancel is intentionally not deferred here — the CLI calls one operation
|
|
// per invocation and exits immediately after, so the leak is benign.
|
|
_ = cancel
|
|
return c
|
|
}
|
|
|
|
// tokenPath returns the path to the persisted token file.
|
|
func tokenPath() string {
|
|
if cfg, err := os.UserConfigDir(); err == nil {
|
|
return filepath.Join(cfg, "mrktplaats", "token")
|
|
}
|
|
return filepath.Join(os.Getenv("HOME"), ".config", "mrktplaats", "token")
|
|
}
|
|
|
|
// savedToken is the JSON structure stored in the token file.
|
|
type savedToken struct {
|
|
AccessToken string `json:"accessToken"`
|
|
RefreshToken string `json:"refreshToken"`
|
|
ExpiresIn string `json:"expiresIn"`
|
|
}
|
|
|
|
// loadTokenData reads the token file and returns parsed token data.
|
|
// Supports both the legacy plain-text format (access token only) and the
|
|
// current JSON format.
|
|
func loadTokenData() *savedToken {
|
|
var data []byte
|
|
var err error
|
|
|
|
if t := os.Getenv("MRKTPLAATS_TOKEN"); t != "" {
|
|
return &savedToken{AccessToken: strings.TrimSpace(t)}
|
|
}
|
|
for _, p := range []string{tokenPath(), ".mrktplaats-token"} {
|
|
if data, err = os.ReadFile(p); err == nil {
|
|
break
|
|
}
|
|
}
|
|
if data == nil {
|
|
return nil
|
|
}
|
|
|
|
// Try JSON first (current format).
|
|
var tok savedToken
|
|
if err := json.Unmarshal(bytes.TrimSpace(data), &tok); err == nil && tok.AccessToken != "" {
|
|
return &tok
|
|
}
|
|
// Fall back to legacy plain-text format.
|
|
if t := strings.TrimSpace(string(data)); t != "" {
|
|
return &savedToken{AccessToken: t}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// saveToken persists the full token payload as JSON to the config file.
|
|
func saveToken(tok savedToken) error {
|
|
p := tokenPath()
|
|
if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil {
|
|
return err
|
|
}
|
|
b, err := json.Marshal(tok)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(p, append(b, '\n'), 0600)
|
|
}
|
|
|
|
// pendingPath returns the path used to persist a pending 2FA session between
|
|
// the two login calls (first call triggers SMS; second call submits the code).
|
|
func pendingPath() string {
|
|
return filepath.Join(filepath.Dir(tokenPath()), "pending-2fa")
|
|
}
|
|
|
|
// pending2FA stores the requestId AND session parameters so the second CLI
|
|
// invocation (with --code) uses the exact same session as the first.
|
|
// The API requires matching session/gaClientId/magic_number/threatmetrix
|
|
// between the login and verify-code requests.
|
|
type pending2FA struct {
|
|
RequestID string `json:"requestId"`
|
|
Session string `json:"session"`
|
|
GAClientID string `json:"gaClientId"`
|
|
MagicNumber string `json:"magicNumber"`
|
|
ThreatMetrixSessionID string `json:"threatMetrixSessionId"`
|
|
}
|
|
|
|
func savePending2FA(p pending2FA) error {
|
|
path := pendingPath()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
|
return err
|
|
}
|
|
b, err := json.Marshal(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, append(b, '\n'), 0600)
|
|
}
|
|
|
|
func loadPending2FA() *pending2FA {
|
|
data, err := os.ReadFile(pendingPath())
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
data = bytes.TrimSpace(data)
|
|
if len(data) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Try JSON format (current).
|
|
var p pending2FA
|
|
if err := json.Unmarshal(data, &p); err == nil && p.RequestID != "" {
|
|
return &p
|
|
}
|
|
|
|
// Fall back to legacy plain-text format (requestId only).
|
|
if id := strings.TrimSpace(string(data)); id != "" {
|
|
return &pending2FA{RequestID: id}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func clearPending2FA() {
|
|
_ = os.Remove(pendingPath())
|
|
}
|
|
|
|
// authedClient returns a client with a valid token, auto-refreshing if expired.
|
|
func authedClient() *mrktplaats.Client {
|
|
tok := loadTokenData()
|
|
if tok == nil || tok.AccessToken == "" {
|
|
fatalf("no token found — run 'mrktplaats login --email E --password P' first,\n" +
|
|
"or set MRKTPLAATS_TOKEN in the environment")
|
|
}
|
|
|
|
// Check expiry and auto-refresh if we have a refresh token.
|
|
if tok.ExpiresIn != "" {
|
|
if exp, err := time.Parse(time.RFC3339, tok.ExpiresIn); err == nil && time.Now().After(exp) {
|
|
if tok.RefreshToken == "" {
|
|
fatalf("access token expired at %s — please re-login:\n"+
|
|
" mrktplaats login --email EMAIL --password PASS",
|
|
exp.Local().Format("2006-01-02 15:04:05"))
|
|
}
|
|
// Attempt silent refresh.
|
|
tmpClient := mrktplaats.NewClient()
|
|
newTok, err := tmpClient.Auth.RefreshToken(ctx(), tok.RefreshToken)
|
|
if err != nil {
|
|
fatalf("token refresh failed (please re-login): %v", err)
|
|
}
|
|
updated := savedToken{
|
|
AccessToken: newTok.AccessToken,
|
|
RefreshToken: newTok.RefreshToken,
|
|
ExpiresIn: newTok.ExpiresIn,
|
|
}
|
|
if err := saveToken(updated); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: could not save refreshed token: %v\n", err)
|
|
}
|
|
tok = &updated
|
|
}
|
|
}
|
|
|
|
return mrktplaats.NewClient(mrktplaats.WithAccessToken(tok.AccessToken))
|
|
}
|
|
|
|
// eurosToC converts a euro string ("20", "20.50") to euro cents.
|
|
func eurosToC(s string) int {
|
|
f, err := strconv.ParseFloat(s, 64)
|
|
if err != nil {
|
|
fatalf("invalid amount %q: must be a number in euros (e.g. 20 or 20.50)", s)
|
|
}
|
|
return int(math.Round(f * 100))
|
|
}
|
|
|
|
// ── commands ──────────────────────────────────────────────────────────────────
|
|
|
|
// login — authenticate and save token
|
|
//
|
|
// mrktplaats login --email EMAIL --password PASS
|
|
func cmdLogin(args []string) {
|
|
fs := flag.NewFlagSet("login", flag.ExitOnError)
|
|
email := fs.String("email", "", "account e-mail address (required)")
|
|
pass := fs.String("password", "", "account password (required)")
|
|
code := fs.String("code", "", "SMS 2FA code (only needed when 2FA is triggered)")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats login --email EMAIL --password PASS [--code 2FA_CODE]
|
|
|
|
Authenticate with marktplaats.nl and save the access token.
|
|
|
|
If the account has 2FA enabled the call will return with a "verification_required"
|
|
status. Re-run the command with --code CODE after receiving the SMS.
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
|
|
if *email == "" || *pass == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := mrktplaats.NewClient()
|
|
|
|
// If the user provided --code, check whether we saved a pending 2FA session
|
|
// from the previous login call. If so, create the client with the SAME session
|
|
// parameters and go straight to VerifyCode — calling Login again would generate
|
|
// a new requestId, invalidating the SMS code the user just received.
|
|
if *code != "" {
|
|
pending := loadPending2FA()
|
|
if pending == nil || pending.RequestID == "" {
|
|
fatalf("no pending 2FA session — run login without --code first to trigger the SMS:\n" +
|
|
" mrktplaats login --email EMAIL --password PASS")
|
|
}
|
|
|
|
// Restore the session parameters from the login call so the API sees
|
|
// the same session/gaClientId/magic_number/threatmetrix as before.
|
|
var opts []mrktplaats.Option
|
|
if pending.Session != "" {
|
|
opts = append(opts, mrktplaats.WithSession(pending.Session))
|
|
}
|
|
if pending.GAClientID != "" {
|
|
opts = append(opts, mrktplaats.WithGAClientID(pending.GAClientID))
|
|
}
|
|
if pending.MagicNumber != "" {
|
|
opts = append(opts, mrktplaats.WithMagicNumber(pending.MagicNumber))
|
|
}
|
|
if pending.ThreatMetrixSessionID != "" {
|
|
opts = append(opts, mrktplaats.WithThreatMetrixSessionID(pending.ThreatMetrixSessionID))
|
|
}
|
|
client = mrktplaats.NewClient(opts...)
|
|
|
|
auth, err := client.Auth.VerifyCode(ctx(), pending.RequestID, *code)
|
|
if err != nil {
|
|
fatalf("2FA verification failed: %v", err)
|
|
}
|
|
clearPending2FA()
|
|
if err := saveToken(savedToken{AccessToken: auth.Auth.AccessToken, RefreshToken: auth.Auth.RefreshToken, ExpiresIn: auth.Auth.ExpiresIn}); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: could not save token: %v\n", err)
|
|
}
|
|
out(map[string]any{
|
|
"status": "ok",
|
|
"token_saved": tokenPath(),
|
|
"access_token": auth.Auth.AccessToken,
|
|
"expires_in": auth.Auth.ExpiresIn,
|
|
"user": auth.User,
|
|
})
|
|
return
|
|
}
|
|
|
|
login, err := client.Auth.Login(ctx(), *email, *pass)
|
|
if err != nil {
|
|
fatalf("login failed: %v", err)
|
|
}
|
|
|
|
// Direct auth — no 2FA
|
|
if login.Auth != nil {
|
|
clearPending2FA()
|
|
if err := saveToken(savedToken{AccessToken: login.Auth.AccessToken, RefreshToken: login.Auth.RefreshToken, ExpiresIn: login.Auth.ExpiresIn}); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: could not save token: %v\n", err)
|
|
}
|
|
out(map[string]any{
|
|
"status": "ok",
|
|
"token_saved": tokenPath(),
|
|
"access_token": login.Auth.AccessToken,
|
|
"expires_in": login.Auth.ExpiresIn,
|
|
})
|
|
return
|
|
}
|
|
|
|
// 2FA required
|
|
if login.Verification == nil {
|
|
fatalf("unexpected login response: no auth and no verification")
|
|
}
|
|
|
|
// Persist the requestId AND session parameters so the next invocation
|
|
// (with --code) can recreate the client with identical session values.
|
|
sp := client.SessionParams()
|
|
if err := savePending2FA(pending2FA{
|
|
RequestID: login.Verification.RequestID,
|
|
Session: sp.Session,
|
|
GAClientID: sp.GAClientID,
|
|
MagicNumber: sp.MagicNumber,
|
|
ThreatMetrixSessionID: sp.ThreatMetrixSessionID,
|
|
}); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: could not save pending 2FA state: %v\n", err)
|
|
}
|
|
|
|
out(map[string]any{
|
|
"status": "verification_required",
|
|
"method": login.Verification.Method,
|
|
"message": login.Verification.Message,
|
|
"next_step": "re-run with --code CODE after receiving the SMS",
|
|
})
|
|
}
|
|
|
|
// search — search listings
|
|
//
|
|
// mrktplaats search --query Q [--category ID] [--page N] [--size N] [--sort FIELD]
|
|
func cmdSearch(args []string) {
|
|
fs := flag.NewFlagSet("search", flag.ExitOnError)
|
|
query := fs.String("query", "", "search query (required)")
|
|
category := fs.Int("category", 0, "category ID filter (0 = all categories)")
|
|
page := fs.Int("page", 1, "page number (1-based)")
|
|
size := fs.Int("size", 20, "results per page (max 100)")
|
|
sort := fs.String("sort", "SORT_INDEX", "sort order: SORT_INDEX | DATE_DESC | PRICE_ASC | PRICE_DESC")
|
|
sellerID := fs.String("seller", "", "filter by seller ID")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats search --query Q [options]
|
|
|
|
Search listings. Does not require authentication.
|
|
|
|
Output: {"total": N, "listings": [{urn, title, price_cents, price_label, city, seller_name, url}]}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
|
|
if *query == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := mrktplaats.NewClient()
|
|
req := &mrktplaats.SearchRequest{
|
|
Query: *query,
|
|
Page: *page,
|
|
Size: *size,
|
|
SortBy: *sort,
|
|
AllowCorrection: true,
|
|
SearchOnTitleAndDescription: true,
|
|
ShowListings: true,
|
|
SupportsReservedFlag: true,
|
|
}
|
|
if *category != 0 {
|
|
req.CategoryID = category
|
|
}
|
|
if *sellerID != "" {
|
|
req.SellerID = *sellerID
|
|
}
|
|
|
|
resp, err := client.Search.Fetch(ctx(), req, nil)
|
|
if err != nil {
|
|
fatalf("search failed: %v", err)
|
|
}
|
|
|
|
type listingResult struct {
|
|
URN string `json:"urn"`
|
|
Title string `json:"title"`
|
|
PriceCents int `json:"price_cents"`
|
|
PriceLabel string `json:"price_label"`
|
|
PriceType string `json:"price_type"`
|
|
City string `json:"city"`
|
|
SellerName string `json:"seller_name"`
|
|
URL string `json:"url"`
|
|
}
|
|
seen := map[string]bool{}
|
|
var listings []listingResult
|
|
for _, item := range resp.Items {
|
|
l := item.Listing()
|
|
if l == nil || seen[l.AdCore.URN] {
|
|
continue
|
|
}
|
|
seen[l.AdCore.URN] = true
|
|
listings = append(listings, listingResult{
|
|
URN: l.AdCore.URN,
|
|
Title: l.AdCore.Title,
|
|
PriceCents: l.AdCore.Price.PriceAmount,
|
|
PriceLabel: l.AdCore.Price.PriceTypeLabel,
|
|
PriceType: l.AdCore.Price.PriceType,
|
|
City: l.AdCore.AdAddress.City,
|
|
SellerName: l.SellerInformation.Name,
|
|
URL: l.URL,
|
|
})
|
|
}
|
|
|
|
total := 0
|
|
if resp.SearchHistograms != nil {
|
|
total = resp.SearchHistograms.NumFound
|
|
}
|
|
out(map[string]any{
|
|
"total": total,
|
|
"page": *page,
|
|
"size": *size,
|
|
"listings": listings,
|
|
})
|
|
}
|
|
|
|
// listing — get full listing details
|
|
//
|
|
// mrktplaats listing --urn URN
|
|
func cmdListing(args []string) {
|
|
fs := flag.NewFlagSet("listing", flag.ExitOnError)
|
|
urn := fs.String("urn", "", "listing URN, e.g. m2372861012 (required)")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats listing --urn URN
|
|
|
|
Fetch full details for a listing: title, description, price, seller, pictures,
|
|
attributes, bids. Requires authentication (some fields are auth-only).
|
|
|
|
Output: full ListingDetail object
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *urn == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := authedClient()
|
|
detail, err := client.Listings.Get(ctx(), *urn)
|
|
if err != nil {
|
|
fatalf("listing %s: %v", *urn, err)
|
|
}
|
|
out(detail)
|
|
}
|
|
|
|
// conversations — list all conversations
|
|
//
|
|
// mrktplaats conversations [--limit N]
|
|
func cmdConversations(args []string) {
|
|
fs := flag.NewFlagSet("conversations", flag.ExitOnError)
|
|
limit := fs.Int("limit", 50, "max conversations to return")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats conversations [--limit N]
|
|
|
|
List all messaging conversations for the authenticated user.
|
|
|
|
Output: {"total": N, "unread": N, "conversations": [{id, item_urn, title, other_party, unread, latest_message}]}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
|
|
client := authedClient()
|
|
resp, err := client.Messaging.Conversations(ctx(), &mrktplaats.ConversationsOptions{Limit: *limit})
|
|
if err != nil {
|
|
fatalf("conversations: %v", err)
|
|
}
|
|
|
|
type convResult struct {
|
|
ID string `json:"id"`
|
|
ItemURN string `json:"item_urn"`
|
|
Title string `json:"title"`
|
|
OtherParty string `json:"other_party"`
|
|
OtherPartyID int `json:"other_party_id"`
|
|
Unread int `json:"unread"`
|
|
LatestMessage string `json:"latest_message"`
|
|
}
|
|
var convs []convResult
|
|
for _, c := range resp.Conversations {
|
|
latest := ""
|
|
if lm := c.LatestMessage(); lm != nil {
|
|
latest = lm.Text
|
|
}
|
|
convs = append(convs, convResult{
|
|
ID: c.ID,
|
|
ItemURN: c.ItemID,
|
|
Title: c.Title,
|
|
OtherParty: c.OtherParticipant.Name,
|
|
OtherPartyID: c.OtherParticipant.ID,
|
|
Unread: c.UnreadMessagesCount,
|
|
LatestMessage: latest,
|
|
})
|
|
}
|
|
out(map[string]any{
|
|
"total": resp.TotalCount,
|
|
"unread": resp.UnreadMessagesCount,
|
|
"conversations": convs,
|
|
})
|
|
}
|
|
|
|
// messages — read messages in a conversation
|
|
//
|
|
// mrktplaats messages --conv CONV_ID [--limit N] [--offset N]
|
|
func cmdMessages(args []string) {
|
|
fs := flag.NewFlagSet("messages", flag.ExitOnError)
|
|
convID := fs.String("conv", "", "conversation ID, e.g. 1cgx:5qgvx3h:2p1gxt95d (required)")
|
|
limit := fs.Int("limit", 50, "max messages to return")
|
|
offset := fs.Int("offset", 0, "skip first N messages")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats messages --conv CONV_ID [--limit N] [--offset N]
|
|
|
|
Read messages in a conversation. Use 'mrktplaats conversations' to find conversation IDs.
|
|
|
|
Output: {"total": N, "messages": [{id, sender_id, text, date, type, read}]}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *convID == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := authedClient()
|
|
resp, err := client.Messaging.Messages(ctx(), *convID, *offset, *limit)
|
|
if err != nil {
|
|
fatalf("messages: %v", err)
|
|
}
|
|
|
|
type msgResult struct {
|
|
ID string `json:"id"`
|
|
SenderID int `json:"sender_id"`
|
|
Text string `json:"text"`
|
|
Date time.Time `json:"date"`
|
|
Type string `json:"type"`
|
|
Read bool `json:"read"`
|
|
}
|
|
var msgs []msgResult
|
|
for _, m := range resp.Messages {
|
|
msgs = append(msgs, msgResult{
|
|
ID: m.ID,
|
|
SenderID: m.SenderID,
|
|
Text: m.Text,
|
|
Date: m.ReceivedDate,
|
|
Type: m.MessageType,
|
|
Read: m.IsRead,
|
|
})
|
|
}
|
|
out(map[string]any{
|
|
"total": resp.TotalCount,
|
|
"messages": msgs,
|
|
})
|
|
}
|
|
|
|
// send — start a new conversation about a listing
|
|
//
|
|
// mrktplaats send --urn URN --text TEXT
|
|
func cmdSend(args []string) {
|
|
fs := flag.NewFlagSet("send", flag.ExitOnError)
|
|
urn := fs.String("urn", "", "listing URN to message the seller about (required)")
|
|
text := fs.String("text", "", "message text (required)")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats send --urn URN --text TEXT
|
|
|
|
Start a new conversation with the seller of a listing.
|
|
To reply to an existing conversation use 'mrktplaats reply'.
|
|
|
|
Output: {"conversation_id": "...", "status": "sent"}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *urn == "" || *text == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := authedClient()
|
|
|
|
// Fetch the listing to get the seller ID required by the enquiry endpoint.
|
|
detail, err := client.Listings.Get(ctx(), *urn)
|
|
if err != nil {
|
|
fatalf("send: fetching listing %s: %v", *urn, err)
|
|
}
|
|
sellerID := fmt.Sprintf("%d", detail.SellerInformation.ID)
|
|
|
|
// Use the ASQ (Ask Seller a Question) enquiry endpoint.
|
|
// This works for both first contact and repeat messages — the API always returns 204.
|
|
if err := client.Enquiry.AskQuestion(ctx(), *urn, sellerID, *text); err != nil {
|
|
fatalf("send: %v", err)
|
|
}
|
|
out(map[string]any{
|
|
"status": "sent",
|
|
"item_urn": *urn,
|
|
"seller_id": sellerID,
|
|
"seller_name": detail.SellerInformation.Name,
|
|
})
|
|
}
|
|
|
|
// reply — send a message in an existing conversation
|
|
//
|
|
// mrktplaats reply --conv CONV_ID --text TEXT
|
|
func cmdReply(args []string) {
|
|
fs := flag.NewFlagSet("reply", flag.ExitOnError)
|
|
convID := fs.String("conv", "", "conversation ID (required)")
|
|
text := fs.String("text", "", "message text (required)")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats reply --conv CONV_ID --text TEXT
|
|
|
|
Send a message in an existing conversation.
|
|
Use 'mrktplaats conversations' to find conversation IDs.
|
|
|
|
Output: {"message_id": "...", "conversation_id": "...", "status": "sent"}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *convID == "" || *text == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := authedClient()
|
|
msgID, err := client.Messaging.SendMessage(ctx(), *convID, *text)
|
|
if err != nil {
|
|
fatalf("reply: %v", err)
|
|
}
|
|
out(map[string]any{
|
|
"message_id": msgID,
|
|
"conversation_id": *convID,
|
|
"status": "sent",
|
|
})
|
|
}
|
|
|
|
// bid — place a bid on a listing
|
|
//
|
|
// mrktplaats bid --urn URN --amount EUROS [--message TEXT]
|
|
func cmdBid(args []string) {
|
|
fs := flag.NewFlagSet("bid", flag.ExitOnError)
|
|
urn := fs.String("urn", "", "listing URN (required)")
|
|
amount := fs.String("amount", "", "bid amount in euros, e.g. 20 or 20.50 (required)")
|
|
message := fs.String("message", "", "optional personal message to the seller")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats bid --urn URN --amount EUROS [--message TEXT]
|
|
|
|
Place a bid on a listing. The listing must have priceType MIN_BID or FAST_BID.
|
|
Amount is in euros (e.g. --amount 20 for €20.00).
|
|
|
|
Output: {"bids": [...], "current_minimum_bid_cents": N, "current_minimum_bid_euros": N}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *urn == "" || *amount == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
cents := eurosToC(*amount)
|
|
client := authedClient()
|
|
resp, err := client.Enquiry.PlaceBid(ctx(), *urn, &mrktplaats.PlaceBidRequest{
|
|
Value: cents,
|
|
PersonalMessage: *message,
|
|
})
|
|
if err != nil {
|
|
fatalf("bid: %v", err)
|
|
}
|
|
out(map[string]any{
|
|
"bids": resp.Bids,
|
|
"current_minimum_bid_cents": resp.CurrentMinimumBid,
|
|
"current_minimum_bid_euros": float64(resp.CurrentMinimumBid) / 100,
|
|
})
|
|
}
|
|
|
|
// remove-bid — remove an existing bid
|
|
//
|
|
// mrktplaats remove-bid --id BID_ID
|
|
func cmdRemoveBid(args []string) {
|
|
fs := flag.NewFlagSet("remove-bid", flag.ExitOnError)
|
|
id := fs.Int("id", 0, "bid ID to remove (required; find it in the 'bids' array from 'bid' or 'listing')")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats remove-bid --id BID_ID
|
|
|
|
Remove a bid you previously placed.
|
|
|
|
Output: {"bids": [...], "current_minimum_bid_euros": N}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *id == 0 {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := authedClient()
|
|
resp, err := client.Enquiry.RemoveBid(ctx(), *id)
|
|
if err != nil {
|
|
fatalf("remove-bid: %v", err)
|
|
}
|
|
out(map[string]any{
|
|
"bids": resp.Bids,
|
|
"current_minimum_bid_cents": resp.CurrentMinimumBid,
|
|
"current_minimum_bid_euros": float64(resp.CurrentMinimumBid) / 100,
|
|
})
|
|
}
|
|
|
|
// favorites — list saved listings
|
|
//
|
|
// mrktplaats favorites [--limit N]
|
|
func cmdFavorites(args []string) {
|
|
fs := flag.NewFlagSet("favorites", flag.ExitOnError)
|
|
limit := fs.Int("limit", 100, "max favorites to return")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats favorites [--limit N]
|
|
|
|
List the authenticated user's saved/favourite listings.
|
|
|
|
Output: {"total": N, "items": [{item_urn, saved_at}]}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
|
|
client := authedClient()
|
|
resp, err := client.Favorites.List(ctx(), *limit)
|
|
if err != nil {
|
|
fatalf("favorites: %v", err)
|
|
}
|
|
type favResult struct {
|
|
ItemURN string `json:"item_urn"`
|
|
SavedAt string `json:"saved_at"`
|
|
}
|
|
var items []favResult
|
|
for _, f := range resp.Items {
|
|
items = append(items, favResult{ItemURN: f.ItemID, SavedAt: f.CreationDate})
|
|
}
|
|
out(map[string]any{
|
|
"total": resp.Total,
|
|
"more": resp.MoreItemsAvailable,
|
|
"items": items,
|
|
})
|
|
}
|
|
|
|
// add-favorite — save a listing to favourites
|
|
//
|
|
// mrktplaats add-favorite --urn URN
|
|
func cmdAddFavorite(args []string) {
|
|
fs := flag.NewFlagSet("add-favorite", flag.ExitOnError)
|
|
urn := fs.String("urn", "", "listing URN (required)")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats add-favorite --urn URN
|
|
|
|
Add a listing to the authenticated user's favourites.
|
|
|
|
Output: {"status": "added", "item_urn": "..."}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *urn == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := authedClient()
|
|
if _, err := client.Favorites.Add(ctx(), *urn); err != nil {
|
|
fatalf("add-favorite: %v", err)
|
|
}
|
|
out(map[string]any{"status": "added", "item_urn": *urn})
|
|
}
|
|
|
|
// remove-favorite — remove a listing from favourites
|
|
//
|
|
// mrktplaats remove-favorite --urn URN
|
|
func cmdRemoveFavorite(args []string) {
|
|
fs := flag.NewFlagSet("remove-favorite", flag.ExitOnError)
|
|
urn := fs.String("urn", "", "listing URN (required)")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats remove-favorite --urn URN
|
|
|
|
Remove a listing from the authenticated user's favourites.
|
|
|
|
Output: {"status": "removed", "item_urn": "..."}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *urn == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := authedClient()
|
|
if err := client.Favorites.Remove(ctx(), *urn); err != nil {
|
|
fatalf("remove-favorite: %v", err)
|
|
}
|
|
out(map[string]any{"status": "removed", "item_urn": *urn})
|
|
}
|
|
|
|
// me — show the authenticated user's profile
|
|
//
|
|
// mrktplaats me
|
|
func cmdMe(args []string) {
|
|
_ = args // no flags
|
|
client := authedClient()
|
|
user, err := client.Users.Me(ctx())
|
|
if err != nil {
|
|
fatalf("me: %v", err)
|
|
}
|
|
out(user)
|
|
}
|
|
|
|
// my-ads — list the authenticated user's own listings
|
|
//
|
|
// mrktplaats my-ads [--status active|inactive|sold] [--limit N]
|
|
func cmdMyAds(args []string) {
|
|
fs := flag.NewFlagSet("my-ads", flag.ExitOnError)
|
|
status := fs.String("status", "active", "filter by status: active | inactive | sold")
|
|
limit := fs.Int("limit", 50, "max listings to return")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats my-ads [--status STATUS] [--limit N]
|
|
|
|
List the authenticated user's own listings.
|
|
|
|
Output: {"total": N, "ads": [{item_id, title, status, price_cents, price_euros}]}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
|
|
client := authedClient()
|
|
resp, err := client.MyAccount.MyAds(ctx(), &mrktplaats.MyAdsOptions{
|
|
Status: *status,
|
|
Limit: *limit,
|
|
Counts: true,
|
|
})
|
|
if err != nil {
|
|
fatalf("my-ads: %v", err)
|
|
}
|
|
|
|
type adResult struct {
|
|
ItemID string `json:"item_id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
PriceCents int `json:"price_cents"`
|
|
PriceEuros float64 `json:"price_euros"`
|
|
}
|
|
var ads []adResult
|
|
for _, a := range resp.MyAds {
|
|
ads = append(ads, adResult{
|
|
ItemID: a.ItemID,
|
|
Title: a.Title,
|
|
Status: a.Status,
|
|
PriceCents: a.PriceInCents,
|
|
PriceEuros: float64(a.PriceInCents) / 100,
|
|
})
|
|
}
|
|
out(map[string]any{
|
|
"total": resp.MyAdsTotalCount,
|
|
"ads": ads,
|
|
})
|
|
}
|
|
|
|
// delete-ad — delete one of the authenticated user's listings
|
|
//
|
|
// mrktplaats delete-ad --urn URN [--reason 1|2|3|4]
|
|
func cmdDeleteAd(args []string) {
|
|
fs := flag.NewFlagSet("delete-ad", flag.ExitOnError)
|
|
urn := fs.String("urn", "", "listing URN to delete (required)")
|
|
reason := fs.Int("reason", 4, "delete reason: 1=sold on marktplaats, 2=sold elsewhere, 3=no longer selling, 4=other")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats delete-ad --urn URN [--reason N]
|
|
|
|
Delete one of the authenticated user's own listings.
|
|
|
|
Reasons:
|
|
1 — sold on marktplaats.nl
|
|
2 — sold elsewhere
|
|
3 — no longer selling
|
|
4 — other (default)
|
|
|
|
Output: {"status": "deleted", "item_urn": "..."}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *urn == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := authedClient()
|
|
if err := client.MyAccount.DeleteAds(ctx(), []string{*urn}, mrktplaats.DeleteAdReason(*reason)); err != nil {
|
|
fatalf("delete-ad: %v", err)
|
|
}
|
|
out(map[string]any{"status": "deleted", "item_urn": *urn})
|
|
}
|
|
|
|
// seller-listings — all listings by a seller
|
|
//
|
|
// mrktplaats seller-listings --seller SELLER_ID [--size N] [--page N]
|
|
// mrktplaats seller-listings --urn URN [--size N] [--page N]
|
|
func cmdSellerListings(args []string) {
|
|
fs := flag.NewFlagSet("seller-listings", flag.ExitOnError)
|
|
sellerID := fs.String("seller", "", "numeric seller ID (e.g. 57506580)")
|
|
urn := fs.String("urn", "", "any listing URN from this seller — seller ID is resolved automatically")
|
|
size := fs.Int("size", 50, "max results per page")
|
|
page := fs.Int("page", 1, "page number")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats seller-listings --seller ID [--size N] [--page N]
|
|
mrktplaats seller-listings --urn URN [--size N] [--page N]
|
|
|
|
List all active listings by a seller. Provide either the seller's numeric ID
|
|
directly (--seller) or any listing URN belonging to that seller (--urn) and the
|
|
seller ID will be resolved automatically from the listing details.
|
|
|
|
Output: {"seller_id": "...", "seller_name": "...", "total": N, "listings": [...]}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
|
|
if *sellerID == "" && *urn == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := authedClient()
|
|
resolvedName := ""
|
|
|
|
if *sellerID == "" {
|
|
detail, err := client.Listings.Get(ctx(), *urn)
|
|
if err != nil {
|
|
fatalf("seller-listings: fetching listing %s: %v", *urn, err)
|
|
}
|
|
*sellerID = fmt.Sprintf("%d", detail.SellerInformation.ID)
|
|
resolvedName = detail.SellerInformation.Name
|
|
}
|
|
|
|
resp, err := client.Search.Fetch(ctx(), &mrktplaats.SearchRequest{
|
|
Query: "",
|
|
SellerID: *sellerID,
|
|
Page: *page,
|
|
Size: *size,
|
|
SortBy: "DATE_DESC",
|
|
ShowListings: true,
|
|
SupportsReservedFlag: true,
|
|
SearchOnTitleAndDescription: true,
|
|
}, nil)
|
|
if err != nil {
|
|
fatalf("seller-listings: %v", err)
|
|
}
|
|
|
|
type listingResult struct {
|
|
URN string `json:"urn"`
|
|
Title string `json:"title"`
|
|
PriceCents int `json:"price_cents"`
|
|
PriceLabel string `json:"price_label"`
|
|
PriceType string `json:"price_type"`
|
|
City string `json:"city"`
|
|
}
|
|
seen := map[string]bool{}
|
|
var listings []listingResult
|
|
for _, item := range resp.Items {
|
|
l := item.Listing()
|
|
if l == nil || seen[l.AdCore.URN] {
|
|
continue
|
|
}
|
|
seen[l.AdCore.URN] = true
|
|
if resolvedName == "" {
|
|
resolvedName = l.SellerInformation.Name
|
|
}
|
|
listings = append(listings, listingResult{
|
|
URN: l.AdCore.URN,
|
|
Title: l.AdCore.Title,
|
|
PriceCents: l.AdCore.Price.PriceAmount,
|
|
PriceLabel: l.AdCore.Price.PriceTypeLabel,
|
|
PriceType: l.AdCore.Price.PriceType,
|
|
City: l.AdCore.AdAddress.City,
|
|
})
|
|
}
|
|
|
|
total := 0
|
|
if resp.SearchHistograms != nil {
|
|
total = resp.SearchHistograms.NumFound
|
|
}
|
|
out(map[string]any{
|
|
"seller_id": *sellerID,
|
|
"seller_name": resolvedName,
|
|
"total": total,
|
|
"page": *page,
|
|
"listings": listings,
|
|
})
|
|
}
|
|
|
|
// reviews — view reviews for a seller
|
|
//
|
|
// mrktplaats reviews --seller SELLER_ID
|
|
// mrktplaats reviews --urn URN
|
|
func cmdReviews(args []string) {
|
|
fs := flag.NewFlagSet("reviews", flag.ExitOnError)
|
|
sellerID := fs.Int("seller", 0, "numeric seller ID (e.g. 57506580)")
|
|
urn := fs.String("urn", "", "any listing URN — seller ID is resolved automatically")
|
|
role := fs.String("role", "reviewee", "filter: reviewee (received) or all")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats reviews --seller ID [--role reviewee|all]
|
|
mrktplaats reviews --urn URN [--role reviewee|all]
|
|
|
|
View reviews for a seller. Provide either the seller's numeric ID directly
|
|
(--seller) or any listing URN belonging to that seller (--urn) and the seller
|
|
ID will be resolved automatically from the listing details.
|
|
|
|
Output: {"seller_id": N, "summary": {average_score, count}, "reviews": [...]}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
|
|
if *sellerID == 0 && *urn == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := authedClient()
|
|
|
|
if *sellerID == 0 {
|
|
detail, err := client.Listings.Get(ctx(), *urn)
|
|
if err != nil {
|
|
fatalf("reviews: fetching listing %s: %v", *urn, err)
|
|
}
|
|
*sellerID = detail.SellerInformation.ID
|
|
}
|
|
|
|
var reviewRole mrktplaats.ReviewRole
|
|
switch *role {
|
|
case "reviewee":
|
|
reviewRole = mrktplaats.ReviewRoleReviewee
|
|
case "all", "":
|
|
reviewRole = mrktplaats.ReviewRoleAll
|
|
default:
|
|
fatalf("invalid --role %q: must be 'reviewee' or 'all'", *role)
|
|
}
|
|
|
|
resp, err := client.Users.Reviews(ctx(), *sellerID, reviewRole)
|
|
if err != nil {
|
|
fatalf("reviews: %v", err)
|
|
}
|
|
|
|
type reviewResult struct {
|
|
ID int `json:"id"`
|
|
Score int `json:"score"`
|
|
ReviewerName string `json:"reviewer_name"`
|
|
ReviewerID int `json:"reviewer_id"`
|
|
Subject string `json:"subject"`
|
|
Direction string `json:"direction"`
|
|
ItemTitle string `json:"item_title"`
|
|
ItemID string `json:"item_id"`
|
|
Date string `json:"date"`
|
|
Details []detailResult `json:"details,omitempty"`
|
|
}
|
|
type summaryResult struct {
|
|
AverageScore float64 `json:"average_score"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
var reviews []reviewResult
|
|
for _, r := range resp.Reviews {
|
|
var details []detailResult
|
|
for _, d := range r.Details {
|
|
var chars []string
|
|
for _, c := range d.Characteristics {
|
|
prefix := "+"
|
|
if !c.IsPositive {
|
|
prefix = "-"
|
|
}
|
|
chars = append(chars, prefix+c.Text)
|
|
}
|
|
details = append(details, detailResult{
|
|
Category: d.Category,
|
|
Score: d.Score,
|
|
Traits: chars,
|
|
})
|
|
}
|
|
reviews = append(reviews, reviewResult{
|
|
ID: r.ID,
|
|
Score: r.Score,
|
|
ReviewerName: r.Reviewer.Nickname,
|
|
ReviewerID: r.Reviewer.ID,
|
|
Subject: r.Content.Subject,
|
|
Direction: r.Direction,
|
|
ItemTitle: r.Advertisement.Title,
|
|
ItemID: r.Advertisement.ID,
|
|
Date: r.CreationDate.Time.Format("2006-01-02"),
|
|
Details: details,
|
|
})
|
|
}
|
|
|
|
out(map[string]any{
|
|
"seller_id": *sellerID,
|
|
"summary": summaryResult{
|
|
AverageScore: resp.Summary.AverageScore,
|
|
Count: resp.Summary.NumberOfReviews,
|
|
},
|
|
"reviews": reviews,
|
|
})
|
|
}
|
|
|
|
type detailResult struct {
|
|
Category string `json:"category"`
|
|
Score int `json:"score"`
|
|
Traits []string `json:"traits,omitempty"`
|
|
}
|
|
|
|
// categories — list full category tree
|
|
//
|
|
// mrktplaats categories
|
|
func cmdCategories(args []string) {
|
|
fs := flag.NewFlagSet("categories", flag.ExitOnError)
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats categories
|
|
|
|
List the full category tree. Does not require authentication.
|
|
|
|
Output: {"categories": [{category_id, name, children: [...], place_ad_allowed}]}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
|
|
client := mrktplaats.NewClient()
|
|
resp, err := client.Categories.All(ctx())
|
|
if err != nil {
|
|
fatalf("categories: %v", err)
|
|
}
|
|
out(resp)
|
|
}
|
|
|
|
// similar — find similar/related listings
|
|
//
|
|
// mrktplaats similar --urn URN [--seller SELLER_ID] [--category CATEGORY_ID]
|
|
func cmdSimilar(args []string) {
|
|
fs := flag.NewFlagSet("similar", flag.ExitOnError)
|
|
urn := fs.String("urn", "", "listing URN (required)")
|
|
sellerID := fs.String("seller", "", "seller ID (resolved from listing if omitted)")
|
|
categoryID := fs.Int("category", 0, "category ID (resolved from listing if omitted)")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats similar --urn URN [--seller ID] [--category ID]
|
|
|
|
Find similar/related listings for a given listing. If --seller and --category
|
|
are omitted they are resolved automatically from the listing details.
|
|
|
|
Output: {"items": [{id, title, price, page_location}], "result_type": "..."}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *urn == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := authedClient()
|
|
|
|
// Resolve seller and category from the listing if not provided.
|
|
if *sellerID == "" || *categoryID == 0 {
|
|
detail, err := client.Listings.Get(ctx(), *urn)
|
|
if err != nil {
|
|
fatalf("similar: fetching listing %s: %v", *urn, err)
|
|
}
|
|
if *sellerID == "" {
|
|
*sellerID = fmt.Sprintf("%d", detail.SellerInformation.ID)
|
|
}
|
|
if *categoryID == 0 {
|
|
*categoryID = detail.AdCore.CategoryID
|
|
}
|
|
}
|
|
|
|
// The Relevant.Get API uses the numeric listing ID, not the full URN.
|
|
// Strip the "m" prefix if present (e.g. "m2372861012" -> "2372861012").
|
|
listingID := strings.TrimPrefix(*urn, "m")
|
|
|
|
resp, err := client.Relevant.Get(ctx(), listingID, *sellerID, *categoryID)
|
|
if err != nil {
|
|
fatalf("similar: %v", err)
|
|
}
|
|
out(resp)
|
|
}
|
|
|
|
// suggest — search autocomplete suggestions
|
|
//
|
|
// mrktplaats suggest --prefix TEXT [--category ID]
|
|
func cmdSuggest(args []string) {
|
|
fs := flag.NewFlagSet("suggest", flag.ExitOnError)
|
|
prefix := fs.String("prefix", "", "search prefix (required)")
|
|
category := fs.Int("category", 0, "category ID (0 = all)")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats suggest --prefix TEXT [--category ID]
|
|
|
|
Get search autocomplete suggestions. Does not require authentication.
|
|
|
|
Output: {"keyword": "...", "category": N, "suggestions": ["...", ...]}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *prefix == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := mrktplaats.NewClient()
|
|
resp, err := client.Search.KeywordSuggestions(ctx(), *prefix, *category)
|
|
if err != nil {
|
|
fatalf("suggest: %v", err)
|
|
}
|
|
out(resp)
|
|
}
|
|
|
|
// notifications — unread notification count
|
|
//
|
|
// mrktplaats notifications
|
|
func cmdNotifications(args []string) {
|
|
fs := flag.NewFlagSet("notifications", flag.ExitOnError)
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats notifications
|
|
|
|
Show unread notification count. Requires authentication.
|
|
|
|
Output: {"unread": N}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
|
|
client := authedClient()
|
|
resp, err := client.Notifications.UnreadCount(ctx())
|
|
if err != nil {
|
|
fatalf("notifications: %v", err)
|
|
}
|
|
out(map[string]any{
|
|
"unread": resp.UnreadNotificationsCount,
|
|
})
|
|
}
|
|
|
|
// create-ad — publish a new listing
|
|
//
|
|
// mrktplaats create-ad --category ID --title T --description D --price EUROS [options]
|
|
func cmdCreateAd(args []string) {
|
|
fs := flag.NewFlagSet("create-ad", flag.ExitOnError)
|
|
categoryID := fs.Int("category", 0, "category ID (required — use 'syi-form' to find)")
|
|
title := fs.String("title", "", "listing title (required)")
|
|
description := fs.String("description", "", "listing description (required)")
|
|
price := fs.String("price", "", "price in euros, e.g. 25 or 25.50 (required)")
|
|
priceType := fs.String("price-type", "FIXED", "price type: FIXED | NEGOTIABLE | MIN_BID | FAST_BID | FREE | SEE_DESCRIPTION")
|
|
delivery := fs.String("delivery", "PICKUP", "delivery method: PICKUP | SHIP | PICKUP_AND_SHIP")
|
|
sellerName := fs.String("seller-name", "", "seller display name (required)")
|
|
postcode := fs.String("postcode", "", "postcode (required)")
|
|
pictureIDs := fs.String("pictures", "", "comma-separated picture IDs from 'upload-image'")
|
|
bidding := fs.Bool("bidding", false, "enable bidding")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats create-ad --category ID --title T --description D --price EUROS [options]
|
|
|
|
Publish a new listing. Requires authentication.
|
|
|
|
Use 'mrktplaats syi-form --category ID' to see required attributes for a category.
|
|
Use 'mrktplaats upload-image --file PATH' to upload images first.
|
|
|
|
Output: full CreateAdResponse with URN of the new listing
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *categoryID == 0 || *title == "" || *description == "" || *price == "" || *sellerName == "" || *postcode == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
var picIDs []string
|
|
if *pictureIDs != "" {
|
|
picIDs = strings.Split(*pictureIDs, ",")
|
|
}
|
|
|
|
req := &mrktplaats.CreateAdRequest{
|
|
CategoryID: *categoryID,
|
|
Translations: []mrktplaats.Translation{{
|
|
Locale: "nl",
|
|
Title: *title,
|
|
Description: *description,
|
|
}},
|
|
PriceInCents: eurosToC(*price),
|
|
PriceType: *priceType,
|
|
BiddingEnabled: *bidding,
|
|
DeliveryMethod: *delivery,
|
|
SellerName: *sellerName,
|
|
Postcode: *postcode,
|
|
PictureIDs: picIDs,
|
|
Attributes: []any{},
|
|
FeatureTypes: []any{},
|
|
ShippingConfig: mrktplaats.ShippingConfig{Carriers: []string{}},
|
|
SelectedBundle: "FREE",
|
|
}
|
|
|
|
client := authedClient()
|
|
resp, err := client.SYI.Create(ctx(), req)
|
|
if err != nil {
|
|
fatalf("create-ad: %v", err)
|
|
}
|
|
out(map[string]any{
|
|
"status": "created",
|
|
"urn": resp.URN(),
|
|
"ad": resp.Ad,
|
|
})
|
|
}
|
|
|
|
// upload-image — upload an image for a listing
|
|
//
|
|
// mrktplaats upload-image --file PATH
|
|
func cmdUploadImage(args []string) {
|
|
fs := flag.NewFlagSet("upload-image", flag.ExitOnError)
|
|
filePath := fs.String("file", "", "path to image file (required)")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats upload-image --file PATH
|
|
|
|
Upload an image for use in a listing. Returns a picture ID to pass to 'create-ad --pictures'.
|
|
Requires authentication. Large images are automatically compressed.
|
|
|
|
Output: {"picture_id": "..."}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *filePath == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
f, err := os.Open(*filePath)
|
|
if err != nil {
|
|
fatalf("upload-image: opening file: %v", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
filename := filepath.Base(*filePath)
|
|
client := authedClient()
|
|
picID, err := client.SYI.UploadImage(ctx(), f, filename)
|
|
if err != nil {
|
|
fatalf("upload-image: %v", err)
|
|
}
|
|
out(map[string]any{
|
|
"picture_id": picID,
|
|
})
|
|
}
|
|
|
|
// syi-form — show required attributes for a category
|
|
//
|
|
// mrktplaats syi-form --category ID
|
|
func cmdSYIForm(args []string) {
|
|
fs := flag.NewFlagSet("syi-form", flag.ExitOnError)
|
|
categoryID := fs.Int("category", 0, "category ID (required)")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats syi-form --category ID
|
|
|
|
Show the listing creation form (required/optional attributes) for a category.
|
|
Requires authentication.
|
|
|
|
Output: {"category_id": N, "attributes": [{id, key, label, type, mandatory, values: [...]}]}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *categoryID == 0 {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := authedClient()
|
|
resp, err := client.SYI.Form(ctx(), *categoryID)
|
|
if err != nil {
|
|
fatalf("syi-form: %v", err)
|
|
}
|
|
|
|
type attrResult struct {
|
|
ID string `json:"id"`
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
Type string `json:"type"`
|
|
Mandatory bool `json:"mandatory"`
|
|
Values []struct {
|
|
ID string `json:"id"`
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
} `json:"values,omitempty"`
|
|
}
|
|
var attrs []attrResult
|
|
for _, a := range resp.SYIAttributes {
|
|
ar := attrResult{
|
|
ID: a.ID,
|
|
Key: a.Key,
|
|
Label: a.Label,
|
|
Type: a.AttributeType,
|
|
Mandatory: a.Mandatory,
|
|
}
|
|
for _, v := range a.SupportedValues {
|
|
ar.Values = append(ar.Values, struct {
|
|
ID string `json:"id"`
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
}{ID: v.ID, Key: v.Key, Label: v.Label})
|
|
}
|
|
attrs = append(attrs, ar)
|
|
}
|
|
out(map[string]any{
|
|
"category_id": resp.CategoryID,
|
|
"attributes": attrs,
|
|
})
|
|
}
|
|
|
|
// price-suggestion — get suggested price ranges
|
|
//
|
|
// mrktplaats price-suggestion --category ID --title TEXT
|
|
func cmdPriceSuggestion(args []string) {
|
|
fs := flag.NewFlagSet("price-suggestion", flag.ExitOnError)
|
|
categoryID := fs.Int("category", 0, "category ID (required)")
|
|
title := fs.String("title", "", "listing title (required)")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats price-suggestion --category ID --title TEXT
|
|
|
|
Get suggested price ranges for a listing based on similar items.
|
|
Does not require authentication.
|
|
|
|
Output: {"segments": [{title, min_price_cents, max_price_cents, min_euros, max_euros, similar_count}]}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *categoryID == 0 || *title == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := mrktplaats.NewClient()
|
|
resp, err := client.SYI.PriceSuggestion(ctx(), *categoryID, *title)
|
|
if err != nil {
|
|
fatalf("price-suggestion: %v", err)
|
|
}
|
|
|
|
type segResult struct {
|
|
Title string `json:"title"`
|
|
MinPriceCents int `json:"min_price_cents"`
|
|
MaxPriceCents int `json:"max_price_cents"`
|
|
MinEuros float64 `json:"min_euros"`
|
|
MaxEuros float64 `json:"max_euros"`
|
|
SimilarCount int `json:"similar_count"`
|
|
}
|
|
var segs []segResult
|
|
for _, s := range resp.Segments {
|
|
segs = append(segs, segResult{
|
|
Title: s.Title,
|
|
MinPriceCents: s.MinPrice,
|
|
MaxPriceCents: s.MaxPrice,
|
|
MinEuros: float64(s.MinPrice) / 100,
|
|
MaxEuros: float64(s.MaxPrice) / 100,
|
|
SimilarCount: s.TotalSimilarAdsCount,
|
|
})
|
|
}
|
|
out(map[string]any{
|
|
"segments": segs,
|
|
})
|
|
}
|
|
|
|
// recognize — auto-detect category from a photo
|
|
//
|
|
// mrktplaats recognize --file PATH
|
|
func cmdRecognize(args []string) {
|
|
fs := flag.NewFlagSet("recognize", flag.ExitOnError)
|
|
filePath := fs.String("file", "", "path to image file (required)")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `usage: mrktplaats recognize --file PATH
|
|
|
|
Upload an image and get category/attribute predictions via image recognition.
|
|
Does not require authentication. Large images are automatically compressed.
|
|
|
|
Output: {"predictions": [{category_id, category_name, confidence, attributes}]}
|
|
|
|
Flags:`)
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
if *filePath == "" {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
f, err := os.Open(*filePath)
|
|
if err != nil {
|
|
fatalf("recognize: opening file: %v", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
filename := filepath.Base(*filePath)
|
|
client := mrktplaats.NewClient()
|
|
resp, err := client.Saleability.Recognize(ctx(), f, filename)
|
|
if err != nil {
|
|
fatalf("recognize: %v", err)
|
|
}
|
|
out(resp)
|
|
}
|
|
|
|
// ── usage ─────────────────────────────────────────────────────────────────────
|
|
|
|
func printUsage() {
|
|
fmt.Fprint(os.Stderr, `mrktplaats — marktplaats.nl command-line client
|
|
|
|
USAGE
|
|
mrktplaats <command> [flags]
|
|
|
|
AUTHENTICATION
|
|
Token resolution order:
|
|
1. MRKTPLAATS_TOKEN environment variable
|
|
2. ~/.config/mrktplaats/token (written automatically after login)
|
|
3. ./.mrktplaats-token in the current directory
|
|
|
|
mrktplaats login --email EMAIL --password PASS [--code 2FA_CODE]
|
|
|
|
COMMANDS
|
|
Authentication
|
|
login Authenticate and save access token
|
|
|
|
Browsing (no auth required)
|
|
search Search listings --query Q [--category ID] [--page N] [--size N] [--sort FIELD]
|
|
listing Get listing details --urn URN
|
|
seller-listings All listings by a seller --seller ID or --urn URN
|
|
reviews View seller reviews --seller ID or --urn URN [--role reviewee|all]
|
|
categories List full category tree
|
|
similar Find similar listings --urn URN [--seller ID] [--category ID]
|
|
suggest Search autocomplete --prefix TEXT [--category ID]
|
|
|
|
Messaging
|
|
conversations List conversations [--limit N]
|
|
messages Read conversation messages --conv CONV_ID [--limit N]
|
|
send Start a new conversation --urn URN --text TEXT
|
|
reply Reply in a conversation --conv CONV_ID --text TEXT
|
|
|
|
Bidding
|
|
bid Place a bid --urn URN --amount EUROS [--message TEXT]
|
|
remove-bid Remove a bid --id BID_ID
|
|
|
|
Favourites
|
|
favorites List saved listings [--limit N]
|
|
add-favorite Save a listing --urn URN
|
|
remove-favorite Unsave a listing --urn URN
|
|
|
|
My Account
|
|
me Show own profile
|
|
my-ads List own listings [--status active|inactive|sold] [--limit N]
|
|
delete-ad Delete a listing --urn URN [--reason 1-4]
|
|
notifications Unread notification count
|
|
|
|
Selling (SYI)
|
|
syi-form Show category form attrs --category ID
|
|
create-ad Publish a new listing --category ID --title T --description D --price EUROS
|
|
upload-image Upload image for listing --file PATH
|
|
price-suggestion Suggested price ranges --category ID --title TEXT
|
|
recognize Auto-detect category --file PATH (image recognition)
|
|
|
|
Run 'mrktplaats <command> --help' for flag details.
|
|
|
|
OUTPUT
|
|
All commands output pretty-printed JSON on stdout.
|
|
Errors are printed on stderr with exit code 1.
|
|
|
|
EXAMPLES
|
|
# Find a watch listing
|
|
mrktplaats search --query "seiko" --category 1831 --size 5
|
|
|
|
# Get full details including bid info
|
|
mrktplaats listing --urn m2372861012
|
|
|
|
# Message the seller
|
|
mrktplaats send --urn m2372861012 --text "Is this still available?"
|
|
|
|
# Reply to an existing conversation
|
|
mrktplaats conversations
|
|
mrktplaats reply --conv 1cgx:5qgvx3h:2p1gxt95d --text "Thanks, I will pick it up."
|
|
|
|
# Place a €25 bid
|
|
mrktplaats bid --urn m2372861012 --amount 25
|
|
|
|
# Save and remove a favourite
|
|
mrktplaats add-favorite --urn m2372861012
|
|
mrktplaats remove-favorite --urn m2372861012
|
|
|
|
# Sell an item
|
|
mrktplaats recognize --file photo.jpg
|
|
mrktplaats syi-form --category 1831
|
|
mrktplaats price-suggestion --category 1831 --title "Seiko SKX007"
|
|
mrktplaats upload-image --file photo.jpg
|
|
mrktplaats create-ad --category 1831 --title "Seiko SKX007" --description "Great watch" --price 150 --seller-name "Jan" --postcode "1000"
|
|
`)
|
|
}
|
|
|
|
// ── main ──────────────────────────────────────────────────────────────────────
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
printUsage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
cmd := os.Args[1]
|
|
args := os.Args[2:]
|
|
|
|
switch cmd {
|
|
case "login":
|
|
cmdLogin(args)
|
|
case "search":
|
|
cmdSearch(args)
|
|
case "listing":
|
|
cmdListing(args)
|
|
case "conversations":
|
|
cmdConversations(args)
|
|
case "messages":
|
|
cmdMessages(args)
|
|
case "send":
|
|
cmdSend(args)
|
|
case "reply":
|
|
cmdReply(args)
|
|
case "bid":
|
|
cmdBid(args)
|
|
case "remove-bid":
|
|
cmdRemoveBid(args)
|
|
case "favorites":
|
|
cmdFavorites(args)
|
|
case "add-favorite":
|
|
cmdAddFavorite(args)
|
|
case "remove-favorite":
|
|
cmdRemoveFavorite(args)
|
|
case "me":
|
|
cmdMe(args)
|
|
case "my-ads":
|
|
cmdMyAds(args)
|
|
case "delete-ad":
|
|
cmdDeleteAd(args)
|
|
case "seller-listings":
|
|
cmdSellerListings(args)
|
|
case "reviews":
|
|
cmdReviews(args)
|
|
case "categories":
|
|
cmdCategories(args)
|
|
case "similar":
|
|
cmdSimilar(args)
|
|
case "suggest":
|
|
cmdSuggest(args)
|
|
case "notifications":
|
|
cmdNotifications(args)
|
|
case "create-ad":
|
|
cmdCreateAd(args)
|
|
case "upload-image":
|
|
cmdUploadImage(args)
|
|
case "syi-form":
|
|
cmdSYIForm(args)
|
|
case "price-suggestion":
|
|
cmdPriceSuggestion(args)
|
|
case "recognize":
|
|
cmdRecognize(args)
|
|
case "help", "--help", "-h":
|
|
printUsage()
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "error: unknown command %q\n\n", cmd)
|
|
printUsage()
|
|
os.Exit(1)
|
|
}
|
|
}
|