Create Release / build (push) Successful in 5s
Fourthwall moved the product title from <h2> to <h1>, which silently emptied [fourthwall_single] and [fourthwall_random]: both gated rendering on a title match that could no longer succeed. Verified against a live store. Parsing - Read schema.org JSON-LD first, falling back to scraped markup field by field, then to og: meta tags. Each field degrades independently, so a future markup change can only affect what it actually touched. - Match CSS classes with a padded contains() predicate instead of @class="...", and select on data-testid where available. Exact class matching is what broke. - Surface sku, availability and currency, which were previously discarded. Product discovery - Enumerate products from sitemap.xml rather than scraping the store page, which only sees the collection it features. [fourthwall_random count="10"] could not return more than the 3 items a featured homepage renders. - New source="auto|sitemap|page" attribute. Off-host <loc> entries are rejected so a hostile sitemap cannot redirect fetches. Caching - Stale-while-revalidate: stale entries are served immediately and refreshed by WP-Cron, so cache expiry never costs a visitor a network round trip. - Refresh scheduling dedupes via wp_next_scheduled and takes a lock, collapsing a stampede to a single job. - Fetch uncached product pages concurrently with curl_multi instead of serially. - Cache lifetime is now configurable (default 60 minutes). - Entries written by earlier versions are read as stale and upgraded in place, so caches turn over without blanking a store. Fixes - Build absolute product links properly. "/collections/all" + "/products/x" produced 404s; a trailing slash produced "//products/x". - Escape the price on output; it was interpolated raw. - strpos() misuse meant an error string at offset 0 was treated as success. - Guard a null description node that would fatal when show_description="true". - Report non-200 responses instead of caching an empty body silently. - Drop the shared /tmp/cookies.txt jar. It was cross-site mutable state and made concurrent fetching unsafe; all endpoints return 200 without it. - Remove the ssl_verify setting. It was a development affordance and its checkbox never worked - unchecked meant "key absent", which read as true. Certificate verification is now pinned on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1129 lines
35 KiB
PHP
1129 lines
35 KiB
PHP
<?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' );
|
|
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 <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);
|
|
}
|
|
|
|
/**
|
|
* 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 '<p>No products found to display.</p>';
|
|
}
|
|
|
|
return '<div class="fw-random-products">' . PHP_EOL . $products_html . PHP_EOL . '</div>';
|
|
}
|