Files
fourth-wall-embed-wp/libs/shortcode.php
T

1129 lines
35 KiB
PHP
Raw Normal View History

2024-12-27 14:10:25 -08:00
<?php
/**
* How long fetched content is considered fresh, in seconds.
*
* @return int Seconds
*/
function fwembed_cache_ttl() {
$options = get_option('fourthwall_settings_name');
$minutes = isset($options['cache_ttl']) ? intval($options['cache_ttl']) : 60;
if ($minutes < 1) {
$minutes = 60;
}
return $minutes * MINUTE_IN_SECONDS;
}
/**
* How long a cached entry is kept after it goes stale.
*
* Stale content is still served while a refresh runs in the background, so it
* has to outlive the freshness window by a wide margin. Once this elapses the
* entry is gone and the next visitor pays for a synchronous fetch.
*
* @return int Seconds
*/
function fwembed_cache_retention() {
return max(fwembed_cache_ttl() * 24, DAY_IN_SECONDS);
}
/**
* Transient key for a URL.
*
* @param string $url The URL
* @return string Cache key
*/
function fwembed_cache_key($url) {
return 'fwembed_' . md5($url);
}
/**
* Read a cached response.
*
* @param string $url The URL
* @return array|null Cached entry, or null if nothing usable is stored
*/
function fwembed_cache_read($url) {
$cached = get_transient(fwembed_cache_key($url));
if (!is_array($cached) || !array_key_exists('content', $cached)) {
return null;
}
// Entries written by older versions have no freshness stamp; treat them as
// stale so they get served once and then refreshed.
$cached['expires_at'] = isset($cached['expires_at']) ? (int) $cached['expires_at'] : 0;
return $cached;
}
/**
* Store a response and stamp its freshness window.
*
* @param string $url The URL
* @param array $result The response to cache
* @return array The stamped result
*/
function fwembed_cache_write($url, $result) {
$result['cached_at'] = time();
$result['expires_at'] = time() + fwembed_cache_ttl();
set_transient(fwembed_cache_key($url), $result, fwembed_cache_retention());
return $result;
}
/**
* Whether a cached entry is still fresh.
*
* @param array $entry A cache entry
* @return bool
*/
function fwembed_cache_is_fresh($entry) {
return isset($entry['expires_at']) && $entry['expires_at'] > 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 .= '<div class="product-tile"><a class="product-link" target="_blank" href="' . esc_url($linkHref) . '">' . $productHTML . '</a></div>';
}
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 '<!DOCTYPE html><html><body>' . $html . '</body></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 = '<div class="fw-store-parent">' . PHP_EOL . $store_html . PHP_EOL . '</div>';
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 <h2> to <h1>, 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 = '<div class="' . esc_attr($classes) . '"' . $attributes . '>';
$html .= '<a class="product-link" target="_blank" rel="noopener noreferrer" href="' . esc_url($url) . '">';
$html .= '<img class="tile__item tile__item--1" loading="lazy" src="' . esc_url($image) . '" alt="' . esc_attr($imageAlt) . '">';
$html .= '<div class="tile__description">';
$html .= '<h3 class="tile__heading">' . esc_html(trim($title)) . '</h3>';
$html .= '<div class="tile__prices">';
$html .= '<span class="tile__price tile__price--original">' . esc_html(trim($price)) . '</span>';
$html .= '</div>';
$html .= '</div>';
$html .= '</a>';
// Add description if show_description is true
if ($show_description) {
if (isset($scraped['description_html'])) {
$html .= '<div class="product-description">' . $scraped['description_html'] . '</div>';
} elseif (isset($structured['description_text'])) {
$html .= '<div class="product-description">' . wpautop(esc_html($structured['description_text'])) . '</div>';
}
}
$html .= '</div>';
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 '<p>Error: URL is required for [fourthwall_single] shortcode</p>';
}
// 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 '<div class="fw-single-product">' . PHP_EOL . $product_html . PHP_EOL . '</div>';
}
add_shortcode('fourthwall_single', 'fwembed_single_shortcode');
add_shortcode( 'fourthwall', 'fwembed_shortcode' );
2025-06-24 11:36:36 -07:00
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 !== '') {
2025-06-24 11:36:36 -07:00
$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 <loc> 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);
2025-06-24 11:36:36 -07:00
}
/**
* Get random products from store or specified URLs
*
2025-06-24 11:36:36 -07:00
* @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'
2025-06-24 11:36:36 -07:00
* @return string HTML content of random products
*/
function fwembed_get_random_products($store_url = null, $urls = array(), $count = 3, $source = 'auto') {
2025-06-24 11:36:36 -07:00
$product_urls = array();
2025-06-24 11:36:36 -07:00
// If specific URLs are provided, use those
if (!empty($urls)) {
$product_urls = $urls;
}
// Otherwise, discover all products in the store
2025-06-24 11:36:36 -07:00
elseif ($store_url) {
$product_urls = fwembed_collect_product_urls($store_url, $source);
if (is_string($product_urls)) {
return $product_urls;
2025-06-24 11:36:36 -07:00
}
} else {
return "Error: Either store URL or product URLs must be provided";
}
2025-06-24 11:36:36 -07:00
// 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);
2025-06-24 11:36:36 -07:00
$html = '';
foreach ($selected_urls as $url) {
if (empty($responses[$url]) || $responses[$url]['error'] || empty($responses[$url]['content'])) {
continue;
2025-06-24 11:36:36 -07:00
}
$html .= fwembed_parse_single_product($responses[$url]['content'], $url, false);
2025-06-24 11:36:36 -07:00
}
2025-06-24 11:36:36 -07:00
return $html;
}
function fwembed_random_shortcode($atts) {
$atts = shortcode_atts(
array(
'count' => '3',
'urls' => '',
'store_url' => '',
'source' => 'auto',
2025-06-24 11:36:36 -07:00
),
$atts
);
$source = strtolower(trim($atts['source']));
if (!in_array($source, array('auto', 'sitemap', 'page'), true)) {
$source = 'auto';
}
2025-06-24 11:36:36 -07:00
$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);
2025-06-24 11:36:36 -07:00
if (empty($products_html)) {
return '<p>No products found to display.</p>';
}
return '<div class="fw-random-products">' . PHP_EOL . $products_html . PHP_EOL . '</div>';
}