commit 7470bf1baa8574fa57037e9a441faf2f1780c693 Author: Joren Date: Wed Apr 15 23:45:49 2026 +0200 Initial Marktplaats CLI scaffold Create mrktplaats-cli from twdehands-cli, updating module/import names, command branding, and token paths while preserving command behavior and payload formats. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b125337 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +mrktplaats +mrktplaats.exe +tmp_ocr_*/ diff --git a/IDENTITY.md b/IDENTITY.md new file mode 100644 index 0000000..a2a615a --- /dev/null +++ b/IDENTITY.md @@ -0,0 +1,40 @@ +Role & Identity: +You are Jonathan, a real human user buying computer hardware on the Belgian marketplace "marktplaats.nl". You are NOT an AI, bot, or automated script. You write messages exactly like a normal person typing quickly on a smartphone. + +Language & Tone: + + Language: Casual, conversational Dutch (Flemish nuances are fine). + + Tone: Friendly, polite, but brief and direct. Do not sound corporate, overly formal, or robotic. + +Strict Formatting Rules (CRITICAL): + + NO Emojis: Never use emojis under any circumstances. + + NO Em-dashes or En-dashes: Never use — or –. Use regular commas or periods. + + NO IDs or Metadata: Never include profile IDs (like "40921019"), URNs, reference numbers, or brackets in your messages to sellers. + + NO Bullet points in chat: Write in short, normal paragraphs or single lines. + + NO Passive Voice: Avoid robotic phrases like "Dit bericht is door mij verstuurd." Use active, human phrasing like "Ik stuurde dit per ongeluk." + +Message Structure: + + Greeting: Start with "Hoi", "Hallo", "Beste", or "Dag [Naam]". + + Body: Keep it strictly to 1 or 2 short sentences. Get straight to the point (e.g., asking about SMART data, making an offer, or apologizing). + + Sign-off: End simply with "Groeten, Jonathan", "Mvg, Jonathan", or just "Groeten". + +Examples of GOOD output: + + "Hoi, oei sorry, dat vorige bericht was een foutje en niet voor jou bedoeld. Mag je negeren! Groeten, Jonathan" + + "Hallo, ik heb wel interesse. Heb je toevallig een recente SMART screenshot van de schijf? Mvg, Jonathan" + +Examples of BAD output (NEVER DO THIS): + + "Hi — sorry, dat bericht was vroegtijdig verstuurd door mij. Trek mijn huidige bieding in. (40921019)" + + "Beste verkoper, hierbij wil ik u informeren dat..." diff --git a/README.md b/README.md new file mode 100644 index 0000000..bcb7a67 --- /dev/null +++ b/README.md @@ -0,0 +1,437 @@ +# mrktplaats CLI + +Command-line interface to [marktplaats.nl](https://www.marktplaats.nl), built on the +[mrktplaats Go SDK](../mrktplaats). Designed to be used by both humans and LLM agents. + +--- + +## Build + +```bash +cd mrktplaats-cli +go build -o mrktplaats . +# optional: install globally +go install . +``` + +--- + +## Authentication + +Run `login` once to authenticate. The token is saved to +`~/.config/mrktplaats/token` and reused automatically. + +```bash +mrktplaats login --email you@example.com --password yourpassword +``` + +If the account has SMS 2FA enabled the first call returns a verification prompt. +Re-run with the code you received: + +```bash +mrktplaats login --email you@example.com --password yourpassword --code 123456 +``` + +**Alternative: environment variable** + +```bash +export MRKTPLAATS_TOKEN=your-token-here +mrktplaats me +``` + +--- + +## Output format + +All commands write **pretty-printed JSON** to stdout. +Errors are written to stderr and exit with code 1. + +--- + +## Command reference + +### `login` + +``` +mrktplaats login --email EMAIL --password PASS [--code 2FA_CODE] +``` + +Authenticate and persist the token. With 2FA enabled, the first call returns +`"status": "verification_required"` — re-run with `--code` after receiving the SMS. + +**Output (success)** +```json +{ + "status": "ok", + "token_saved": "/home/you/.config/mrktplaats/token", + "access_token": "8fc90f77-..." +} +``` + +**Output (2FA required)** +```json +{ + "status": "verification_required", + "method": "sms", + "message": "We sent a code to +32 4xx xxx xxx", + "request_id": "abc123", + "next_step": "re-run with --code CODE after receiving the SMS" +} +``` + +--- + +### `search` + +``` +mrktplaats search --query Q [--category ID] [--page N] [--size N] [--sort FIELD] +``` + +Search listings. No authentication required. + +| Flag | Default | Description | +|------|---------|-------------| +| `--query` | *(required)* | Search query | +| `--category` | 0 (all) | Category ID filter | +| `--page` | 1 | Page number | +| `--size` | 20 | Results per page (max 100) | +| `--sort` | `SORT_INDEX` | `SORT_INDEX` \| `DATE_DESC` \| `PRICE_ASC` \| `PRICE_DESC` | +| `--seller` | — | Filter by seller ID | + +**Output** +```json +{ + "total": 2573, + "page": 1, + "size": 3, + "listings": [ + { + "urn": "m2372861012", + "title": "Seiko SARV001", + "price_cents": 25000, + "price_label": "€ 250,00", + "price_type": "FIXED", + "city": "Gent", + "seller_name": "Jonathan", + "url": "https://..." + } + ] +} +``` + +`price_type` values: `FIXED`, `MIN_BID`, `FAST_BID`, `FREE`, `ON_REQUEST`. + +--- + +### `listing` + +``` +mrktplaats listing --urn URN +``` + +Fetch full details for a listing. Requires authentication. + +**Output** — full listing object including: +- `adCore.title`, `.description`, `.price`, `.adAddress`, `.pictures`, `.attributes` +- `sellerInformation.id`, `.name` — seller's ID is needed for `Relevant.Get` +- `bids` — array of current bids +- `currentMinimumBid` — minimum next bid in cents + +--- + +### `conversations` + +``` +mrktplaats conversations [--limit N] +``` + +List all messaging conversations. + +**Output** +```json +{ + "total": 2, + "unread": 0, + "conversations": [ + { + "id": "1cgx:5qgvx3h:2p1gxt95d", + "item_urn": "m2372861012", + "title": "NVIDEA 100000 V2", + "other_party": "Mattia", + "other_party_id": 57506580, + "unread": 0, + "latest_message": "Is this still available?" + } + ] +} +``` + +--- + +### `messages` + +``` +mrktplaats messages --conv CONV_ID [--limit N] [--offset N] +``` + +Read messages in a conversation. Use `conversations` to find IDs. + +**Output** +```json +{ + "total": 4, + "messages": [ + { + "id": "abc123", + "sender_id": 40921019, + "text": "Is this still available?", + "date": "2026-03-03T18:48:00Z", + "type": "chat", + "read": false + } + ] +} +``` + +--- + +### `send` + +``` +mrktplaats send --urn URN --text TEXT +``` + +Start a **new** conversation with the seller of a listing. +Use `reply` to respond to an existing conversation. + +**Output** +```json +{ + "conversation_id": "1cgx:5qgvx3h:2p1gxt95d", + "item_urn": "m2372861012", + "status": "sent" +} +``` + +--- + +### `reply` + +``` +mrktplaats reply --conv CONV_ID --text TEXT +``` + +Send a message in an **existing** conversation. + +**Output** +```json +{ + "message_id": "029c3014-...", + "conversation_id": "1cgx:5qgvx3h:2p1gxt95d", + "status": "sent" +} +``` + +--- + +### `bid` + +``` +mrktplaats bid --urn URN --amount EUROS [--message TEXT] +``` + +Place a bid. The listing must have `price_type` of `MIN_BID` or `FAST_BID`. +Amount is in **euros** (e.g. `--amount 20` for €20.00). + +**Output** +```json +{ + "bids": [ + {"id": 1533844324, "value": 2000, "date": "...", "user": {"id": 40921019, "nickname": "Jonathan"}} + ], + "current_minimum_bid_cents": 2000, + "current_minimum_bid_euros": 20.0 +} +``` + +--- + +### `remove-bid` + +``` +mrktplaats remove-bid --id BID_ID +``` + +Remove a bid. `BID_ID` is the numeric `id` field from the `bids` array. + +**Output** +```json +{ + "bids": [], + "current_minimum_bid_cents": 2000, + "current_minimum_bid_euros": 20.0 +} +``` + +--- + +### `favorites` + +``` +mrktplaats favorites [--limit N] +``` + +List saved listings. + +**Output** +```json +{ + "total": 3, + "more": false, + "items": [ + {"item_urn": "m2372861012", "saved_at": ""} + ] +} +``` + +--- + +### `add-favorite` / `remove-favorite` + +``` +mrktplaats add-favorite --urn URN +mrktplaats remove-favorite --urn URN +``` + +**Output** +```json +{"status": "added", "item_urn": "m2372861012"} +{"status": "removed", "item_urn": "m2372861012"} +``` + +--- + +### `me` + +``` +mrktplaats me +``` + +Get the authenticated user's profile (id, name, email, postcode, etc.). + +--- + +### `my-ads` + +``` +mrktplaats my-ads [--status active|inactive|sold] [--limit N] +``` + +List own listings. + +**Output** +```json +{ + "total": 1, + "ads": [ + { + "item_id": "m2373048475", + "title": "Seiko SARV001 - SDK integratietest", + "status": "active", + "price_cents": 25000, + "price_euros": 250.0 + } + ] +} +``` + +--- + +### `delete-ad` + +``` +mrktplaats delete-ad --urn URN [--reason N] +``` + +Delete one of your own listings. + +| Reason | Meaning | +|--------|---------| +| 1 | Sold on marktplaats.nl | +| 2 | Sold elsewhere | +| 3 | No longer selling | +| 4 | Other (default) | + +**Output** +```json +{"status": "deleted", "item_urn": "m2373048475"} +``` + +--- + +## LLM agent usage guide + +This tool is designed to be called by an LLM agent as a subprocess. All output +is structured JSON so the agent can parse results directly. + +### Typical workflow for buying + +``` +1. search for items + mrktplaats search --query "laptop" --size 10 + +2. inspect a promising listing + mrktplaats listing --urn + +3. check conversations to see if you already contacted this seller + mrktplaats conversations + +4. contact the seller (new conversation) + mrktplaats send --urn --text "Is this still available? Can you do €X?" + + OR reply to an existing conversation + mrktplaats reply --conv --text "..." + +5. if the listing supports bidding (price_type = MIN_BID or FAST_BID) + mrktplaats bid --urn --amount + # to cancel: mrktplaats remove-bid --id + +6. save interesting listings for later + mrktplaats add-favorite --urn +``` + +### Typical workflow for selling + +``` +1. check own listings + mrktplaats my-ads + +2. delete a listing + mrktplaats delete-ad --urn + +3. check and reply to incoming messages + mrktplaats conversations + mrktplaats messages --conv + mrktplaats reply --conv --text "..." +``` + +### Key fields to extract from responses + +| Task | Command | Field to read | +|------|---------|---------------| +| Get listing URN from search | `search` | `listings[].urn` | +| Get seller ID (for bids/contact) | `listing` | `sellerInformation.id` | +| Get bid ID (to cancel) | `listing` or `bid` | `bids[].id` | +| Get conversation ID | `conversations` or `send` | `conversations[].id` / `conversation_id` | +| Check if bid-enabled | `listing` | `adCore.price.priceType == "MIN_BID"` | +| Get minimum bid | `listing` | `currentMinimumBid` (cents) | + +### Error handling + +On error the tool exits with code 1 and writes to stderr: + +``` +error: listing m9999: mrktplaats: API error 404: ... +``` + +The agent should check the exit code and parse the error message from stderr. diff --git a/SKILLS.md b/SKILLS.md new file mode 100644 index 0000000..23a57ce --- /dev/null +++ b/SKILLS.md @@ -0,0 +1,153 @@ +# mrktplaats CLI — Skill Guide for AI Agents + +## What You Can Do + +You have a `mrktplaats` CLI tool to interact with marktplaats.nl (Belgian marketplace). Use it to: +- **Search** for items (phones, RAM, electronics, etc.) +- **Get details** on listings (price, condition, seller info) +- **Check seller ratings** before buying +- **Contact sellers** via marktplaats messaging +- **Check your messages** and conversations +- **Bid** on auction items +- **Post listings** to sell items + +## When to Use This Tool + +**Use it when the user asks:** +- "Are there any messages?" → Run `conversations` +- "Find me a cheap Samsung phone" → Run `search` +- "What's the condition of listing X?" → Run `listing --urn X` +- "Is seller Y trustworthy?" → Run `reviews --seller Y` +- "Message the seller about X" → Run `send` or `reply` +- "Send those links to Discord" → Use curl to webhook + +**Don't use it for:** +- General web searches (use websearch) +- Code questions +- Anything unrelated to marktplaats.nl + +## Quick Reference + +```bash +# Search (most common) +./mrktplaats search --query "TERMS" --size 50 --sort PRICE_ASC + +# Get details +./mrktplaats listing --urn URN + +# Check seller +./mrktplaats reviews --seller ID + +# Messages +./mrktplaats conversations --limit 10 +./mrktplaats messages --conv CONV_ID --limit 10 + +# Contact seller (NEW listing) +./mrktplaats send --urn URN --text "MESSAGE" + +# Contact seller (EXISTING conversation) +./mrktplaats reply --conv CONV_ID --text "MESSAGE" + +# Discord webhook +curl -X POST WEBHOOK_URL -H "Content-Type: application/json" -d '{"content": "..."}' +``` + +## Writing Messages (IMPORTANT) + +When contacting sellers, follow these rules: + +**Language:** Casual Dutch (Flemish) +**Tone:** Friendly, brief, direct — like texting +**NO:** emojis, em-dashes (—), IDs/URNs, bullet points + +**Structure:** +1. Greeting: "Hoi", "Hallo", "Beste [Naam]" +2. Body: 1-2 short sentences +3. Sign-off: "Groeten, Jonathan" + +**Good examples:** +- "Hoi, is die Samsung S22 nog beschikbaar? Groeten, Jonathan" +- "Hoi, kan ik hem morgen ophalen? Groeten, Jonathan" + +**Never do:** +- "Hi — I'm interested in your listing (URN: m123456). Please respond." +- Include profile IDs, prices in subject, or formal language + +## Finding Deals + +User's benchmarks: +- DDR4 RAM: ~€2-3/GB is good +- Used phones: depends on model/condition +- Always check: price, condition (in description), seller reviews + +**Workflow:** +1. Search with broad terms: `./mrktplaats search --query "ram sodimm" --size 50 --sort PRICE_ASC` +2. Analyze top results +3. Get details: `./mrktplaats listing --urn URN` +4. Check seller: `./mrktplaats reviews --seller ID` +5. Present findings with links + +**Links format:** `https://link.marktplaats.nl/{URN}` + +## Discord Integration + +**Webhook URL:** +``` +https://discord.com/api/webhooks/1478131681033588859/xjPSaVsePMWrmXI1jGtvnvAaQ2L4jeBVZt9KzXY2LAH3KmHATsLIkqsgjzcMa31oYcnY +``` + +**Send message:** +```bash +curl -X POST WEBHOOK_URL \ + -H "Content-Type: application/json" \ + -d '{"content": "Your message here"}' +``` + +## Errors & Solutions + +| Error | Meaning | Solution | +|-------|---------|----------| +| `401 Unauthorized` | Token expired | Ask user to login | +| `403 VERIFICATION_NOT_FOUND` | 2FA blocked | Wait hours, then retry login | +| `dial tcp: lookup app.marktplaats.nl` | Network error | Retry later | +| No results returned | Bad search terms | Try different query | + +## Login Flow + +1. Ask user for email/password (or token) +2. Run: `./mrktplaats login --email EMAIL --password PASS` +3. If 2FA required → ask for SMS code +4. Re-run with code: `./mrktplaats login --email EMAIL --password PASS --code CODE` + +## Complete Workflow Examples + +### Example 1: User wants to find cheap DDR4 RAM +``` +You: ./mrktplaats search --query "DDR4 sodimm" --size 50 --sort PRICE_ASC +→ Results: [...] + +You: Analyze and calculate €/GB for top items + +You: Present table with best deals +``` + +### Example 2: User wants to contact a seller +``` +You: ./mrktplaats send --urn m1234567890 --text "Hoi, is die nog beschikbaar? Groeten, Jonathan" +→ {"status": "sent", ...} +``` + +### Example 3: User wants to check messages +``` +You: ./mrktplaats conversations --limit 10 +→ Shows conversations with unread count + +You: ./mrktplaats messages --conv CONV_ID --limit 5 +→ Shows message history +``` + +### Example 4: User wants to send deal links to Discord +``` +You: curl -X POST WEBHOOK_URL -H "Content-Type: application/json" \ + -d '{"content": "DDR4 RAM deals:\n- 16GB - €50: https://link.marktplaats.nl/m123"}' +``` diff --git a/TOOLS.md b/TOOLS.md new file mode 100644 index 0000000..abf4b73 --- /dev/null +++ b/TOOLS.md @@ -0,0 +1,291 @@ +# mrktplaats CLI — Technical Reference + +## Installation & Setup + +The CLI is in `/home/joren/dev/marktplaatsApi/mrktplaats-cli/`. Run commands from there or add to PATH. + +## Authentication + +```bash +# Login (starts 2FA flow) +./mrktplaats login --email EMAIL --password PASS + +# If 2FA triggered, re-run with code +./mrktplaats login --email EMAIL --password PASS --code 123456 + +# Token location: ~/.config/mrktplaats/token +# Or set env var: MRKTPLAATS_TOKEN=... +``` + +## Commands + +### Search Listings + +```bash +./mrktplaats search --query "SEARCH_TERMS" [--category ID] [--size N] [--sort SORT] +``` + +| Flag | Values | Description | +|------|--------|-------------| +| `--query` | string | Search terms (required) | +| `--category` | ID | Category ID (optional) | +| `--size` | 1-100 | Number of results (default 20) | +| `--sort` | PRICE_ASC, PRICE_DESC, DATE_DESC | Sort order | + +**Example:** +```bash +./mrktplaats search --query "Samsung Galaxy S22" --size 30 --sort PRICE_ASC +``` + +**Output:** +```json +{ + "listings": [ + { + "urn": "m2375361894", + "title": "Samsung Galaxy s22, 128g, 8g ram", + "price_cents": 16000, + "price_label": "€ 160,00", + "price_type": "FIXED", + "city": "Halle", + "seller_name": "Tom" + } + ], + "page": 1, + "size": 30, + "total": 403 +} +``` + +--- + +### Get Listing Details + +```bash +./mrktplaats listing --urn URN +``` + +**Output:** +```json +{ + "adCore": { + "urn": "m2375361894", + "title": "Samsung Galaxy s22, 128g, 8g ram", + "description": "Is ongeveer een tweetal jaar gebruikt...", + "price": {"priceAmount": 16000, "priceType": "FIXED"}, + "pictures": [...], + "attributes": [ + {"key": "condition", "name": "Conditie", "values": [{"name": "Gebruikt"}]}, + {"key": "Opslagcapaciteit", "values": [{"name": "128 GB"}]} + ], + "link": "https://link.marktplaats.nl/m2375361894" + }, + "sellerInformation": { + "id": 34593982, + "name": "Tom", + "type": "CONSUMER", + "activeSince": {"label": "8½ jaar"}, + "kycState": {"twoFactorVerificationState": "VERIFIED"} + } +} +``` + +--- + +### Seller Reviews + +```bash +./mrktplaats reviews --seller ID +# or from a listing +./mrktplaats reviews --urn URN +``` + +**Output:** +```json +{ + "reviews": [ + { + "id": 125540237, + "score": 5, + "reviewer_name": "Kakoje", + "subject": "Monitoren", + "direction": "S2B", + "date": "2024-06-26", + "details": [{"category": "general", "score": 5, "traits": ["+Reageert snel", "+Vriendelijk"]}] + } + ], + "seller_id": 34593982, + "summary": {"average_score": 4.5, "count": 14} +} +``` + +--- + +### Conversations + +```bash +./mrktplaats conversations --limit N +``` + +**Output:** +```json +{ + "conversations": [ + { + "id": "5b6x:2p0q1r6:2p16t6r09", + "item_urn": "m2366146019", + "title": "Ebike FIETSBATRERIJ", + "other_party": "linde.de", + "other_party_id": 32557926, + "unread": 1, + "latest_message": "Wilrijk" + } + ], + "total": 3, + "unread": 1 +} +``` + +--- + +### Messages + +```bash +./mrktplaats messages --conv CONV_ID --limit N +``` + +**Output:** +```json +{ + "messages": [ + { + "id": "7ae205d4-18c6-11f1-977b-25f14ffc6a81", + "sender_id": 32557926, + "text": "Wilrijk", + "date": "2026-03-05T19:06:56.942Z", + "type": "chat" + } + ], + "total": 6 +} +``` + +--- + +### Send Message (New Conversation) + +```bash +./mrktplaats send --urn URN --text "MESSAGE" +``` + +**Output:** +```json +{ + "item_urn": "m2372861012", + "seller_id": "57506580", + "seller_name": "Mattia", + "status": "sent" +} +``` + +--- + +### Reply to Conversation + +```bash +./mrktplaats reply --conv CONV_ID --text "MESSAGE" +``` + +**Output:** +```json +{ + "conversation_id": "1cgx:5qgvx3h:2p1gxt95d", + "message_id": "448b34fe-1806-11f1-9484-e9655a940eaf", + "status": "sent" +} +``` + +--- + +### Place Bid + +```bash +./mrktplaats bid --urn URN --amount EUROS [--message TEXT] +``` + +--- + +### Other Commands + +```bash +# Seller's other listings +./mrktplaats seller-listings --seller ID + +# Similar listings +./mrktplaats similar --urn URN + +# Search autocomplete +./mrktplaats suggest --prefix TEXT + +# Favorites +./mrktplaats favorites +./mrktplaats add-favorite --urn URN +./mrktplaats remove-favorite --urn URN + +# User info +./mrktplaats me +./mrktplaats notifications +./mrktplaats my-ads + +# Sell item +./mrktplaats recognize --file PATH +./mrktplaats price-suggestion --category ID --title TEXT +./mrktplaats create-ad --category ID --title T --description D --price EUROS +``` + +## Error Messages + +| Error | Cause | Fix | +|-------|-------|-----| +| `401 Unauthorized` | Token expired/missing | Run login | +| `403 VERIFICATION_NOT_FOUND` | Too many wrong 2FA codes | Wait hours | +| `403 VERIFICATION_REQUESTS_BLOCKED` | 2FA blocked | Wait hours | +| `dial tcp: lookup app.marktplaats.nl: server misbehaving` | DNS/network | Retry later | +| `No results found` | Search terms not matching | Try broader terms | + +## Common Patterns + +### Finding Deals +1. Search: `./mrktplaats search --query "DDR4 sodimm" --size 50 --sort PRICE_ASC` +2. Parse results (max 100) +3. Fetch details for promising URNs: `./mrktplaats listing --urn URN` +4. Calculate price-per-GB for memory +5. Check seller reviews + +### Checking Messages +1. `./mrktplaats conversations --limit 10` → shows unread count +2. If unread > 0, fetch: `./mrktplaats messages --conv CONV_ID --limit 10` + +### Contacting Seller +1. Use `./mrktplaats send --urn URN --text "..."` for new listings +2. Use `./mrktplaats reply --conv CONV_ID --text "..."` for existing threads + +### Sending to Discord +```bash +curl -X POST "https://discord.com/api/webhooks/1478131681033588859/xjPSaVsePMWrmXI1jGtvnvAaQ2L4jeBVZt9KzXY2LAH3KmHATsLIkqsgjzcMa31oYcnY" \ + -H "Content-Type: application/json" \ + -d '{"content": "Samsung S22 - €160: https://link.marktplaats.nl/m2375361894"}' +``` + +## IDENTITY.md Reminder + +When writing messages to sellers: +- Use casual Dutch (Flemish) +- Keep it short (1-2 sentences) +- NO emojis, NO em-dashes, NO IDs/URNs +- Sign off with "Groeten, Jonathan" + +**Example:** +``` +Hoi, is die Samsung S22 nog beschikbaar? Groeten, Jonathan +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b8302bb --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/joren/mrktplaats-cli + +go 1.25.0 + +require github.com/joren/mrktplaats v0.0.0 + +replace github.com/joren/mrktplaats => ../mrktplaats diff --git a/main.go b/main.go new file mode 100644 index 0000000..99c7ce9 --- /dev/null +++ b/main.go @@ -0,0 +1,1711 @@ +// 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 [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 --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) + } +} diff --git a/mrktplaats-cli b/mrktplaats-cli new file mode 100755 index 0000000..e3738f8 Binary files /dev/null and b/mrktplaats-cli differ diff --git a/scripts/poll_nvidia_reply.py b/scripts/poll_nvidia_reply.py new file mode 100755 index 0000000..5966d99 --- /dev/null +++ b/scripts/poll_nvidia_reply.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +Long-running poller for a conversation on the mrktplaats-cli. + +Usage: python3 scripts/poll_nvidia_reply.py --conv CONV_ID --user USER_ID [--interval 900] + +The script runs in a loop, calling `go run main.go messages --conv CONV_ID --limit 50` +in the repo directory, parses the JSON output and exits when it finds any message +whose `sender_id` is not the provided USER_ID (i.e. a seller reply). + +When a reply is found the script writes a JSON file with the matching messages to +/home/joren/.local/share/opencode/tool-output/nvidia_reply_found.json and exits with +code 0. It logs progress to stdout/stderr so you can follow the `nohup` log file. +""" + +import argparse +import json +import subprocess +import time +import os +from datetime import datetime + + +def now(): + return datetime.utcnow().isoformat() + "Z" + + +def fetch_messages(conv, workdir): + # Run the mrktplaats-cli to fetch the conversation messages + cmd = ["go", "run", "main.go", "messages", "--conv", conv, "--limit", "50"] + proc = subprocess.run(cmd, cwd=workdir, capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError(f"messages command failed: {proc.stderr.strip()}") + return proc.stdout + + +def find_foreign_messages(json_text, user_id): + data = json.loads(json_text) + msgs = data.get("messages") or [] + foreign = [m for m in msgs if int(m.get("sender_id", 0)) != int(user_id)] + return foreign + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--conv", required=True, help="Conversation id to poll") + parser.add_argument("--user", required=True, help="Our user id (numeric)") + parser.add_argument( + "--interval", + type=int, + default=900, + help="Poll interval in seconds (default 900 = 15m)", + ) + parser.add_argument( + "--workdir", + default="/home/joren/dev/marktplaatsApi/mrktplaats-cli", + help="Path to mrktplaats-cli repo", + ) + args = parser.parse_args() + + out_dir = os.path.expanduser("/home/joren/.local/share/opencode/tool-output") + os.makedirs(out_dir, exist_ok=True) + found_path = os.path.join(out_dir, "nvidia_reply_found.json") + + print( + f"{now()} Poller started for conv={args.conv}, user={args.user}, interval={args.interval}s" + ) + # write pid file + pid_path = os.path.join(out_dir, "nvidia_poll.pid") + with open(pid_path, "w") as f: + f.write(str(os.getpid())) + + try: + while True: + try: + raw = fetch_messages(args.conv, args.workdir) + except Exception as e: + print(f"{now()} Error fetching messages: {e}") + time.sleep(30) + continue + + try: + foreign = find_foreign_messages(raw, args.user) + except Exception as e: + print(f"{now()} Error parsing messages JSON: {e}") + time.sleep(30) + continue + + if foreign: + result = {"conv": args.conv, "detected_at": now(), "messages": foreign} + with open(found_path, "w") as f: + json.dump(result, f, indent=2) + print( + f"{now()} Found {len(foreign)} foreign message(s) — wrote {found_path}" + ) + # also print the messages for immediate visibility + print(json.dumps(result, indent=2)) + return 0 + + print(f"{now()} No reply yet; sleeping {args.interval} seconds") + time.sleep(args.interval) + finally: + # cleanup pid file + try: + os.remove(pid_path) + except Exception: + pass + + +if __name__ == "__main__": + main()