This commit is contained in:
parent
faf881fa0c
commit
a8bc400371
3 changed files with 128 additions and 7 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">)"
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue