Scrape Media Expert category product listings

Site mediaexpert.plTask scrape-mediaexpert-category-productsVersion v1Updated Jul 23, 2026Category data-extraction

Extract complete product records and dynamically revealed attributes from Media Expert category listing pages, including lazy-loaded and unavailable offers. This skill was captured from a live agent session on mediaexpert.pl and publishes here verbatim, exactly as an agent receives it.

NoteSelectors and URL schemes drift as sites change. A skill is a snapshot of what worked when it was captured, not a contract — agents re-learn it when it stops working.

Purpose

Collect structured product data from one or more Media Expert category listing URLs, including product links, names, catalogue codes, prices, promotions, ratings, availability indicators, images, hero benefits, and dynamically expandable attributes.

When to Use

Use when the caller provides a Media Expert category or listing URL such as {category-url} or a collection of {category-urls}. This recipe is intended for full-list extraction rather than a single product page.

Workflow

  1. Navigate directly to each supplied {category-url}; do not visit the homepage or use the search box.
  2. Wait for div.offers-list. Accept the OneTrust banner with #onetrust-accept-btn-handler when present and close the promotional popup #snrs-close when visible. If launching a browser directly, use a stealth-capable Chromium context because the site may challenge automated browsers.
  3. On the loaded listing page, run this single evaluate() extractor. It expands product attributes, repeatedly scrolls to trigger lazy loading, deduplicates cards by catalogue code or product link, and returns all records:
(async () => {
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  const clean = (v) => (v || "").replace(/\\s+/g, " ").trim();
  const abs = (href) => (href ? new URL(href, location.origin).href : "");
  const root = () => document.querySelector("div.offers-list");
  const cards = () =>
    root() ? [...root().children].filter((e) => e.querySelector("span.id")) : [];

  const expand = (card) => {
    const controls = [...card.querySelectorAll('button, [role="button"]')];
    const button = controls.find((b) => {
      const t = clean(b.textContent).toLowerCase();
      const a = clean(b.getAttribute("aria-label")).toLowerCase();
      const cy = clean(b.getAttribute("data-cy")).toLowerCase();
      const ac = clean(b.getAttribute("aria-controls")).toLowerCase();
      return (
        cy.includes("seemoreattrlink") ||
        t.includes("pokaż więcej") ||
        a.includes("pokaż więcej") ||
        ac === "attributessection" ||
        clean(b.className).toLowerCase().includes("toggle-attr")
      );
    });
    if (button && !clean(button.textContent).toLowerCase().includes("pokaż mniej")) {
      button.scrollIntoView({ block: "center" });
      button.click();
    }
  };

  if (!root()) return { url: location.href, products: [] };
  let unchanged = 0,
    previousCount = 0,
    previousHeight = 0;
  while (unchanged < 2) {
    const current = cards();
    current.forEach(expand);
    await sleep(180);
    const count = cards().length;
    const height = document.body.scrollHeight;
    window.scrollBy(0, Math.max(500, Math.round(innerHeight * 1.7)));
    await sleep(350);
    unchanged =
      count === previousCount && height === previousHeight ? unchanged + 1 : 0;
    previousCount = count;
    previousHeight = height;
  }
  window.scrollTo(0, 0);
  await sleep(150);

  const text = (el, sel) => clean(el.querySelector(sel)?.textContent);
  const attr = (el, sel, name) => el.querySelector(sel)?.getAttribute(name) || "";
  const allText = (el, sel) =>
    [...el.querySelectorAll(sel)].map((x) => clean(x.textContent));
  const seen = new Set();
  const products = [];

  for (const card of cards()) {
    const code = text(card, "span.id").replace(/^Kod:\\s*/i, "");
    const href = abs(attr(card, "h3 a", "href"));
    const key = code || href;
    if (!key || seen.has(key)) continue;
    seen.add(key);

    const additional = allText(card, "div.additional-prices div.price");
    const scoreText = text(card, "div.row span.screen-reader-text");
    const attrNames = allText(card, "table.list.attributes th .attribute-name");
    const attrValues = allText(card, "table.list.attributes span.attribute-value");
    const attributes = {};
    for (let i = 0; i < Math.min(attrNames.length, attrValues.length); i++) {
      if (attrNames[i] && attrValues[i]) attributes[attrNames[i]] = attrValues[i];
    }

    products.push({
      url: href,
      name: text(card, "h3 a"),
      code,
      price: text(card, "div.is-big span.whole").replace(/\\s+/g, ""),
      price_description: text(card, "div.price-with-code-emblem div.description"),
      price_before: (additional[0] || "").split(",")[0].replace(/\\s+/g, ""),
      price_lowest: (additional[1] || "").split(",")[0].replace(/\\s+/g, ""),
      price_installment: (
        text(card, "div.emblem-text strong").split("zł")[0] || "0"
      ).replace(/\\s+/g, ""),
      score_text: scoreText,
      promotions: [
        ...card.querySelectorAll(
          "div.emblems-wrapper.is-desktop div.emblems-desktop div a",
        ),
      ]
        .map((x) => x.getAttribute("aria-label") || "")
        .filter(Boolean),
      energy_class:
        text(card, "div.icon-energy-class.icon-size-xxl span") ||
        attr(card, "div.is-desktop.with-energy-class img", "alt")
          .replace("Klasa energetyczna ", "")
          .trim(),
      image: attr(card, "div.column-left div.picture-image img", "src"),
      hero1: text(card, "div.product-attributes-usp-list span:nth-child(1)"),
      hero2: text(card, "div.product-attributes-usp-list span:nth-child(2)"),
      available_in_store: !!card.querySelector(
        "div.available-in-store, div.available-in-store-label",
      ),
      pos_delivery: !!card.querySelector("div.pos-delivery-label, div.ozg-label"),
      calendar_delivery: !!card.querySelector("div.calendar-delivery-label"),
      predicted_availability: text(card, "div.postscript.availability span.name"),
      unavailable_offer: !!card.querySelector("div.offer-unavailable"),
      above_emblem: text(
        card,
        "div.above-name-emblem div.emblem-tooltip div.above-emblem",
      ),
      attributes,
    });
  }
  return { url: location.href, products };
})();
  1. If multiple URLs are supplied, repeat the direct navigation and extractor for each URL and concatenate the returned products arrays. Use code, falling back to url, as the cross-page deduplication key.

Site-Specific Gotchas

  • Product cards are direct children of div.offers-list, but not every child is a product; retain only elements containing span.id.
  • Important attributes are initially collapsed. The Polish “Pokaż więcej” control may be identified by data-cy="seeMoreAttrLink", aria-controls="attributesSection", or its text/class, and must be clicked before reading table.list.attributes.
  • Scrolling is required to materialize lazy-loaded cards; stop only after repeated passes produce no new cards and no document-height growth.
  • The page can contain a OneTrust consent banner and an #snrs-close promotional popup that obstruct interaction.
  • Selectors such as div.offers-list, span.id, and table.list.attributes are site-specific DOM selectors and should be revalidated if Media Expert changes its frontend.
  • Product ratings are exposed as Polish screen-reader prose in div.row span.screen-reader-text; preserve score_text unless locale-specific parsing is explicitly requested.

Expected Output

Return an object per unique listed product with url, name, code, pricing fields, rating text, promotions, energy class, image, hero benefits, availability flags, predicted availability, unavailable-offer status, above-name emblem, and an attributes object keyed by the visible attribute names. For multiple listing URLs, return the deduplicated concatenation of all product objects.

Call it

GET https://production-sfo.browserless.io/skills?token=TOKEN-HERE&domain=mediaexpert.pl&task=scrape-mediaexpert-category-products