From 651924c2a32b5c03d97e347d3dd0a0abbe45b4ad Mon Sep 17 00:00:00 2001 From: Microck Date: Mon, 5 Oct 2026 16:23:44 +0000 Subject: [PATCH] feat(item): expose direct image links --- README.md | 3 +- docs-site/content/docs/reference/item.mdx | 18 +++++++ docs/cli-spec.md | 5 ++ internal/cli/cli_test.go | 65 +++++++++++++++++++++++ internal/cli/item.go | 33 ++++++++++++ internal/fakewallapop/fake.go | 7 ++- 6 files changed, 128 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 712edd2..a59250a 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ eligible interactive commands check for a new stable release at most once a day | `search [keywords] [--filter k=v]...` | search around your location; `search filters` lists valid keys | | `category list` | category ids | | `alert list` | your saved searches on wallapop; `watch add search --from-alert ID --name NAME` turns one into a watch | -| `item show / open / favorite / unfavorite` | inspect a listing by hash or url | +| `item show / images / open / favorite / unfavorite` | inspect a listing by hash or url | | `item reserve / sold / delete` | act on your own listings (`sold`, `delete` ask or need `--yes`) | | `user show / items / reviews` | look at a seller by hash, url or slug | | `me show / items / favorites` | your account | @@ -207,6 +207,7 @@ wallapop completion powershell >> $PROFILE wallapop search "thinkpad x1" --max-price 400 --sort newest --format pretty wallapop search --category 100 --filter brand=Toyota --filter max_km=120000 --limit 20 wallapop item show https://es.wallapop.com/item/thinkpad-x1-carbon-1092837465 +wallapop item images https://es.wallapop.com/item/thinkpad-x1-carbon-1092837465 --format pretty wallapop watch add search "thinkpad x1" --max-price 400 --name x1 --notify phone wallapop watch check --all --format jsonl | jq -r 'select(.type=="item.price_changed") | .item.url' diff --git a/docs-site/content/docs/reference/item.mdx b/docs-site/content/docs/reference/item.mdx index 9fc6b85..3f84e07 100644 --- a/docs-site/content/docs/reference/item.mdx +++ b/docs-site/content/docs/reference/item.mdx @@ -81,6 +81,24 @@ Add the listing to your favorites wallapop item favorite ITEM ``` +## `wallapop item images` + +Direct image links in gallery order + +```bash +wallapop item images ITEM +``` + +Return direct image URLs for an item hash or full item URL. +JSON returns an array of URL strings; JSONL returns one JSON string per line. +Use --format pretty for one plain URL per line. This reads item metadata, +without downloading the images. + +```bash +wallapop item images k2j3h4g5f6d7 --format pretty +wallapop item show k2j3h4g5f6d7 | jq -r '.images[]' +``` + ## `wallapop item open` Open the listing in the browser diff --git a/docs/cli-spec.md b/docs/cli-spec.md index f9517e9..f79995c 100644 --- a/docs/cli-spec.md +++ b/docs/cli-spec.md @@ -134,6 +134,11 @@ detail with the page flags. - The item detail endpoint is the API of record: title, description, condition and price come from it, empty and zero included, and the rendered page contributes only what it alone knows (flags, counters, slug and the full taxonomy path). The page is a cached snapshot and lags writes. Both reference forms take the same merge, so a hash and its URL always report the same listing. - `item show ITEM` merges `GET /api/v3/items/{hash}` with the page flags (reserved, sold, expired, modified date, views, favorites). +- `item images ITEM` returns the direct image URLs in gallery order, using the same + item read as `item show`. JSON is an array of URL strings; JSONL emits one string + per line; pretty emits one unadorned URL per line. No images returns an empty + array (or no lines in JSONL/pretty). `item show --format pretty` also lists each + image URL without truncation. These commands read metadata, not image bytes. - `item open ITEM` opens the web page in the browser. - `item favorite ITEM` / `item unfavorite ITEM` via `PUT /api/v3/items/{hash}/favorite`. - `item reserve ITEM [--off]`, `item sold ITEM [--yes]`, `item delete ITEM [--yes]` act on the diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index b52d9d9..65084ff 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -360,6 +360,71 @@ func TestItemShowPrefersDetailOverAStalePage(t *testing.T) { } } +// Image extraction must keep the detail gallery order, even when the page +// has an older gallery, and plain output must contain only complete links. +func TestItemImages(t *testing.T) { + h := newHarness(t) + it := h.fake.AddItem(fakewallapop.Item{ + Hash: "hashsssstale", Title: "Fresh", Images: 2, PageImages: 5, + }) + want := []string{"https://cdn/hashsssstale-1.jpg", "https://cdn/hashsssstale-2.jpg"} + for _, ref := range []string{it.Hash, "https://es.wallapop.com/item/" + it.Slug} { + for _, format := range []string{"json", "jsonl", "pretty", "toon"} { + r := h.must("", "item", "images", ref, "--format", format) + switch format { + case "json": + var urls []string + decode(t, r.stdout, &urls) + if len(urls) != len(want) || urls[0] != want[0] || urls[1] != want[1] { + t.Fatalf("image URLs = %v, want %v", urls, want) + } + case "jsonl": + if r.stdout != fmt.Sprintf("%q\n%q\n", want[0], want[1]) { + t.Fatalf("JSONL = %q", r.stdout) + } + case "pretty": + if r.stdout != strings.Join(want, "\n")+"\n" { + t.Fatalf("plain links = %q", r.stdout) + } + case "toon": + if !strings.Contains(r.stdout, want[0]) || !strings.Contains(r.stdout, want[1]) || strings.Contains(r.stdout, "-3.jpg") { + t.Fatalf("TOON = %q", r.stdout) + } + } + } + shown := h.must("", "item", "show", ref, "--format", "pretty") + for _, url := range want { + if !strings.Contains(shown.stdout, url) { + t.Fatalf("pretty item omitted %s: %s", url, shown.stdout) + } + } + } + for _, tc := range []struct { + ref string + code int + }{ + {"1301645280", 2}, {"zzzzzzzzzzzz", 4}, + } { + r := h.run("", "item", "images", tc.ref) + if r.code != tc.code || r.stdout != "" { + t.Fatalf("images %s: exit %d, stdout %q, stderr %s", tc.ref, r.code, r.stdout, r.stderr) + } + } +} + +func TestItemImagesEmptyGallery(t *testing.T) { + h := newHarness(t) + it := h.fake.AddItem(fakewallapop.Item{Hash: "hashnoimages", Images: -1}) + for _, tc := range []struct{ format, want string }{ + {"json", "[]\n"}, {"jsonl", ""}, {"pretty", ""}, + } { + r := h.must("", "item", "images", it.Hash, "--format", tc.format) + if r.stdout != tc.want { + t.Fatalf("empty gallery in %s: got %q, want %q", tc.format, r.stdout, tc.want) + } + } +} + func TestItemShowByHashAndByURLAgree(t *testing.T) { h := newHarness(t) it := h.fake.AddItem(fakewallapop.Item{Hash: "hashbbbbbbbb", Title: "Trek Marlin", Price: 300, Reserved: true}) diff --git a/internal/cli/item.go b/internal/cli/item.go index 1ae1b05..5c56bd2 100644 --- a/internal/cli/item.go +++ b/internal/cli/item.go @@ -19,6 +19,16 @@ import ( "github.com/Microck/wallapop-cli/internal/wallapop" ) +// imageURLs keeps the machine formats as a simple array and pretty output +// suitable for copying or piping, without labels or shortened links. +type imageURLs []string + +func (urls imageURLs) Pretty(w io.Writer, color bool) { + for _, url := range urls { + fmt.Fprintln(w, url) + } +} + type itemView wallapop.Item func (v itemView) Pretty(w io.Writer, color bool) { @@ -58,6 +68,9 @@ func (v itemView) Pretty(w io.Writer, color bool) { rows = append(rows, []string{"modified", it.ModifiedAt.Local().Format(time.RFC3339)}) } rows = append(rows, []string{"url", it.URL}) + for i, url := range it.Images { + rows = append(rows, []string{fmt.Sprintf("image %d", i+1), url}) + } output.Table(w, color, nil, rows) if it.Description != "" { fmt.Fprintln(w) @@ -83,6 +96,26 @@ es.wallapop.com/item/... URL.`, return a.Printer.Print(itemView(it)) }, }, + &cobra.Command{ + Use: "images ITEM", Short: "Direct image links in gallery order", Args: cobra.ExactArgs(1), + Long: `Return direct image URLs for an item hash or full item URL. +JSON returns an array of URL strings; JSONL returns one JSON string per line. +Use --format pretty for one plain URL per line. This reads item metadata, +without downloading the images. + +Examples: + wallapop item images k2j3h4g5f6d7 --format pretty + wallapop item show k2j3h4g5f6d7 | jq -r '.images[]'`, + RunE: func(cmd *cobra.Command, args []string) error { + it, err := a.Client.Item(cmd.Context(), args[0]) + if err != nil { + return err + } + // A successful item with no images is [], never JSON null. + urls := append(imageURLs{}, it.Images...) + return a.Printer.Print(urls) + }, + }, &cobra.Command{ Use: "open ITEM", Short: "Open the listing in the browser", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/fakewallapop/fake.go b/internal/fakewallapop/fake.go index d394cc6..de683dc 100644 --- a/internal/fakewallapop/fake.go +++ b/internal/fakewallapop/fake.go @@ -34,7 +34,7 @@ type Item struct { Description string Price float64 Condition string - Images int + Images int // zero defaults to one image; negative means an empty gallery // Attrs are the listing's category attributes beyond the common four, // served under type_attributes and rewritten by edits. Attrs map[string]string @@ -393,7 +393,10 @@ func (s *Server) savedSearches(w http.ResponseWriter, r *http.Request) { } func fakeImages(hash string, n int) []map[string]any { - if n <= 0 { + if n < 0 { + return []map[string]any{} + } + if n == 0 { n = 1 } imgs := make([]map[string]any, 0, n)