time(); } /** * Queue a background refresh for a stale URL. * * WP-Cron fires on a later request, so the visitor who found the stale entry is * never the one who waits for the network. * * @param string $url The URL to refresh * @return void */ function fwembed_schedule_refresh($url) { if (!function_exists('wp_next_scheduled') || !function_exists('wp_schedule_single_event')) { return; } // Collapses a stampede: many visitors hitting the same stale entry queue // one job between them. if (wp_next_scheduled('fwembed_refresh_url', array($url))) { return; } wp_schedule_single_event(time() + 1, 'fwembed_refresh_url', array($url)); } /** * WP-Cron handler: re-fetch a URL and replace its cache entry. * * @param string $url The URL to refresh * @return void */ function fwembed_do_refresh($url) { $lock_key = fwembed_cache_key($url) . '_lock'; // Guards against two overlapping cron runs fetching the same URL. if (get_transient($lock_key) !== false) { return; } set_transient($lock_key, 1, 2 * MINUTE_IN_SECONDS); $result = fwembed_fetch($url); if (!$result['error'] && $result['status_code'] == 200) { fwembed_cache_write($url, $result); } delete_transient($lock_key); } add_action('fwembed_refresh_url', 'fwembed_do_refresh'); /** * Build a configured cURL handle. * * @param string $url The URL to fetch * @return resource|CurlHandle */ function fwembed_curl_handle($url) { // More complete browser-like headers $headers = array( 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language: en-US,en;q=0.5', 'Connection: keep-alive', 'Upgrade-Insecure-Requests: 1', 'Cache-Control: max-age=0' ); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_ENCODING, ""); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0'); curl_setopt($ch, CURLOPT_TIMEOUT, 30); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); return $ch; } /** * Turn a finished cURL handle into a response array. * * @param resource|CurlHandle $ch The handle * @param string|bool $content The transferred body * @return array Response with 'content', 'error' and 'status_code' */ function fwembed_curl_result($ch, $content) { $error = null; $status_code = 0; if (curl_errno($ch)) { $error = curl_error($ch); } else { $status_code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($status_code == 403) { $error = "Access forbidden (403). The website may be blocking automated requests."; } elseif ($status_code >= 400) { $error = "Request failed with HTTP status " . $status_code . "."; } } return array( 'content' => $content, 'error' => $error, 'status_code' => $status_code, ); } /** * Fetch a single URL, bypassing the cache. * * @param string $url The URL to fetch * @return array Response array */ function fwembed_fetch($url) { $ch = fwembed_curl_handle($url); $content = curl_exec($ch); $result = fwembed_curl_result($ch, $content); curl_close($ch); return $result; } /** * Fetch several URLs concurrently, bypassing the cache. * * @param array $urls URLs to fetch * @param int $concurrency Maximum simultaneous connections * @return array Map of URL => response array */ function fwembed_fetch_many($urls, $concurrency = 8) { $urls = array_values(array_unique($urls)); $results = array(); if (empty($urls)) { return $results; } if (count($urls) === 1 || !function_exists('curl_multi_init')) { foreach ($urls as $url) { $results[$url] = fwembed_fetch($url); } return $results; } foreach (array_chunk($urls, max(1, (int) $concurrency)) as $batch) { $multi = curl_multi_init(); $handles = array(); foreach ($batch as $url) { $ch = fwembed_curl_handle($url); curl_multi_add_handle($multi, $ch); $handles[$url] = $ch; } $running = null; do { $status = curl_multi_exec($multi, $running); if ($running) { curl_multi_select($multi, 1.0); } } while ($running && $status === CURLM_OK); foreach ($handles as $url => $ch) { $results[$url] = fwembed_curl_result($ch, curl_multi_getcontent($ch)); curl_multi_remove_handle($multi, $ch); curl_close($ch); } curl_multi_close($multi); } return $results; } /** * Common function to make HTTP requests to Fourthwall * * Serves cached content immediately whenever any is stored. A stale entry is * returned as-is and refreshed by WP-Cron, so expiry never lands the cost of a * network round trip on a visitor. * * @param string $url The URL to fetch * @return array Array containing 'content' and 'error' keys */ function fwembed_make_request($url) { $cached = fwembed_cache_read($url); if ($cached !== null) { if (!fwembed_cache_is_fresh($cached)) { fwembed_schedule_refresh($url); } return $cached; } $result = fwembed_fetch($url); if (!$result['error'] && $result['status_code'] == 200) { $result = fwembed_cache_write($url, $result); } return $result; } /** * Make several requests at once, using the cache where possible. * * Anything not already cached is fetched in parallel, turning N sequential * round trips into one batch. * * @param array $urls URLs to fetch * @return array Map of URL => response array */ function fwembed_make_requests($urls) { $results = array(); $pending = array(); foreach (array_unique($urls) as $url) { $cached = fwembed_cache_read($url); if ($cached !== null) { if (!fwembed_cache_is_fresh($cached)) { fwembed_schedule_refresh($url); } $results[$url] = $cached; continue; } $pending[] = $url; } foreach (fwembed_fetch_many($pending) as $url => $result) { if (!$result['error'] && $result['status_code'] == 200) { $result = fwembed_cache_write($url, $result); } $results[$url] = $result; } return $results; } /** * Build an XPath predicate matching a single CSS class name. * * Fourthwall reorders and appends classes between releases, so never match * @class exactly - pad the attribute and look for the padded class name. * * @param string $class The class name to match * @return string XPath predicate fragment */ function fwembed_class_predicate($class) { return 'contains(concat(" ", normalize-space(@class), " "), " ' . $class . ' ")'; } /** * Run XPath queries in priority order and return the first one that matches. * * Lets each selector degrade to a looser fallback when Fourthwall changes * their markup, instead of the whole product silently disappearing. * * @param DOMXPath $xpath The XPath engine * @param array $queries XPath expressions, most specific first * @param DOMNode|null $context Optional context node for relative queries * @return DOMNodeList|null First non-empty result, or null if none matched */ function fwembed_query_first(DOMXPath $xpath, array $queries, $context = null) { foreach ($queries as $query) { $nodes = $context === null ? $xpath->query($query) : $xpath->query($query, $context); if ($nodes !== false && $nodes->length > 0) { return $nodes; } } return null; } /** * Resolve a possibly-relative href against the page it was found on. * * Product hrefs are root-relative ("/products/foo"), so naive concatenation * breaks whenever the store URL has a trailing slash or a path such as * /collections/all. * * @param string $href The href to resolve * @param string $page_url The URL of the page the href was found on * @return string Absolute URL */ function fwembed_resolve_url($href, $page_url) { $href = trim($href); if ($href === '') { return ''; } if (preg_match('#^https?://#i', $href)) { return $href; } $parts = parse_url($page_url); if (empty($parts['scheme']) || empty($parts['host'])) { return $href; } $origin = $parts['scheme'] . '://' . $parts['host']; if (!empty($parts['port'])) { $origin .= ':' . $parts['port']; } if (strpos($href, '//') === 0) { return $parts['scheme'] . ':' . $href; } if (strpos($href, '/') === 0) { return $origin . $href; } $path = isset($parts['path']) ? $parts['path'] : '/'; $dir = (substr($path, -1) === '/') ? $path : str_replace('\\', '/', dirname($path)) . '/'; return $origin . $dir . $href; } /** * Parse HTML content and extract product tiles * * @param string $html_content The HTML content to parse * @param string $base_url The base URL for constructing links * @return string Parsed HTML content */ function fwembed_parse_product_tiles($html_content, $base_url) { $html = ''; libxml_use_internal_errors(true); $dom = new DOMDocument(); @$dom->loadHTML(loadHTML5($html_content), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); $dom->documentURI = $base_url; $xpath = new DOMXPath($dom); $products = $xpath->query('//*[@data-testid="product"]'); if ($products === false || $products->length === 0) { $products = $xpath->query('//*[' . fwembed_class_predicate('product-tile') . ']'); } foreach ($products as $product) { $tileLink = fwembed_query_first($xpath, array( './/a[' . fwembed_class_predicate('tile') . ']', './/a[@href]', ), $product); $tileItem = fwembed_query_first($xpath, array( './/img[' . fwembed_class_predicate('tile__item--1') . ']', './/*[' . fwembed_class_predicate('image__object') . ']//img', './/img', ), $product); $tileDesc = fwembed_query_first($xpath, array( './/*[' . fwembed_class_predicate('tile__description') . ']', ), $product); $productHTML = ''; $linkHref = ''; if ($tileLink !== null) { $linkHref = fwembed_resolve_url($tileLink->item(0)->getAttribute('href'), $base_url); } if ($tileItem !== null) { $productHTML .= $dom->saveHTML($tileItem->item(0)); } if ($tileDesc !== null) { $productHTML .= $dom->saveHTML($tileDesc->item(0)); } if ($productHTML === '') { continue; } $html .= '
' . $productHTML . '
'; } libxml_clear_errors(); return $html; } function fwembed_parse_html($url = null) { if ($url === null) { throw new ValueError("Missing URL"); } $result = fwembed_make_request($url); if ($result['error']) { return "Error fetching URL: " . $result['error']; } return fwembed_parse_product_tiles($result['content'], $url); } function loadHTML5($html) { return '' . $html . ''; } function fwembed_shortcode( $atts ) { $options = get_option( 'fourthwall_settings_name' ); $value = isset( $options['fourth_url'] ) ? $options['fourth_url'] : 'https://fourthwall.com'; $store_html = fwembed_parse_html($value); $store_render = '
' . PHP_EOL . $store_html . PHP_EOL . '
'; return $store_render; } /** * Decode every JSON-LD block on a page. * * @param DOMXPath $xpath The XPath engine * @return array List of decoded JSON-LD structures */ function fwembed_jsonld_blocks(DOMXPath $xpath) { $blocks = array(); $scripts = $xpath->query('//script[contains(@type, "ld+json")]'); if ($scripts === false) { return $blocks; } foreach ($scripts as $script) { $decoded = json_decode(trim($script->textContent), true); if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { $blocks[] = $decoded; } } return $blocks; } /** * Walk a decoded JSON-LD structure looking for a schema.org Product node. * * Handles the shapes publishers actually emit: a bare object, a list, or a * node wrapped in @graph / itemListElement / mainEntity. * * @param mixed $data Decoded JSON-LD fragment * @param int $depth Current recursion depth * @return array|null The Product node, or null if this branch has none */ function fwembed_find_jsonld_product($data, $depth = 0) { if (!is_array($data) || $depth > 6) { return null; } if (isset($data['@type'])) { foreach ((array) $data['@type'] as $type) { if (is_string($type) && strcasecmp($type, 'Product') === 0) { return $data; } } } foreach (array('@graph', 'itemListElement', 'mainEntity', 'item') as $key) { if (isset($data[$key])) { $found = fwembed_find_jsonld_product($data[$key], $depth + 1); if ($found !== null) { return $found; } } } // A plain list of nodes. foreach ($data as $key => $item) { if (is_int($key) && is_array($item)) { $found = fwembed_find_jsonld_product($item, $depth + 1); if ($found !== null) { return $found; } } } return null; } /** * Pick the cheapest offer from a JSON-LD Product node. * * Variants each get their own Offer, so the lowest price is what a storefront * shows as the headline figure. AggregateOffer states it directly as lowPrice. * * @param array $product A schema.org Product node * @return array Keys 'price', 'currency', 'availability'; empty if no offer */ function fwembed_jsonld_offer($product) { if (empty($product['offers'])) { return array(); } $offers = $product['offers']; if (isset($offers['@type']) || isset($offers['price']) || isset($offers['lowPrice'])) { $offers = array($offers); } $best = array(); foreach ((array) $offers as $offer) { if (!is_array($offer)) { continue; } foreach (array('lowPrice', 'price') as $field) { if (!isset($offer[$field]) || !is_numeric($offer[$field])) { continue; } $price = (float) $offer[$field]; if (empty($best) || $price < $best['price']) { $availability = isset($offer['availability']) ? (string) $offer['availability'] : ''; $best = array( 'price' => $price, 'currency' => isset($offer['priceCurrency']) ? (string) $offer['priceCurrency'] : '', // "https://schema.org/InStock" -> "InStock" 'availability' => $availability === '' ? '' : substr($availability, strrpos($availability, '/') + 1), ); } break; } } return $best; } /** * Format a numeric price for display. * * Only used when the storefront's own formatted price could not be scraped. * * @param float $amount The price * @param string $currency ISO 4217 currency code * @return string Display-ready price */ function fwembed_format_price($amount, $currency) { $currency = strtoupper(trim($currency)); $symbols = array( 'USD' => '$', 'CAD' => '$', 'AUD' => '$', 'NZD' => '$', 'EUR' => "\xE2\x82\xAC", 'GBP' => "\xC2\xA3", 'JPY' => "\xC2\xA5", ); $formatted = number_format($amount, $currency === 'JPY' ? 0 : 2); if (isset($symbols[$currency])) { return $symbols[$currency] . $formatted; } return $currency === '' ? $formatted : $currency . ' ' . $formatted; } /** * Extract product fields from a page's JSON-LD. * * Preferred over scraping: it is a published contract rather than styling, so * it survives the theme changes that repeatedly break CSS-class selectors. * * @param DOMXPath $xpath The XPath engine * @return array Normalised product fields (any may be absent) */ function fwembed_product_from_jsonld(DOMXPath $xpath) { $product = null; foreach (fwembed_jsonld_blocks($xpath) as $block) { $product = fwembed_find_jsonld_product($block); if ($product !== null) { break; } } if ($product === null) { return array(); } $fields = array(); if (!empty($product['name']) && is_string($product['name'])) { $fields['title'] = $product['name']; } if (!empty($product['image'])) { $image = $product['image']; // May be a string, a list, or an ImageObject. if (is_array($image)) { $image = isset($image[0]) ? $image[0] : (isset($image['url']) ? $image['url'] : null); } if (is_array($image) && isset($image['url'])) { $image = $image['url']; } if (is_string($image) && $image !== '') { $fields['image'] = $image; } } if (!empty($product['description']) && is_string($product['description'])) { $fields['description_text'] = $product['description']; } if (!empty($product['sku']) && is_string($product['sku'])) { $fields['sku'] = $product['sku']; } $offer = fwembed_jsonld_offer($product); if (!empty($offer)) { $fields['price'] = fwembed_format_price($offer['price'], $offer['currency']); $fields['currency'] = $offer['currency']; $fields['availability'] = $offer['availability']; } return $fields; } /** * Extract product fields by scraping the rendered markup. * * Fallback for stores whose JSON-LD is missing or incomplete, and the source of * the store's own price formatting and rich HTML description. * * @param DOMDocument $dom The parsed page * @param DOMXPath $xpath The XPath engine * @param string $url The product URL * @return array Normalised product fields (any may be absent) */ function fwembed_product_from_html(DOMDocument $dom, DOMXPath $xpath, $url) { $fields = array(); // Fourthwall moved the title from

to

, so match on the class / // data-testid rather than the tag, and fall back to the OpenGraph tags // (which have stayed stable across their redesigns). $productTitle = fwembed_query_first($xpath, array( '//*[@data-testid="product.name"]', '//*[' . fwembed_class_predicate('product-info__title') . ']', '//meta[@property="og:title"]', )); $productPrice = fwembed_query_first($xpath, array( '//*[@data-testid="product.price"]', '//*[' . fwembed_class_predicate('product-info__price--original') . ']', '//*[' . fwembed_class_predicate('product-info__price') . ']', )); $productImage = fwembed_query_first($xpath, array( '//*[@data-testid="product.image"]//img[' . fwembed_class_predicate('gallery__image-object') . ']', '//*[@data-gallery="gallery-slide"]//img[' . fwembed_class_predicate('gallery__image-object') . ']', '//img[' . fwembed_class_predicate('gallery__image-object') . ']', '//meta[@property="og:image"]', )); $productDesc = fwembed_query_first($xpath, array( '//*[' . fwembed_class_predicate('product-info__description') . ']//*[' . fwembed_class_predicate('html-formatter') . ']', '//*[' . fwembed_class_predicate('product-info__description') . ']', )); if ($productTitle !== null) { $node = $productTitle->item(0); $fields['title'] = $node->nodeName === 'meta' ? $node->getAttribute('content') : $node->textContent; } if ($productPrice !== null) { $fields['price'] = $productPrice->item(0)->textContent; } if ($productImage !== null) { $node = $productImage->item(0); if ($node->nodeName === 'meta') { $src = $node->getAttribute('content'); } else { // Lazy-loaded galleries keep the real URL in data-src. $src = $node->getAttribute('src'); if ($src === '' || strpos($src, 'data:') === 0) { $src = $node->getAttribute('data-src'); } $alt = $node->getAttribute('alt'); if ($alt !== '') { $fields['image_alt'] = $alt; } } if (trim($src) !== '') { $fields['image'] = fwembed_resolve_url($src, $url); } } if ($productDesc !== null) { $fields['description_html'] = $dom->saveHTML($productDesc->item(0)); } return $fields; } /** * Parse HTML content and extract single product information * * Reads JSON-LD first and falls back to scraping field by field, so a markup * change can only degrade whatever it actually touched. * * @param string $html_content The HTML content to parse * @param string $url The product URL * @param bool $show_description Whether to show product description * @return string Parsed HTML content */ function fwembed_parse_single_product($html_content, $url, $show_description = false) { $html = ''; libxml_use_internal_errors(true); $dom = new DOMDocument(); @$dom->loadHTML(loadHTML5($html_content), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); $dom->documentURI = $url; $xpath = new DOMXPath($dom); $structured = fwembed_product_from_jsonld($xpath); $scraped = fwembed_product_from_html($dom, $xpath, $url); // JSON-LD wins where it is the more stable source. The scraped price wins // because it carries the store's own formatting ("from $20", locale rules), // and the scraped description wins because JSON-LD flattens it to plain text. $title = fwembed_first_value(array( isset($structured['title']) ? $structured['title'] : null, isset($scraped['title']) ? $scraped['title'] : null, )); $image = fwembed_first_value(array( isset($structured['image']) ? $structured['image'] : null, isset($scraped['image']) ? $scraped['image'] : null, )); $price = fwembed_first_value(array( isset($scraped['price']) ? $scraped['price'] : null, isset($structured['price']) ? $structured['price'] : null, )); libxml_clear_errors(); if ($title === '' || $image === '') { return ''; } $image = fwembed_resolve_url($image, $url); $imageAlt = isset($scraped['image_alt']) ? $scraped['image_alt'] : $title; $availability = isset($structured['availability']) ? $structured['availability'] : ''; $classes = 'product-tile'; if (strcasecmp($availability, 'OutOfStock') === 0 || strcasecmp($availability, 'SoldOut') === 0) { $classes .= ' product-tile--sold-out'; } $attributes = ''; if ($availability !== '') { $attributes .= ' data-availability="' . esc_attr($availability) . '"'; } if (!empty($structured['sku'])) { $attributes .= ' data-sku="' . esc_attr($structured['sku']) . '"'; } $html = '
'; $html .= ''; $html .= '' . esc_attr($imageAlt) . ''; $html .= '
'; $html .= '

' . esc_html(trim($title)) . '

'; $html .= '
'; $html .= '' . esc_html(trim($price)) . ''; $html .= '
'; $html .= '
'; $html .= '
'; // Add description if show_description is true if ($show_description) { if (isset($scraped['description_html'])) { $html .= '
' . $scraped['description_html'] . '
'; } elseif (isset($structured['description_text'])) { $html .= '
' . wpautop(esc_html($structured['description_text'])) . '
'; } } $html .= '
'; return $html; } /** * Return the first non-empty string from a list of candidates. * * @param array $candidates Values in priority order * @return string First usable value, or an empty string */ function fwembed_first_value($candidates) { foreach ($candidates as $candidate) { if (is_string($candidate) && trim($candidate) !== '') { return $candidate; } } return ''; } function fwembed_parse_html_single($url = null, $show_description = false) { if ($url === null) { throw new ValueError("Missing URL"); } $result = fwembed_make_request($url); if ($result['error']) { return "Error fetching URL: " . $result['error']; } return fwembed_parse_single_product($result['content'], $url, $show_description); } function fwembed_single_shortcode($atts) { $atts = shortcode_atts( array( 'url' => '', 'show_description' => 'false', ), $atts ); if (empty($atts['url'])) { return '

Error: URL is required for [fourthwall_single] shortcode

'; } // Convert string 'true'/'false' to boolean $show_description = filter_var($atts['show_description'], FILTER_VALIDATE_BOOLEAN); $product_html = fwembed_parse_html_single($atts['url'], $show_description); return '
' . PHP_EOL . $product_html . PHP_EOL . '
'; } add_shortcode('fourthwall_single', 'fwembed_single_shortcode'); add_shortcode( 'fourthwall', 'fwembed_shortcode' ); add_shortcode( 'fourthwall_random', 'fwembed_random_shortcode' ); /** * Extract all product URLs from a store page * * @param string $html_content The HTML content to parse * @param string $base_url The base URL for constructing links * @return array Array of product URLs */ function fwembed_extract_product_urls($html_content, $base_url) { $product_urls = array(); libxml_use_internal_errors(true); $dom = new DOMDocument(); @$dom->loadHTML(loadHTML5($html_content), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); $dom->documentURI = $base_url; $xpath = new DOMXPath($dom); $products = $xpath->query('//*[@data-testid="product"]'); if ($products === false || $products->length === 0) { $products = $xpath->query('//*[' . fwembed_class_predicate('product-tile') . ']'); } foreach ($products as $product) { $tileLink = fwembed_query_first($xpath, array( './/a[' . fwembed_class_predicate('tile') . ']', './/a[@href]', ), $product); if ($tileLink !== null) { $full_url = fwembed_resolve_url($tileLink->item(0)->getAttribute('href'), $base_url); if ($full_url !== '') { $product_urls[] = $full_url; } } } libxml_clear_errors(); // The same product can appear in several sections of one page. return array_values(array_unique($product_urls)); } /** * Read product URLs from a sitemap, following one level of sitemap index. * * @param string $sitemap_url The sitemap to read * @param string $store_url The store URL, used to reject off-site entries * @param int $depth Current recursion depth * @return array Product URLs */ function fwembed_read_sitemap($sitemap_url, $store_url, $depth = 0) { if ($depth > 1) { return array(); } $result = fwembed_make_request($sitemap_url); if ($result['error'] || empty($result['content'])) { return array(); } libxml_use_internal_errors(true); $xml = new DOMDocument(); $loaded = @$xml->loadXML($result['content']); if (!$loaded || $xml->documentElement === null) { libxml_clear_errors(); return array(); } $store_host = parse_url($store_url, PHP_URL_HOST); $is_index = strcasecmp($xml->documentElement->localName, 'sitemapindex') === 0; $urls = array(); $nested = 0; foreach ($xml->getElementsByTagName('loc') as $loc) { $value = trim($loc->textContent); if ($value === '') { continue; } // Never follow a pointing off the configured store. if (parse_url($value, PHP_URL_HOST) !== $store_host) { continue; } if ($is_index) { if ($nested >= 10) { break; } $nested++; $urls = array_merge($urls, fwembed_read_sitemap($value, $store_url, $depth + 1)); } elseif (strpos($value, '/products/') !== false) { $urls[] = $value; } } libxml_clear_errors(); return array_values(array_unique($urls)); } /** * List every product in a store via its sitemap. * * The store page only renders whatever collection it is configured to feature, * so scraping it under-reports the catalogue. The sitemap is the full list. * * @param string $store_url The store URL * @return array Product URLs */ function fwembed_extract_product_urls_from_sitemap($store_url) { $sitemap_url = fwembed_resolve_url('/sitemap.xml', $store_url); if ($sitemap_url === '') { return array(); } return fwembed_read_sitemap($sitemap_url, $store_url); } /** * Collect candidate product URLs for a store. * * @param string $store_url The store URL * @param string $source One of 'auto', 'sitemap', 'page' * @return array|string Product URLs, or an error string */ function fwembed_collect_product_urls($store_url, $source = 'auto') { if ($source !== 'page') { $urls = fwembed_extract_product_urls_from_sitemap($store_url); if (!empty($urls) || $source === 'sitemap') { return $urls; } } $result = fwembed_make_request($store_url); if ($result['error']) { return "Error fetching store URL: " . $result['error']; } return fwembed_extract_product_urls($result['content'], $store_url); } /** * Get random products from store or specified URLs * * @param string $store_url The store URL to fetch products from (optional if urls provided) * @param array $urls Array of specific product URLs to randomize from * @param int $count Number of products to display * @param string $source Where to discover products: 'auto', 'sitemap' or 'page' * @return string HTML content of random products */ function fwembed_get_random_products($store_url = null, $urls = array(), $count = 3, $source = 'auto') { $product_urls = array(); // If specific URLs are provided, use those if (!empty($urls)) { $product_urls = $urls; } // Otherwise, discover all products in the store elseif ($store_url) { $product_urls = fwembed_collect_product_urls($store_url, $source); if (is_string($product_urls)) { return $product_urls; } } else { return "Error: Either store URL or product URLs must be provided"; } // Shuffle the URLs to randomize shuffle($product_urls); // Limit to requested count $selected_urls = array_slice($product_urls, 0, $count); // Fetch every uncached product page in one parallel batch rather than // serially, so a cold cache costs one round trip instead of N. $responses = fwembed_make_requests($selected_urls); $html = ''; foreach ($selected_urls as $url) { if (empty($responses[$url]) || $responses[$url]['error'] || empty($responses[$url]['content'])) { continue; } $html .= fwembed_parse_single_product($responses[$url]['content'], $url, false); } return $html; } function fwembed_random_shortcode($atts) { $atts = shortcode_atts( array( 'count' => '3', 'urls' => '', 'store_url' => '', 'source' => 'auto', ), $atts ); $source = strtolower(trim($atts['source'])); if (!in_array($source, array('auto', 'sitemap', 'page'), true)) { $source = 'auto'; } $count = intval($atts['count']); if ($count <= 0) { $count = 3; } $urls = array(); if (!empty($atts['urls'])) { // Split URLs by comma and clean them up $url_array = explode(',', $atts['urls']); foreach ($url_array as $url) { $clean_url = trim($url); if (!empty($clean_url)) { $urls[] = $clean_url; } } } $store_url = ''; if (!empty($atts['store_url'])) { $store_url = trim($atts['store_url']); } else { // Use default store URL from settings if no store_url provided $options = get_option('fourthwall_settings_name'); $store_url = isset($options['fourth_url']) ? $options['fourth_url'] : ''; } $products_html = fwembed_get_random_products($store_url, $urls, $count, $source); if (empty($products_html)) { return '

No products found to display.

'; } return '
' . PHP_EOL . $products_html . PHP_EOL . '
'; }