2026-08-15 00:54:05 +02:00
|
|
|
/*
|
|
|
|
|
catcrafts.net
|
|
|
|
|
Copyright (C) 2026 Catcrafts
|
|
|
|
|
|
|
|
|
|
The source code of this website is made available for viewing purposes only.
|
|
|
|
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// The JSON-LD blocks are the machine-readable identity and offer records,
|
|
|
|
|
// born from a Google AI overview flatly asserting that nobody named Catcrafts
|
2026-09-05 18:38:39 +02:00
|
|
|
// sells the hardware it then sold. Parsed with our own JSON reader — the same
|
|
|
|
|
// one that must accept them — and checked for the facts.
|
2026-08-15 00:54:05 +02:00
|
|
|
|
|
|
|
|
import std;
|
|
|
|
|
import Catcrafts.Shared;
|
|
|
|
|
import Catcrafts.E2eHarness;
|
|
|
|
|
|
|
|
|
|
using namespace Catcrafts;
|
|
|
|
|
using namespace Catcrafts::E2e;
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
// Every <script type="application/ld+json"> block on the page, parsed. The
|
|
|
|
|
// block content is pure JSON, which contains no '<', so slicing to the next
|
|
|
|
|
// tag is exact.
|
|
|
|
|
std::vector<Json::Value> ExtractLd(TestServer& srv, const std::string& path) {
|
|
|
|
|
const std::string body = srv.Body(path);
|
|
|
|
|
constexpr std::string_view kOpen = "<script type=\"application/ld+json\">";
|
|
|
|
|
std::vector<Json::Value> out;
|
|
|
|
|
for (std::size_t pos = body.find(kOpen); pos != std::string::npos;
|
|
|
|
|
pos = body.find(kOpen, pos + kOpen.size())) {
|
|
|
|
|
const std::size_t start = pos + kOpen.size();
|
|
|
|
|
const std::size_t end = body.find('<', start);
|
|
|
|
|
if (end == std::string::npos) break;
|
|
|
|
|
if (auto v = Json::Parse(std::string_view(body).substr(start, end - start))) {
|
|
|
|
|
out.push_back(std::move(*v));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
int main(int argc, char** argv) {
|
|
|
|
|
TestServer srv(argv[1], 8212);
|
|
|
|
|
|
|
|
|
|
// ── the home identity graph ───────────────────────────────────────
|
|
|
|
|
// The home record is an @graph (Organization + WebSite joined by @id);
|
|
|
|
|
// the org node inside it must carry the registered identity.
|
|
|
|
|
{
|
|
|
|
|
bool ok = false;
|
|
|
|
|
for (const Json::Value& doc : ExtractLd(srv, "/")) {
|
|
|
|
|
const Json::Value* g = doc.Find("@graph");
|
|
|
|
|
if (!g || !g->IsArray()) continue;
|
|
|
|
|
std::size_t orgs = 0;
|
|
|
|
|
for (const Json::Value& node : g->array) {
|
|
|
|
|
if (node.Str("@type") == "Organization"
|
|
|
|
|
&& node.Str("vatID") == "NL003329281B38") {
|
|
|
|
|
++orgs;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ok = ok || orgs == 1;
|
|
|
|
|
}
|
|
|
|
|
Check(ok, "home Organization schema parses and carries the VAT identity");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-05 18:38:39 +02:00
|
|
|
// ── the shop index ────────────────────────────────────────────────
|
2026-08-15 00:54:05 +02:00
|
|
|
{
|
|
|
|
|
bool listOk = false;
|
|
|
|
|
for (const Json::Value& doc : ExtractLd(srv, "/shop")) {
|
|
|
|
|
if (doc.Str("@type") == "ItemList") {
|
|
|
|
|
const Json::Value* items = doc.Find("itemListElement");
|
|
|
|
|
listOk = items && items->IsArray() && !items->array.empty();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Check(listOk, "shop index carries an ItemList of the product pages");
|
|
|
|
|
}
|
2026-09-05 18:38:39 +02:00
|
|
|
|
|
|
|
|
// ── the product record ────────────────────────────────────────────
|
|
|
|
|
// Against whatever priced product the catalogue lists first, with every
|
|
|
|
|
// expected value read off that record: a listing coming or going is a
|
|
|
|
|
// content change, not an edit here. While no goods are listed (as since
|
|
|
|
|
// 2026-09-05) this block prints a note and is skipped.
|
|
|
|
|
if (const Product* pr = FirstPricedProduct()) {
|
|
|
|
|
const std::string shop = "/shop/" + pr->slug;
|
|
|
|
|
// Variants are a ProductGroup: one variant Product per colour, each
|
|
|
|
|
// with its own single offer — not one Product with three prices. A
|
|
|
|
|
// single-colour or colourless listing is a plain Product with one offer.
|
|
|
|
|
const bool grouped = pr->variants.size() > 1;
|
|
|
|
|
// Kept alive for the block: `record` and `firstOffer` point into it.
|
|
|
|
|
const std::vector<Json::Value> productLd = ExtractLd(srv, shop);
|
|
|
|
|
const Json::Value* record = nullptr;
|
|
|
|
|
for (const Json::Value& doc : productLd) {
|
|
|
|
|
if (doc.Str("@type") == (grouped ? "ProductGroup" : "Product")) record = &doc;
|
2026-08-15 00:54:05 +02:00
|
|
|
}
|
2026-09-05 18:38:39 +02:00
|
|
|
const Json::Value* firstOffer = nullptr;
|
|
|
|
|
{
|
|
|
|
|
bool offersOk = false;
|
|
|
|
|
if (record && grouped) {
|
|
|
|
|
if (const Json::Value* v = record->Find("hasVariant"); v && v->IsArray()
|
|
|
|
|
&& v->array.size() == pr->variants.size()) {
|
|
|
|
|
offersOk = true;
|
|
|
|
|
for (const Json::Value& node : v->array) {
|
|
|
|
|
const Json::Value* offers = node.Find("offers");
|
|
|
|
|
offersOk = offersOk && offers && offers->IsObject();
|
|
|
|
|
}
|
|
|
|
|
if (!v->array.empty()) firstOffer = v->array[0].Find("offers");
|
|
|
|
|
}
|
|
|
|
|
} else if (record) {
|
|
|
|
|
firstOffer = record->Find("offers");
|
|
|
|
|
offersOk = firstOffer && firstOffer->IsObject();
|
2026-08-15 00:54:05 +02:00
|
|
|
}
|
2026-09-05 18:38:39 +02:00
|
|
|
Check(offersOk, grouped
|
|
|
|
|
? "product schema parses with one variant (and offer) per colour"
|
|
|
|
|
: "product schema parses with its one offer");
|
|
|
|
|
}
|
|
|
|
|
srv.BodyHas(shop, "\"price\":\"" + Money::FormatMinor(pr->priceInclMinor) + "\"",
|
|
|
|
|
"schema price is the checkout integer");
|
|
|
|
|
// Merchant-grade offer fields: what Merchant Center's website-crawl feed
|
|
|
|
|
// reads. Shipping is published from the live carrier table at one unit's
|
|
|
|
|
// weight — the same integers checkout charges, so the listing cannot
|
|
|
|
|
// quote a rate the till won't honour; returns mirror the terms page.
|
|
|
|
|
srv.BodyHas(shop, "OfferShippingDetails", "offers carry shipping details");
|
|
|
|
|
srv.BodyHas(shop, "MerchantReturnPolicy", "offers carry a return policy");
|
|
|
|
|
const std::string sku = pr->variants.empty()
|
|
|
|
|
? pr->slug : pr->slug + "-" + pr->variants[0].slug;
|
|
|
|
|
srv.BodyHas(shop, "\"sku\":\"" + sku + "\"", "offers carry per-variant skus");
|
|
|
|
|
if (!pr->brand.empty()) {
|
|
|
|
|
srv.BodyHas(shop, "\"brand\":{\"@type\":\"Brand\",\"name\":\"" + pr->brand + "\"}",
|
|
|
|
|
"product carries the hardware brand");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// One entry per (transit tier, price) the carrier table produces, for
|
|
|
|
|
// the destinations the shop actually sells to — two, since the fixture
|
|
|
|
|
// prices NL/DE/GB/CH and checkout refuses DE and GB pending their
|
|
|
|
|
// producer registrations. Not a fixed property of the code: it is
|
|
|
|
|
// whatever the carrier prices INTERSECTED with where the shop sells,
|
|
|
|
|
// and publishing a rate to a country checkout would decline is an
|
|
|
|
|
// offer that cannot be accepted.
|
|
|
|
|
const Json::Value* shipping = firstOffer ? firstOffer->Find("shippingDetails") : nullptr;
|
|
|
|
|
Check(shipping && shipping->IsArray() && shipping->array.size() == 2,
|
|
|
|
|
"shipping details group the carrier's rates");
|
|
|
|
|
// The advertised rate IS the carrier's single-unit price. Neither a
|
|
|
|
|
// destination the table does not cover (AU) nor one the policy refuses
|
|
|
|
|
// (DE, priced at €25 in the fixture) is ever advertised.
|
|
|
|
|
if (shipping && shipping->IsArray()) {
|
|
|
|
|
std::vector<std::string> rates;
|
|
|
|
|
bool au = false;
|
|
|
|
|
bool refused = false;
|
|
|
|
|
for (const Json::Value& detail : shipping->array) {
|
|
|
|
|
if (const Json::Value* rate = detail.Find("shippingRate")) {
|
|
|
|
|
rates.emplace_back(rate->Str("value"));
|
|
|
|
|
}
|
|
|
|
|
if (const Json::Value* dest = detail.Find("shippingDestination")) {
|
|
|
|
|
if (const Json::Value* cc = dest->Find("addressCountry"); cc && cc->IsArray()) {
|
|
|
|
|
for (const Json::Value& c : cc->array) {
|
|
|
|
|
au = au || c.string == "AU";
|
|
|
|
|
refused = refused || c.string == "DE" || c.string == "GB";
|
|
|
|
|
}
|
2026-08-18 11:12:24 +02:00
|
|
|
}
|
2026-08-15 00:54:05 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-09-05 18:38:39 +02:00
|
|
|
std::ranges::sort(rates);
|
|
|
|
|
Check(rates == std::vector<std::string>{ "15.00", "55.00" },
|
|
|
|
|
"published shipping rates come from the carrier table");
|
|
|
|
|
Check(!au, "an uncovered destination is not advertised");
|
|
|
|
|
Check(!refused, "a destination the policy refuses is not advertised");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (srv.ShopOpen(pr->slug)) {
|
|
|
|
|
srv.BodyHas(shop, "schema.org/InStock", "open shop maps to InStock availability");
|
|
|
|
|
} else {
|
|
|
|
|
srv.BodyHas(shop, "schema.org/PreOrder", "coming-soon maps to PreOrder availability");
|
2026-08-15 00:54:05 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-05 18:38:39 +02:00
|
|
|
// og: tags are the link-preview card on Mastodon and Lemmy — where the
|
|
|
|
|
// traffic actually comes from. Per product, not per site: two listings
|
|
|
|
|
// that share a link-preview image are two posts that look like the
|
|
|
|
|
// same thing.
|
|
|
|
|
if (!pr->image.empty()) {
|
|
|
|
|
srv.BodyHas(shop, "og:image\" content=\"https://catcrafts.net" + pr->image,
|
|
|
|
|
"product og:image is the absolute photo URL");
|
|
|
|
|
}
|
2026-08-15 00:54:05 +02:00
|
|
|
} else {
|
2026-09-05 18:38:39 +02:00
|
|
|
std::println("note: the catalogue lists no priced product; the product-record "
|
|
|
|
|
"checks did not run and re-arm when one is listed");
|
2026-08-15 00:54:05 +02:00
|
|
|
}
|
|
|
|
|
srv.BodyHas("/", "property=\"og:title\"", "home page has og:title");
|
|
|
|
|
|
|
|
|
|
// ── the person-company weld ───────────────────────────────────────
|
|
|
|
|
// The about page: the founder must be named in the HTML, the Person
|
|
|
|
|
// schema must parse, and the home page byline must link it.
|
|
|
|
|
srv.BodyHas("/about", "Jorijn van der Graaf", "about page names the founder");
|
|
|
|
|
srv.BodyHas("/", "Jorijn van der Graaf", "home page carries the founder byline");
|
|
|
|
|
{
|
|
|
|
|
bool personOk = false;
|
|
|
|
|
for (const Json::Value& doc : ExtractLd(srv, "/about")) {
|
|
|
|
|
if (const Json::Value* person = doc.Find("mainEntity")) {
|
|
|
|
|
personOk = personOk || person->Str("name") == "Jorijn van der Graaf";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Check(personOk, "about Person schema parses and names the founder");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 00:02:32 +02:00
|
|
|
// ── the post's video ──────────────────────────────────────────────
|
|
|
|
|
// Google carouselled these recordings off the bare <video> element; the
|
|
|
|
|
// VideoObject is what makes that deliberate rather than lucky.
|
|
|
|
|
//
|
|
|
|
|
// PostVideoLd only marks up our OWN mirrored copies, so this can only be
|
|
|
|
|
// asserted when the fixture was actually mirrored — an unmirrored checkout
|
|
|
|
|
// still carries the instance's absolute URLs, and skipping is the correct
|
|
|
|
|
// behaviour there, not a failure. Same reasoning (and same note) as the
|
|
|
|
|
// format-ladder checks in ShouldServePostPages.
|
|
|
|
|
{
|
|
|
|
|
constexpr std::string_view kVideoPost =
|
|
|
|
|
"/posts/fairphone-6-postmarketos-working-main-camera";
|
|
|
|
|
const std::string postBody = srv.Body(std::string(kVideoPost));
|
|
|
|
|
if (postBody.find("poster=\"/media/") == std::string::npos) {
|
|
|
|
|
std::println("note: no locally-mirrored poster on {} — the "
|
|
|
|
|
"VideoObject checks did not run", kVideoPost);
|
|
|
|
|
} else {
|
|
|
|
|
bool found = false;
|
|
|
|
|
bool complete = false;
|
|
|
|
|
bool ownMirror = false;
|
|
|
|
|
for (const Json::Value& doc : ExtractLd(srv, std::string(kVideoPost))) {
|
|
|
|
|
if (doc.Str("@type") != "BlogPosting") continue;
|
|
|
|
|
const Json::Value* v = doc.Find("video");
|
|
|
|
|
if (!v) continue;
|
|
|
|
|
if (v->IsArray() && v->array.empty()) continue;
|
|
|
|
|
// One video: the object itself, not a single-element array.
|
|
|
|
|
const Json::Value& vid = v->IsArray() ? v->array.front() : *v;
|
|
|
|
|
found = true;
|
|
|
|
|
const std::string_view thumb = vid.Str("thumbnailUrl");
|
|
|
|
|
const std::string_view content = vid.Str("contentUrl");
|
|
|
|
|
complete = vid.Str("@type") == "VideoObject"
|
|
|
|
|
&& !vid.Str("name").empty()
|
|
|
|
|
&& !vid.Str("uploadDate").empty()
|
|
|
|
|
&& !thumb.empty() && !content.empty();
|
|
|
|
|
ownMirror = thumb.starts_with("https://catcrafts.net/media/")
|
|
|
|
|
&& content.starts_with("https://catcrafts.net/media/");
|
|
|
|
|
}
|
|
|
|
|
Check(found, "a video post's BlogPosting carries a video node");
|
|
|
|
|
Check(complete, "VideoObject has the fields the rich result requires");
|
|
|
|
|
Check(ownMirror, "video thumbnail and content URLs point at our own mirror");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A post that leads with no video must not grow an empty video property:
|
|
|
|
|
// an Article claiming a video it does not have is a structured-data error,
|
|
|
|
|
// not a harmless extra key.
|
|
|
|
|
{
|
|
|
|
|
bool sawPosting = false;
|
|
|
|
|
bool bare = true;
|
|
|
|
|
for (const Json::Value& doc :
|
|
|
|
|
ExtractLd(srv, "/posts/the-linux-phone-travel-experience")) {
|
|
|
|
|
if (doc.Str("@type") != "BlogPosting") continue;
|
|
|
|
|
sawPosting = true;
|
|
|
|
|
if (doc.Find("video")) bare = false;
|
|
|
|
|
}
|
|
|
|
|
Check(sawPosting && bare, "a post with no video carries no video node");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
return Finish();
|
|
|
|
|
}
|