Shopping and listings

vinted.com

Vinted - Catalog search listings (title, price, brand, size, condition, URL)

Notes an AI agent reads before it works on vinted.com: what to open, what to call, what breaks. Written by browser-harness contributors after real runs, and kept on GitHub under the MIT license.

Give it to your agent

Any coding agent

Start the task with this line. The Markdown version is the same notes without the page around them.

Read https://browser-use.com/skills/vinted.md, then <your task>

Browser Harness

Save the files into your workspace and turn domain skills on. The code below calls its helpers, such as http_get and js. What Browser Harness is.

d="${BH_AGENT_WORKSPACE:-$HOME/.config/browser-harness/agent-workspace}/domain-skills/vinted"
mkdir -p "$d" && cd "$d"
curl -sLO https://raw.githubusercontent.com/browser-use/browser-harness/main/agent-workspace/domain-skills/vinted/scraping.md
export BH_DOMAIN_SKILLS=1

Field-tested against www.vinted.com on 2026-10-31 using a Browser Use cloud browser (plain HTTP was also tried; results were inconsistent).

robots.txt (fetched 2026-10-31) for User-agent: * disallows /checkout, /util, /apipie/, /admin_alert/new, /member/ (except about 10 specific /member/<id> pages) and /inbox, and allows everything else; this skill only loads /catalog?search_text=... pages. The same group sets Content-Signal: ai-train=no, search=yes, ai-input=yes, and the file's comments forbid using content for model training, fine-tuning, dataset creation or embedding, so use this for live lookups, not to build datasets.

