Skill: salamankakit/gumtree-search-uk (version 1.0.0)

--- name: gumtree-bb-browser version: 1.0.0 description: >- Search [Gumtree UK](https://www.gumtree.com/) from the terminal with bb-browser: property and flats to buy or rent, general second-hand deals, pets, used cars, and more local classifieds—structured JSON plus first-image Markdown for assistants. Includes gumtree/search and gumtree/listing adapters; requires bb-browser. author: SalamankaKit tags: - gumtree - uk - classifieds - property - rent - flatshare - motors - pets - second-hand - bb-browser - local-ads --- # Gumtree UK + bb-browser Use this skill when you need **UK local classifieds** as data an agent can reason over: **homes for sale**, **rentals and flatshares**, **second-hand goods**, **pets**, **used cars**, and other Gumtree categories—without manual copy-paste from the site. ## What this helps with - **Property (buy)**: search sale listings; open a listing for full text, price, location, and all photos. - **Renting / flatshare**: same flow for rental and shared-accommodation style listings on Gumtree. - **Second-hand & general trade**: furniture, phones, bikes, and other “for sale” items—compare results using titles, prices, snippets, and thumbnails. - **Pets**: find or inspect pet listings (always follow platform rules and local animal-welfare laws). - **Used cars & motors**: browse motors listings; pull detail pages for specs in the description and image sets. All of the above use the same two commands: **`gumtree/search`** then **`gumtree/listing`** for any result URL. ## Prerequisites - [bb-browser](https://www.npmjs.com/package/bb-browser) installed globally (`npm i -g bb-browser`). - Adapter files: `bb-sites/gumtree/search.js` and `bb-sites/gumtree/listing.js`. Install adapters (private site overrides the community bundle): ```bash mkdir -p ~/.bb-browser/sites/gumtree cp bb-sites/gumtree/search.js ~/.bb-browser/sites/gumtree/search.js cp bb-sites/gumtree/listing.js ~/.bb-browser/sites/gumtree/listing.js ``` After you change adapter files, copy again or use symlinks. --- ## 1) Search: `gumtree/search` Each hit includes: - `firstImageUrl` — absolute URL for the lead image (same role as `image` when present) - `firstImageMarkdown` — ready-to-paste Markdown image line for chat UIs that render Markdown - `url`, `title`, `price`, `location`, `snippet` ```bash bb-browser site gumtree/search "2 bed flat Manchester" --json bb-browser site gumtree/search "VW Golf" --json bb-browser site gumtree/search "sofa" --json ``` **Comparing multiple results in chat**: paste `firstImageMarkdown` per row (or `![title](firstImageUrl)`). If remote images are blocked, fall back to showing `firstImageUrl` as a link. **Positional arguments** (order follows bb-browser field order): 1. `query` (required) 2. `location` — default `United Kingdom` 3. `page` — default `1` 4. `category` — Gumtree `search_category`, default `all` (e.g. `cars-vans-motorbikes`; for property, pets, or other verticals use the slug from Gumtree’s category URLs / filters) ```bash bb-browser site gumtree/search sofa Manchester --json bb-browser site info gumtree/search ``` Note: unknown `--flags` are dropped by bb-browser’s global parser—use **positionals**, not `--page 2`. --- ## 2) Listing detail: `gumtree/listing` Fetches the listing page: full **`description`** (JSON-LD `Product` when available, else `og:description`), plus **all image URLs** and a **lead image**. ```bash bb-browser site gumtree/listing "https://www.gumtree.com/p/..." # Path-only input is OK (domain is filled in): bb-browser site gumtree/listing /p/coffee-table/some-slug/1512630746 ``` Typical JSON fields: - `description` — body text - `title`, `price`, `location` - `firstImageUrl`, `imageUrls`, `firstImageMarkdown` - Removed listings return `error` and `hint` ```bash bb-browser site info gumtree/listing ``` --- ## When Gumtree HTML / JSON-LD changes - **Search**: update selectors in `bb-sites/gumtree/search.js` (e.g. `article[data-q="search-result"]`). - **Listing**: keep JSON-LD `Product` parsing first; fall back to `og:description` / `og:image`. ## Logged-in-only flows Contacting sellers or posting ads still requires `bb-browser open` and normal browser interaction. ## Contributing upstream Follow the [bb-sites](https://github.com/epiral/bb-sites) pattern: one JS file per command with `/* @meta */`. ## Compliance Respect [Gumtree](https://www.gumtree.com/) terms, robots.txt, and polite request rates.

path: bb-sites/gumtree/listing.js
content: /* @meta
{
  "name": "gumtree/listing",
  "description": "Fetch a Gumtree UK listing detail page: full description, price, location, first image and all image URLs (JSON-LD + fallbacks)",
  "domain": "www.gumtree.com",
  "args": {
    "url": {"required": true, "description": "Full listing URL or path, e.g. /p/.../123456 or https://www.gumtree.com/p/..."}
  },
  "readOnly": true,
  "example": "bb-browser site gumtree/listing /p/coffee-table/item/1512630746"
}
*/

async (args) => {
  const absolutize = (u) => {
    if (!u || typeof u !== 'string') return '';
    const t = u.trim();
    if (t.startsWith('http')) return t;
    if (t.startsWith('//')) return 'https:' + t;
    if (t.startsWith('/')) return 'https://www.gumtree.com' + t;
    return 'https://www.gumtree.com/' + t;
  };

  const productFromJsonLd = (d) => {
    if (!d || typeof d !== 'object') return null;
    if (d['@type'] === 'Product' && d.name) return d;
    if (Array.isArray(d)) {
      for (const n of d) {
        const p = productFromJsonLd(n);
        if (p) return p;
      }
    }
    if (Array.isArray(d['@graph'])) {
      for (const n of d['@graph']) {
        if (n && n['@type'] === 'Product' && n.name) return n;
      }
    }
    if (d['@graph'] && d['@graph']['@type'] === 'Product') return d['@graph'];
    return null;
  };

  const normalizeImages = (imageField) => {
    if (!imageField) return [];
    if (typeof imageField === 'string') return [absolutize(imageField)];
    if (Array.isArray(imageField)) return imageField.map(absolutize).filter(Boolean);
    if (imageField.url) return [absolutize(imageField.url)];
    return [];
  };

  const parseMetaProperty = (html, prop) => {
    const m = html.match(new RegExp('<meta\\s+property="' + prop + '"\\s+content="([^"]*)"', 'i'));
    if (!m) return '';
    return m[1]
      .replace(/&amp;/g, '&')
      .replace(/&quot;/g, '"')
      .replace(/&#39;/g, "'")
      .replace(/&lt;/g, '<')
      .replace(/&gt;/g, '>');
  };

  if (!args.url) return { error: 'Missing argument: url', hint: 'e.g. bb-browser site gumtree/listing "https://www.gumtree.com/p/.../ID"' };

  let path = String(args.url).trim();
  if (!path.startsWith('http')) {
    if (!path.startsWith('/')) path = '/' + path;
    path = 'https://www.gumtree.com' + path;
  }

  const resp = await fetch(path, {
    headers: {
      'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
      Accept: 'text/html,application/xhtml+xml',
      'Accept-Language': 'en-GB,en;q=0.9',
    },
  });
  if (!resp.ok) return { error: 'HTTP ' + resp.status, url: path };

  const finalUrl = resp.url || path;
  const html = await resp.text();

  if (/\/for-sale\//.test(finalUrl) && (html.includes('adRemoved') || finalUrl.includes('adRemoved='))) {
    return { error: 'Listing not available (removed or category redirect)', url: finalUrl, hint: 'Try a fresh /p/... URL from gumtree/search' };
  }

  let title = '';
  let description = '';
  let firstImage = '';
  let images = [];
  let price = '';
  let priceCurrency = 'GBP';
  let location = '';

  const doc = new DOMParser().parseFromString(html, 'text/html');
  const scripts = doc.querySelectorAll('script[type="application/ld+json"]');
  for (const s of scripts) {
    const raw = (s.textContent || '').trim();
    if (!raw) continue;
    try {
      const d = JSON.parse(raw);
      const p = productFromJsonLd(d);
      if (p) {
        title = (p.name || '').trim();
        description = (p.description || '').trim().replace(/\s+/g, ' ');
        images = normalizeImages(p.image);
        firstImage = images[0] || '';
        const o = p.offers;
        if (o) {
          if (typeof o.price === 'string' || typeof o.price === 'number') {
            price = o.price === 0 || o.price === '0' ? 'FREE' : String(o.price);
          }
          if (o.priceCurrency) priceCurrency = String(o.priceCurrency);
          const av = o.availableAtOrFrom;
          if (av && typeof av === 'string') location = av;
        }
        break;
      }
    } catch (e) {
      /* try next */
    }
  }

  if (!description) {
    const ogd = doc.querySelector('meta[property="og:description"]');
    if (ogd) description = (ogd.getAttribute('content') || '').trim();
  }
  if (!description) description = parseMetaProperty(html, 'og:description');

  if (!title) {
    const ogp = doc.querySelector('meta[property="og:title"]');
    if (ogp) title = (ogp.getAttribute('content') || '').split('|')[0].trim();
  }

  if (images.length === 0) {
    const ogi = doc.querySelector('meta[property="og:image"]');
    if (ogi) {
      firstImage = absolutize(ogi.getAttribute('content') || '');
      if (firstImage) images = [firstImage];
    }
  } else {
    firstImage = images[0];
  }

  const firstImageMarkdown = firstImage && title
    ? '![' + title.replace(/[[\]]/g, '').slice(0, 80) + '](' + firstImage + ')'
    : (firstImage ? '![](' + firstImage + ')' : undefined);

  return {
    url: finalUrl,
    title: title || undefined,
    description: description || undefined,
    price: price || undefined,
    priceCurrency,
    location: location || undefined,
    firstImageUrl: firstImage || undefined,
    imageUrls: images,
    countImages: images.length,
    firstImageMarkdown: firstImageMarkdown || undefined,
  };
}


path: bb-sites/gumtree/search.js
content: /* @meta
{
  "name": "gumtree/search",
  "description": "Search Gumtree UK classifieds (titles, prices, first image URL, locations, listing URLs) via public search HTML",
  "domain": "www.gumtree.com",
  "args": {
    "query": {"required": true, "description": "Search keywords (e.g. sofa, iPhone, VW Golf)"},
    "location": {"required": false, "description": "Location or postcode (default: United Kingdom)"},
    "page": {"required": false, "description": "Results page number (default: 1)"},
    "category": {"required": false, "description": "search_category (default: all; e.g. cars-vans-motorbikes)"}
  },
  "readOnly": true,
  "example": "bb-browser site gumtree/search \"sofa\""
}
*/

async (args) => {
  const absolutize = (u) => {
    if (!u || typeof u !== 'string') return '';
    const t = u.trim();
    if (t.startsWith('http')) return t;
    if (t.startsWith('//')) return 'https:' + t;
    if (t.startsWith('/')) return 'https://www.gumtree.com' + t;
    return t;
  };

  if (!args.query) return {error: 'Missing argument: query', hint: 'e.g. bb-browser site gumtree/search "sofa"'};

  const location = args.location || args.search_location || 'United Kingdom';
  const category = args.category || args.search_category || 'all';
  const page = Math.max(1, parseInt(String(args.page || '1'), 10) || 1);

  const params = new URLSearchParams({
    q: String(args.query),
    search_category: String(category),
    search_location: String(location),
  });
  if (page > 1) params.set('page', String(page));

  const url = 'https://www.gumtree.com/search?' + params.toString();
  const resp = await fetch(url, {
    headers: {
      'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
      Accept: 'text/html,application/xhtml+xml',
      'Accept-Language': 'en-GB,en;q=0.9',
    },
  });
  if (!resp.ok) return {error: 'HTTP ' + resp.status, url};

  const html = await resp.text();
  const parser = new DOMParser();
  const doc = parser.parseFromString(html, 'text/html');

  const results = [];
  doc.querySelectorAll('article[data-q="search-result"]').forEach((article) => {
    const a = article.querySelector('a[data-q="search-result-anchor"]');
    if (!a) return;
    const path = a.getAttribute('href') || '';
    const listingUrl = path.startsWith('http') ? path : 'https://www.gumtree.com' + path;

    const img = a.querySelector('figure img[alt], img.lazyload[alt], img[alt]');
    const coverAlt = img ? (img.getAttribute('alt') || '').trim() : '';

    let snippet = '';
    a.querySelectorAll('p').forEach((p) => {
      const t = (p.textContent || '').trim().replace(/\s+/g, ' ');
      if (t.length > snippet.length) snippet = t;
    });

    const priceEl = a.querySelector('[data-q="tile-price"]');
    const price = priceEl ? priceEl.textContent.trim() : '';

    const locEl = a.querySelector('[data-q="tile-location"]');
    const loc = locEl ? locEl.textContent.trim() : '';

    const rawSrc = img ? (img.getAttribute('src') || img.getAttribute('data-src') || img.getAttribute('data-lazy') || '') : '';
    const firstImageUrl = absolutize(rawSrc);
    const displayTitle = (coverAlt || snippet.slice(0, 120) || listingUrl).toString();
    const safeAlt = displayTitle.replace(/[[\]]/g, '').slice(0, 100);
    const firstImageMarkdown = firstImageUrl
      ? '![' + safeAlt + '](' + firstImageUrl + ')'
      : undefined;

    results.push({
      title: displayTitle,
      price,
      location: loc,
      url: listingUrl,
      snippet: snippet || undefined,
      firstImageUrl: firstImageUrl || undefined,
      image: firstImageUrl || undefined,
      firstImageMarkdown: firstImageMarkdown || undefined,
    });
  });

  return {
    query: args.query,
    location,
    category,
    page,
    url,
    count: results.length,
    results,
  };
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)