' . __( 'Display random products:', 'fourthwall_text_domain' ) . ' [fourthwall_random count="5"]
' . __( 'Random from specific URLs:', 'fourthwall_text_domain' ) . ' [fourthwall_random count="3" urls="https://store.com/product1,https://store.com/product2,https://store.com/product3"]
' . __( 'Random from different store:', 'fourthwall_text_domain' ) . ' [fourthwall_random count="2" store_url="https://different-store.fourthwall.com"]
' . __( 'Note: Disable SSL verification only for local development. Keep enabled for production sites.', 'fourthwall_text_domain' ) . '
' . "\n"; + echo '' . __( 'Random from the store page instead of the sitemap:', 'fourthwall_text_domain' ) . ' [fourthwall_random count="3" source="page"]
' . __( 'Random products are drawn from your store sitemap, which covers your whole catalog. Set source="page" to only use products shown on the store page itself.', 'fourthwall_text_domain' ) . '
' . "\n"; + echo '' . __( 'Tip: point the Store URL at your /collections/all page to list every product with [fourthwall].', 'fourthwall_text_domain' ) . '
' . "\n"; echo ' ' . "\n"; echo '' . "\n"; @@ -114,17 +139,20 @@ class fourthwall_settings { } - function render_ssl_verify_field() { + function render_cache_ttl_field() { // Retrieve data from the database. $options = get_option( 'fourthwall_settings_name' ); // Set default value. - $value = isset( $options['ssl_verify'] ) ? $options['ssl_verify'] : ''; + $value = isset( $options['cache_ttl'] ) ? intval( $options['cache_ttl'] ) : 60; + if ( $value < 1 ) { + $value = 60; + } // Field output. - echo ''; - echo '' . __( 'Enable SSL verification', 'fourthwall_text_domain' ) . '
'; + echo ' ' . esc_html__( 'minutes', 'fourthwall_text_domain' ); + echo '' . __( 'How long store content stays fresh before it is refreshed (default: 60). Expired content is still shown instantly while it refreshes in the background, so raising this does not slow your pages down.', 'fourthwall_text_domain' ) . '
'; } diff --git a/libs/shortcode.php b/libs/shortcode.php index 8ebe3cc..a223840 100644 --- a/libs/shortcode.php +++ b/libs/shortcode.php @@ -1,34 +1,154 @@ 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 = [ + $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); @@ -36,81 +156,304 @@ function fwembed_make_request($url) { 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_SSL_VERIFYPEER, $ssl_verify); - curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt'); - curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt'); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); - $html_content = curl_exec($ch); + 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 = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $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); - $result = [ - 'content' => $html_content, - 'error' => $error, - 'status_code' => $status_code - ]; - - // Cache the result for 1 hour (3600 seconds) if no error - if (!$error && $status_code == 200) { - set_transient($cache_key, $result, 3600); - } - 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 = null; + $html = ''; libxml_use_internal_errors(true); $dom = new DOMDocument(); @$dom->loadHTML(loadHTML5($html_content), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); $dom->documentURI = $base_url; - $divs = $dom->getElementsByTagName('div'); - - foreach ($divs as $div) { - if ($div->hasAttribute('data-testid') && $div->getAttribute('data-testid') === 'product') { - $xpath = new DOMXPath($dom); + $xpath = new DOMXPath($dom); - $tileLink = $xpath->query('.//a[contains(@class, "tile")]', $div); - $tileItem = $xpath->query('.//img[contains(@class, "tile__item--1") - and not(contains(@class, "badge")) - and not(contains(@class, "tile_options")) - and not(contains(@class, "tile__item--2"))]', $div); - $tileDesc = $xpath->query('.//*[contains(@class, "tile__description") - and not(contains(@class, "badge")) - and not(contains(@class, "tile_options"))]', $div); - - $productHTML = ''; - $linkHref = ''; - if ($tileLink->length > 0) { - $linkHref = $tileLink->item(0)->getAttribute('href'); - } - if ($tileItem->length > 0) { - $productHTML .= $dom->saveHTML($tileItem->item(0)); - } - if ($tileDesc->length > 0) { - $productHTML .= $dom->saveHTML($tileDesc->item(0)); - } + $products = $xpath->query('//*[@data-testid="product"]'); + if ($products === false || $products->length === 0) { + $products = $xpath->query('//*[' . fwembed_class_predicate('product-tile') . ']'); + } - $html = $html . ''; + 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 .= ''; } libxml_clear_errors(); return $html; @@ -142,63 +485,376 @@ function fwembed_shortcode( $atts ) { 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 fromNo products found to display.
';