video seo
All checks were successful
Deploy / build-deploy (push) Successful in 2m33s

This commit is contained in:
Jorijn van der Graaf 2026-08-19 00:02:32 +02:00
commit a8bc400371
3 changed files with 128 additions and 7 deletions

View file

@ -208,14 +208,25 @@ catcrafts.net {
# ── access log ──────────────────────────────────────────────────────── # ── access log ────────────────────────────────────────────────────────
# #
# Feeds the GoAccess analytics (see deploy/catcrafts-analytics). Retention # Feeds the GoAccess analytics (see deploy/catcrafts-analytics), and is the
# sized for that: ~15 MB/day means a roll roughly weekly; 52 compressed # only record of everything those aggregates discard: Sec-Fetch headers,
# rolls ~ a year of raw logs (~6 MB each gzipped). roll_keep_for must be # Range behaviour, query strings, per-IP request sequences. Every bot fleet
# explicit — lumberjack's default silently deletes after 90 days. # identified so far was caught in the raw log, not the report.
#
# roll_keep is a COUNT, so retention is really min(roll_keep × roll_size,
# roll_keep_for) — a byte budget, not the time window it reads as. 52 rolls
# is 5.2 GB, which is under a year for anything busier than 14 MB/day, and
# nothing here has ever been that quiet: 19-24 MB/day on a slow week, and
# 617 MB/day at the Hacker News peak, where 52 rolls is EIGHT DAYS. 365
# rolls (~2.6 GB gzipped, at the ~14:1 these compress to) makes
# roll_keep_for the constraint that actually decides, up to ~100 MB/day.
#
# roll_keep_for must be explicit — lumberjack's default silently deletes
# after 90 days.
log { log {
output file /var/log/caddy/catcrafts.net.log { output file /var/log/caddy/catcrafts.net.log {
roll_size 100MiB roll_size 100MiB
roll_keep 52 roll_keep 365
roll_keep_for 8760h roll_keep_for 8760h
} }
} }

View file

@ -446,6 +446,57 @@ export RenderedPage RenderPosts(std::span<const Post> posts) {
return page; return page;
} }
// The post's video, as a schema.org VideoObject hung off the BlogPosting.
//
// Google already indexed and carouselled these videos on the strength of the
// bare <video> element; this makes the placement deliberate rather than lucky,
// and a typed thumbnailUrl is what earns the still on the text result too.
//
// thumbnailUrl and uploadDate are what the video rich result requires, so a
// video whose mirror produced no poster is skipped rather than emitted
// incomplete — the same both-or-neither rule Dimensions() applies to width and
// height. Only our own mirrored paths qualify, for the reason ogImage below
// gives: an absolute src or a third-party poster is the off-site fetch the
// mirror exists to avoid, just performed by a crawler.
//
// Headline media only. That IS the post — these posts are their recording —
// and it is the one video a search result should be pointing at.
//
// No encodingFormat and no duration: neither is required, the mirror records no
// duration, and naming a container guessed from a file extension would assert
// something we do not know. A wrong type is worse than a missing one, for the
// same reason MimeFor() refuses to guess.
std::string PostVideoLd(const Post& p) {
std::vector<std::string> videos;
for (const PostMedia& m : p.media) {
if (m.kind != "video") continue;
if (!m.src.starts_with("/") || !m.poster.starts_with("/")) continue;
std::string node = std::format(
R"({{"@type":"VideoObject","name":{},"uploadDate":{},)"
R"("thumbnailUrl":{},"contentUrl":{})",
JsonStr(p.title), JsonStr(p.published),
JsonStr("https://catcrafts.net" + m.poster),
JsonStr("https://catcrafts.net" + m.src));
if (!p.excerpt.empty()) node += ",\"description\":" + JsonStr(p.excerpt);
if (m.width > 0 && m.height > 0) {
node += std::format(R"(,"width":{},"height":{})", m.width, m.height);
}
node += "}";
videos.push_back(std::move(node));
}
if (videos.empty()) return {};
if (videos.size() == 1) return ",\"video\":" + videos.front();
std::string out = ",\"video\":[";
for (std::size_t i = 0; i < videos.size(); ++i) {
if (i) out += ',';
out += videos[i];
}
out += ']';
return out;
}
// ── one post, in full ───────────────────────────────────────────────── // ── one post, in full ─────────────────────────────────────────────────
// The whole point of hosting the body: a page a search engine can index, a // The whole point of hosting the body: a page a search engine can index, a
@ -494,14 +545,15 @@ export RenderedPage RenderPost(const Post& p) {
R"("@type":"Person","name":"Jorijn van der Graaf"}},)" R"("@type":"Person","name":"Jorijn van der Graaf"}},)"
R"("publisher":{{"@id":"https://catcrafts.net/#organization",)" R"("publisher":{{"@id":"https://catcrafts.net/#organization",)"
R"("@type":"Organization","name":"Catcrafts"}},)" R"("@type":"Organization","name":"Catcrafts"}},)"
R"("discussionUrl":{}{}{}}})", R"("discussionUrl":{}{}{}{}}})",
JsonStr(p.title), JsonStr(p.published), JsonStr(p.title), JsonStr(p.published),
JsonStr("https://catcrafts.net" + canonical), JsonStr("https://catcrafts.net" + canonical),
JsonStr("https://catcrafts.net" + canonical), JsonStr("https://catcrafts.net" + canonical),
JsonStr(p.permalink), JsonStr(p.permalink),
p.excerpt.empty() ? std::string{} : ",\"description\":" + JsonStr(p.excerpt), p.excerpt.empty() ? std::string{} : ",\"description\":" + JsonStr(p.excerpt),
ogImage.empty() ? std::string{} ogImage.empty() ? std::string{}
: ",\"image\":" + JsonStr("https://catcrafts.net" + ogImage)); : ",\"image\":" + JsonStr("https://catcrafts.net" + ogImage),
PostVideoLd(p));
page.main = Format( page.main = Format(
R"(<article class="post">)" R"(<article class="post">)"

View file

@ -181,5 +181,63 @@ int main(int argc, char** argv) {
Check(personOk, "about Person schema parses and names the founder"); Check(personOk, "about Person schema parses and names the founder");
} }
// ── 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");
}
return Finish(); return Finish();
} }