Compare
Compare
Shared across every PremiumPress product. 13 classes in inc/Compare.
CategoryComparison CategoryComparison.php
Comparison Theme — per-category price-comparison table. Adds a "Show comparison table" switch to each category's editor (the standard WordPress term add/edit screens). When on, a table comparing every product in that category — image, rating, and lowest price — renders at the top of the category page, so visitors see a price comparison of the whole category at a glance. This is the broad-match / alternatives view (many products, one category), the counterpart to the offers table on a single product page. Only active on the compare profile. Term field uses the same taxonomy hooks the theme's Directory\TermMeta uses; the table renders on the ppt_category_before_results action added to the search/category template.
FeedFetcher FeedFetcher.php
Price-comparison feed FETCHER — downloads a merchant/affiliate feed to a local temp file for streaming parse. Feeds are frequently large (tens/hundreds of MB) and often gzipped, so we stream to disk via WordPress's own download_url() (chunked, memory-safe) rather than pulling the whole body into memory with wp_remote_get. The returned path is a throwaway temp file the caller must @unlink() when done. gzip is handled transparently downstream by {@see FeedParser} (zlib's gz* reads plain files too).
FeedParser FeedParser.php
Price-comparison feed PARSER — turns a downloaded CSV/TSV or XML feed file into a stream of associative rows keyed by the feed's own column names. Both parsers are generators so a multi-hundred-MB feed never lands in memory at once — the caller ({@see Ingest}) pulls one row at a time. gzip is transparent: zlib's gzopen/gzgets read a plain file unchanged and a .gz file decompressed, so a feed served gzipped needs no special-casing. XML is walked with XMLReader (pull parser) for the same memory reason; SimpleXML would load the whole tree. Nothing here maps columns to canonical fields — that's {@see Mapper}'s job. The parser only cares about structure, not meaning.
Ingest Ingest.php
Price-comparison INGEST — the daily pipeline that keeps offers fresh. For each merchant with a feed configured it runs: fetch → parse → map → match → upsert → tombstone → recompute, logging the run to {@see Offers::runsTable()}. The two freshness guarantees live here: • every upserted offer is stamped with this run's id + last_seen_at; • after the run, {@see Offers::tombstone()} expires every offer this run didn't touch — absence from the feed means it's off sale, so no stale price survives. Then the cheapest live price per affected product is mirrored into the listing's price search meta so the existing search / sort / currency stack keeps working. Scheduling: a daily WP-Cron event is registered (mirroring {@see \Ppt\Directory\Expiry}), but WP-Cron fires on traffic and is unreliable for heavy imports — so the real trigger should be a system cron / pm2 job calling either wp ppt compare-ingest (WP-CLI) or the token-guarded endpoint admin-post.php?action=ppt_compare_ingest&token=…. All three funnel through {@see runAll()}.
Mapper Mapper.php
Price-comparison feed MAPPER — normalises one raw feed row (keyed by the feed's own column names) into the canonical offer shape the rest of the pipeline speaks. Every affiliate network names its columns differently — AWIN's price is search_price, Google Merchant's is g:price, a direct merchant might just call it price. This class owns those translations as built-in per-source profiles, and lets a merchant override any field via its "Column map (JSON)" meta (so an odd feed is a config change, not a code change). Output keys (canonical offer): merchant_sku, raw_title, price, delivery_cost, currency, stock_status, delivery_eta, offer_url, image_url, gtin, mpn, brand, variant_key. Prices are parsed to floats; availability strings are normalised to the {@see Offers} STOCK_* constants.
Matcher Matcher.php
Price-comparison MATCHER — decides which canonical product (a listing_type post) a feed offer belongs to, so six merchants' offers land on one comparison page. This is the hard part of any CSE. Matching runs in confidence order: 1. GTIN / EAN / UPC (barcode) → exact, confidence 1.0 2. brand + MPN → confidence 0.9 3. fuzzy title + brand → confidence from string similarity Anything below {@see THRESHOLD} is treated as unmatched and parked in the match queue rather than guessed — never auto-create duplicate product pages from a weak match (see the plan). A manual-override map always wins (highest confidence). Canonical product identity lives in listing postmeta: gtin, mpn, brand (written when a product is promoted from the queue / created from a feed). The GTIN lookup also falls back to the shop fork's existing product_barcode meta so pre-existing shop products match without re-tagging.
Merchant Merchant.php
Price-comparison MERCHANT (shop) — a lightweight CPT: Amazon, Foot Locker, etc. A merchant is the "shop" side of a price-comparison offer (Product × Merchant = Offer, see {@see \Ppt\Compare\Offers}). It's deliberately a low-volume, mostly static entity — a few hundred rows at most, changing rarely — so it lives as a WordPress CPT (free admin UI, permalinks, logo via the featured image) rather than a custom table like the volatile offers do. The post title is the shop name; the featured image is the logo. Everything else (rating, review count, payment methods, the affiliate-deeplink template, and the feed connection used by {@see \Ppt\Compare\Ingest}) is postmeta, edited from the "Merchant details" meta box.
Offers Offers.php
Price-comparison OFFERS — the volatile join between a product and a merchant, plus the supporting tables (price history, feed runs, match queue). This is the data layer the anti-staleness design hinges on. An "offer" is one merchant's live price/stock/delivery for one product variant. Offers churn daily and can number in the millions, so — unlike the low-volume {@see Merchant} CPT — they live in dedicated $wpdb tables, following the theme's established versioned-dbDelta-on-init pattern (see {@see \Ppt_sp\Cart::install()}). Four tables: {prefix}ppt_offers one row per (merchant × product variant); the live table {prefix}ppt_price_history append-only price log → idealo-style price graphs {prefix}ppt_feed_runs one row per feed pull; run log + failure alerting {prefix}ppt_match_queue offers we couldn't confidently match to a product The freshness contract: every {@see upsert()} stamps feed_run_id + last_seen_at. After a run, {@see tombstone()} marks every offer for that merchant NOT carrying the just-finished run id as out-of-stock — absence from the feed means it's no longer for sale, so a stale price is never left live.
OffersTable OffersTable.php
Price-comparison OFFERS TABLE — renders the "many shops, one product" comparison table (the idealo-style grid in the brief). A pure renderer: the comparison single-page template ({@see \Ppt_cm\SingleListing} and inc/_cm/templates/single.php) calls {@see render()} directly, so the table is a first-class part of the layout rather than injected into another profile's content. Rows come from {@see Offers::forListing()} already sorted by total price and already filtered for staleness (offers not refreshed within the freshness window are excluded). Each row prints an honest "checked N ago" stamp from last_seen_at, and the cheapest carries the "Best price incl. delivery" badge. Prices render through {@see \Ppt\Content\Currencies} so the currency switcher re-expresses every row for free.
ProductControls ProductControls.php
Comparison Theme — per-product display controls. Adds "Comments" and "Reviews" on/off switches to the admin product editor sidebar (see the gated block in Admin/views/listing-edit.php) and persists them: comments map to WordPress's native comment_status, reviews to a ppt_reviews_off meta. The comparison single page (inc/_cm/templates/single.php) reads {@see reviewsOn()} / {@see commentsOn()} to decide whether to render each section, so an admin can hide the comments or reviews form on one specific product. Saving hooks the same admin-post action the listing editor uses, at an earlier priority so it runs before the editor redirects. Only active on compare.
Retention Retention.php
Price-comparison RETENTION — the daily prune job that keeps the offer tables from growing forever. The ingest never deletes anything on its own: offers that vanish from a feed are only tombstoned (marked out-of-stock), and price_history / feed_runs are append-only. Left alone those tables grow without bound. This service prunes them on a daily cron, keeping only what's useful: • offers out-of-stock (tombstoned) longer than KEEP_OUT_OF_STOCK days → deleted (plus their price history) • price_history rows older than KEEP_PRICE_HISTORY days → deleted (keeps the graph window, drops ancient points) • feed_runs older than KEEP_FEED_RUNS days → deleted (keeps recent run logs) Every threshold is filterable, and every delete is batched (LIMIT loops) so even a large first run can't lock the tables or blow memory. Only active on compare.
SampleData SampleData.php
Price-comparison DEMO DATA — seeds one product with several competing merchant offers so the comparison table can be seen without wiring up real feeds. Everything it creates is flagged with {@see DEMO_META} so {@see clear()} can remove it cleanly (posts, offers, history, runs). Merchants are fictional shops on purpose — we never fabricate ratings/prices under a real retailer's name. Offers are inserted through the real {@see Offers::upsert()} path so last_seen_at, feed_run stamping and price history behave exactly as a live ingest would. Not registered as a Service — invoke from a script or WP-CLI: Ppt\Compare\SampleData::seed(); Ppt\Compare\SampleData::clear();
Scraper Scraper.php
Price-comparison SCRAPER — best-effort, standards-based product reader. There is no scraper that works on every site, so this deliberately reads the STRUCTURED DATA that a large slice of the web already publishes, trying several formats in order of reliability and stopping at the first that yields a priced product: 1. JSON-LD schema.org Product / Offer / AggregateOffer / ItemList (@graph aware) 2. Microdata itemscope/itemtype=schema.org/Product + itemprop price/name/… 3. RDFa typeof="…Product" + property="…" 4. Open Graph og:* + product:price:amount 5. Heuristic <h1> + a visible price (itemprop=price, or common price classes) It parses with DOMDocument (not brittle string matching) and covers single product pages (→ one product) and category / listing pages that expose several products in one document (→ many). URL discovery is via a sitemap.xml (index-aware): one link in, many products out. It cannot see JavaScript-rendered content and only extracts what it can parse — so a real feed is always preferable. Capped and polite.