Quick summary

  • Plain HTTP is unreliable. On 2026-10-31 curl got 403 for /catalog?search_text=nike in one test and 200 with the same payload in another; Python urllib's default User-Agent got 403, and /api/v2/catalog/items was 403 every time. Use the browser.
  • The rows are server-rendered. /catalog?search_text=<q> is a Next.js App Router page. The search results ship inside the page's own self.__next_f.push(...) script tags as "items":{"items":[...]} followed by "pagination":{...}. The page does not call a catalog API after load, so the code reads those script tags instead of scraping tiles.
  • 96 rows per page, &page=N (the URL the site's own pager links to). pagination gives current_page, per_page, total_entries, total_pages.
  • Hard cap: 10 pages / 960 rows. nike, levis jeans, vintage dress, lego and handmade scarf all reported total_entries: 960, total_pages: 10. page=10 returned 96 rows; page=11 returned 0 rows and no pagination. When total_entries is 960 the code returns truncated_site_cap.
  • Brand, size and condition are not separate JSON fields. Each row has an itemBox.accessibilityLabel like Nike sports tank, Brand: Nike, Condition: Very good, Size: S / US 4-6, 4.00 $, 4.90 $. Brand and Size are omitted when the seller left them empty (81 of 384 sampled rows had no brand, 99 no size). The code parses that label and cross-checks it against the visible itemBox.secondLine (S / US 4-6 ยท Very good).
  • No login wall, cookie wall or challenge appeared in about 45 page loads at 3 s or more spacing.

What works

1. Browser, multi-page, with filters (main method)

import json, math, re, time, urllib.parse
 
BASE = "https://www.vinted.com"
SITE_CAP_TOTAL = 960   # total_entries never exceeded 960 (10 pages x 96); page 11 returns 0 rows
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
PHONE = re.compile(
    r"(?<![\w+])(?:\+[\s.-]?|00)[1-9](?:[\s.()-]{0,2}\d){6,14}(?!\d)"   # international: +44 20 7946 0958
    r"|(?<!\d)(?:1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}(?!\d)")  # North American: (555) 123-4567
# Label after the "<title>, " prefix. Brand and Size are left out when the seller did not set them.
LABEL = re.compile(r"(?:Brand: (?P<brand>.*?), )?Condition: (?P<condition>.*?), "
                   r"(?:Size: (?P<size>.*?), )?[\d.,]+ \$, [\d.,]+ \$")
 
EXTRACT = r"""(() => {
  if (document.querySelector('iframe[src*="captcha-delivery.com"]')) return {error: 'challenge on page'};
  if (document.readyState !== 'complete') return {pending: 'readyState ' + document.readyState};
  const P = 'self.__next_f.push(';
  const flight = [...document.scripts].map(s => s.textContent).filter(t => t.startsWith(P)).map(t => {
    try { const a = JSON.parse(t.slice(P.length, t.lastIndexOf(')'))); return typeof a[1] === 'string' ? a[1] : ''; }
    catch (e) { return ''; }
  }).join('');
  const key = '"items":{"items":[';
  const i = flight.indexOf(key);
  if (i < 0) return {pending: 'no catalog items in the page payload yet'};
  let j = i + key.length - 1, depth = 0, inStr = false, esc = false;   // find the matching ]
  for (; j < flight.length; j++) {
    const c = flight[j];
    if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; }
    if (c === '"') inStr = true; else if (c === '[' || c === '{') depth++;
    else if (c === ']' || c === '}') { if (--depth === 0) break; }
  }
  const items = JSON.parse(flight.slice(i + key.length - 1, j + 1));
  const pg = flight.slice(j + 1, j + 400).match(/^,"pagination":(\{[^}]*\})/);
  return {
    pagination: pg ? JSON.parse(pg[1]) : null,
    rows: items.map(x => { const p = x.productItem || {}, b = p.itemBox || {};
      return {id: p.id, title: p.title, label: b.accessibilityLabel, second_line: b.secondLine,
              price: p.price, discounted: p.priceWithDiscount, total: p.totalItemPrice, promoted: !!p.isPromoted}; }),
  };
})()"""
 
def redact(text):
    """Titles are seller-written free text: mask any email address or phone number in them."""
    return PHONE.sub("[phone]", EMAIL.sub("[email]", text))
 
def money(m):
    try:
        v = float((m or {}).get("amount") or 0)
    except ValueError:
        return None
    return v if math.isfinite(v) and v > 0 else None
 
def parse_row(r):
    if not isinstance(r["id"], int) or not r["title"] or money(r["price"]) is None:
        raise RuntimeError(f"row {r['id']!r} is missing id/title/price; refusing the page")
    prefix = r["title"] + ", "
    m = LABEL.fullmatch(r["label"][len(prefix):]) if (r["label"] or "").startswith(prefix) else None
    if not m:
        raise RuntimeError(f"row {r['id']}: unexpected label {r['label']!r}")
    size, condition = m["size"], m["condition"]
    if r["second_line"] != " ยท ".join(x for x in (size, condition) if x):   # what the tile shows
        raise RuntimeError(f"row {r['id']}: label and tile disagree: {r['label']!r} vs {r['second_line']!r}")
    return {
        "id": r["id"],
        "title": redact(r["title"]),
        "brand": m["brand"],
        "size": size,
        "condition": condition,
        "price": money(r["price"]),
        "discounted_price": money(r["discounted"]),
        "total_price": money(r["total"]),           # price + buyer protection fee, as the tile shows
        "currency": r["price"].get("currencyCode"),
        "promoted": r["promoted"],
        # ID-only permalink; it redirects to /items/<id>-<title-slug>, and the slug repeats the seller-written title.
        "url": f"{BASE}/items/{r['id']}",
    }
 
def same_url(a, b):
    """Same host, path and query (order and %-encoding ignored), so page N is never read as page N-1."""
    pa, pb = urllib.parse.urlparse(a), urllib.parse.urlparse(b)
    q = lambda p: sorted(urllib.parse.parse_qsl(p.query, keep_blank_values=True))
    return (pa.netloc, urllib.parse.unquote(pa.path), q(pa)) == (pb.netloc, urllib.parse.unquote(pb.path), q(pb))
 
_tab = {"open": False}
 
def open_page(url, settle_s=25):
    u = urllib.parse.urlparse(url)
    if u.scheme != "https" or u.netloc != "www.vinted.com" or u.path != "/catalog":
        raise ValueError(f"refusing {url}: only https://www.vinted.com/catalog?... pages are used")
    goto_url(url) if _tab["open"] else new_tab(url)
    _tab["open"] = True
    wait_for_load()
    deadline, href, data = time.monotonic() + settle_s, None, None
    while time.monotonic() < deadline:   # one deadline for the URL and the payload to settle
        href = js("location.href") or ""
        if same_url(href, url):
            data = js(EXTRACT)
            if data and data.get("error"):
                raise RuntimeError(f"{data['error']} on {href}: stop, do not retry or change the request")
            if data and "rows" in data:
                return data
        time.sleep(0.5)
    raise RuntimeError(f"page did not settle on {url}: href={href} state={data and data.get('pending')}")
 
def search(query, filters=None, max_pages=3, pause_s=3.0):
    """Returns (rows, status). status is "complete", "truncated_max_pages" (more pages exist)
    or "truncated_site_cap" (Vinted stops at 10 pages / 960 rows; treat as a hard stop)."""
    seen, out = set(), []
    for page in range(1, max_pages + 1):
        q = [("search_text", query)] + list((filters or {}).items()) + ([("page", page)] if page > 1 else [])
        data = open_page(f"{BASE}/catalog?" + urllib.parse.urlencode(q, doseq=True))
        pg = data["pagination"] or {}
        rows = [parse_row(r) for r in data["rows"]]
        if not rows and pg.get("total_entries"):
            raise RuntimeError(f"page {page} has 0 rows but total_entries={pg.get('total_entries')}")
        new = [r for r in rows if r["id"] not in seen]
        seen.update(r["id"] for r in new)
        out.extend(new)
        print(f"page {page}: {len(rows)} rows, {len(new)} new, total_entries={pg.get('total_entries')}, "
              f"total_pages={pg.get('total_pages')}")
        if page >= (pg.get("total_pages") or 0):
            return out, ("truncated_site_cap" if (pg.get("total_entries") or 0) >= SITE_CAP_TOTAL else "complete")
        if page == max_pages:
            return out, "truncated_max_pages"
        time.sleep(pause_s)
 
listings, status = search("nike", max_pages=3)
print(len(listings), "listings, status:", status)
for r in listings[:2]:
    print(json.dumps(r, ensure_ascii=False))
 
time.sleep(3)   # filters exactly as the site's own filter UI writes them into the URL
listings, status = search("levis jeans", {"order": "newest_first", "status_ids[]": "6",
                                          "price_to": "30", "currency": "USD"}, max_pages=1)
print(len(listings), "listings, status:", status,
      "| conditions:", sorted({r["condition"] for r in listings}),
      "| max price:", max(r["price"] for r in listings))
 
time.sleep(3)
listings, status = search("vintage dress", max_pages=1)
no_brand = [r for r in listings if r["brand"] is None]
print(len(listings), "listings,", len(no_brand), "without a brand")
print(json.dumps(no_brand[0] if no_brand else None, ensure_ascii=False))
 
time.sleep(3)
empty, status = search("zzqxqvnotathing")
print(len(empty), "listings for a nonsense query, status:", status)
 
try:   # robots.txt disallows /member/: refused before the tab navigates
    open_page("https://www.vinted.com/member/12345")
except ValueError as e:
    print("refused:", e)

Real output (trimmed):

page 1: 96 rows, 96 new, total_entries=960, total_pages=10
page 2: 96 rows, 89 new, total_entries=960, total_pages=10
page 3: 96 rows, 84 new, total_entries=960, total_pages=10
269 listings, status: truncated_max_pages
{"id": 10611858188, "title": "Nike Zip", "brand": "Nike", "size": "M / US 8-10", "condition": "Good", "price": 4.0, "discounted_price": null, "total_price": 4.9, "currency": "USD", "promoted": false, "url": "https://www.vinted.com/items/10611858188"}
{"id": 10611815068, "title": "Nike Dri-Fit Tshirt", "brand": "Nike", "size": "M", "condition": "Very good", "price": 5.0, "discounted_price": null, "total_price": 5.95, "currency": "USD", "promoted": true, "url": "https://www.vinted.com/items/10611815068"}
page 1: 96 rows, 96 new, total_entries=960, total_pages=10
96 listings, status: truncated_max_pages | conditions: ['New with tags'] | max price: 30.0
page 1: 96 rows, 96 new, total_entries=960, total_pages=10
96 listings, 23 without a brand
{"id": 10610445929, "title": "True vintage handmade dress", "brand": null, "size": "XXS / US 0", "condition": "Very good", "price": 10.0, "discounted_price": null, "total_price": 11.2, "currency": "USD", "promoted": false, "url": "https://www.vinted.com/items/10610445929"}
page 1: 0 rows, 0 new, total_entries=0, total_pages=1
0 listings for a nonsense query, status: complete
refused: refusing https://www.vinted.com/member/12345: only https://www.vinted.com/catalog?... pages are used

truncated_max_pages is expected for nike: max_pages=3 stopped while 7 more pages existed.

URL patterns and filters (all observed)

PatternResult
/catalog?search_text=<q>200 in the browser, 96 rows, pagination.current_page: 1
&page=Npage N (the pager's own links). page=10 had 96 rows, page=11 0 rows and no pagination
&order=Sort by dropdown values: relevance, price_high_to_low, price_low_to_high, newest_first
&status_ids[]=Condition filter: 6 New with tags, 1 New without tags, 2 Very good, 3 Good, 4 Satisfactory. With status_ids[]=6 all 96 rows were "New with tags"
&price_from= / &price_to= + &currency=USDPrice filter; the UI adds currency=USD. With price_to=30 no row was above $30
&time=<unix seconds>Added by the UI when a filter or sort changes; not needed for direct loads
/catalog?search_text=<nonsense>0 rows, total_entries: 0, total_pages: 1 (not an error page)
/items/<id>redirects to /items/<id>-<title-slug>

Size, Brand, Color, Material and Category filters also exist in the UI (catalog--size-filter--trigger, etc.); pick them in the UI and copy the URL rather than guessing their ids.

Fields

Output fieldSource in each items.items[i].productItemNotes
ididinteger, also the number in the item URL
titletitleseller-written; email addresses and phone numbers are replaced with [email] / [phone]
branditemBox.accessibilityLabel Brand: ...None when the seller set no brand (then itemBox.firstLine is the title, not a brand, so do not read brand from it)
sizelabel Size: ...e.g. S / US 4-6, 24M | 2T/2, Other; None for bags, toys and other unsized items
conditionlabel Condition: ...New with tags, New without tags, Very good, Good, Satisfactory
priceprice.amount (string)item price the seller set
discounted_pricepriceWithDiscount.amountnull on all 384 sampled rows
total_pricetotalItemPrice.amountprice plus buyer protection fee (the tile's "incl." price)
currencyprice.currencyCodeUSD on www.vinted.com
promotedisPromoted87 of 384 sampled rows were promoted ("bumped") listings mixed into the results
urlbuilt from id: https://www.vinted.com/items/<id>redirects to the canonical slug URL; the slug repeats the seller-written title, so it is left out
(page) total_entries, total_pages"pagination" right after the items arraycapped at 960 / 10

Each row also carries user.id (the seller), photos and a search score. The code does not copy the seller id; leave it out unless you have a reason and a basis to process personal data.

Gotchas

  • window.__next_f is empty after hydration (its .map returned nothing); read the self.__next_f.push(...) script tags instead, as EXTRACT does.
  • The label format is English and $-specific; this skill covers www.vinted.com only. Other Vinted domains (vinted.fr, vinted.co.uk, ...) have their own robots.txt, language and currency and were not tested.
  • Every query tried reported total_entries: 960, so 960 means "960 or more", not an exact count. Do not split or reshape the query to get past the cap; report the result as truncated.
  • Pages overlap. With the default sort, consecutive nike pages shared 7 to 12 ids (one check: 11 shared between pages 1 and 2, only 2 of them promoted), so new listings and reshuffling push rows forward. The code de-duplicates by id, which is why 3 pages gave 269 rows (264 on a re-run), not 288.
  • Promoted ("bumped") rows sit inside the normal result order (28 of 96 on each of two nike pages); filter on promoted if you want organic rows only.
  • EXTRACT returns error on a DataDome challenge frame (captcha-delivery.com); the code raises and you should stop and hand it to a human, not retry or change anything to get past it. None appeared during testing at 3 s between pages.
  • The code refuses any URL other than https://www.vinted.com/catalog?... before navigating, refuses a page whose URL differs from the one requested, and refuses a row whose label and tile text disagree.

What does not work

  • Plain HTTP: /api/v2/catalog/items and urllib on /catalog got 403; curl on /catalog was 403 or 200 depending on the run, so it is not a dependable path.
  • Pages past 10: 0 rows.
  • /member/ profile pages are disallowed by robots.txt for User-agent: * and were not requested.

scraping.md ยท updated Oct 31, 2026 ยท 2,177 words

Sites change. These notes describe what worked when they were last updated, not a guarantee they still do. If something broke, the fix is a pull request to browser-harness.