Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion app/api/cron/lx-autoblog/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { env } from "@/lib/env";
import { nextPublishAt } from "@/lib/lx/schedule";
import {
enqueueArticleGenerate,
enqueueGuestPostGenerate,
enqueueKeywordResearch,
} from "@/lib/lx/workerClient";
import { isGuestPostSlot, planGuestPost } from "@/lib/lx/autoGuestPost";
import { repairStuckLxJobs } from "@/lib/lx/repair";

export const runtime = "nodejs";
Expand Down Expand Up @@ -82,6 +84,8 @@ export async function POST(req: Request) {

let enqueued = 0;
let skipped_no_webhook = 0;
let guest_posts = 0;
let guest_posts_unavailable = 0;

for (const s of due ?? []) {
if (!s.webhook_url) {
Expand All @@ -101,7 +105,31 @@ export async function POST(req: Request) {
.update({ next_publish_at: next?.toISOString() ?? null })
.eq("id", s.id);

await enqueueArticleGenerate(s.id);
// Every tenth post this site publishes goes to a partner's blog instead of
// its own. The cadence, the partner and the topic are all decided without a
// human — see lib/lx/autoGuestPost, which is deliberate about the two
// places that would be expensive to get wrong: it only ever targets sites
// that opted into the network, and it never costs the author a publishing
// slot.
//
// That last part is why this falls through rather than skipping. A site
// with no eligible partner, or one that has already written for everybody
// recently, still publishes today — to its own blog, as it always did.
// Silence would be the worse failure: the customer is paying for a
// schedule, not for a guest post.
let guested = false;
if (await isGuestPostSlot(svc, s.id as string)) {
const plan = await planGuestPost(svc, s.id as string);
if (plan) {
await enqueueGuestPostGenerate(s.id as string, plan.targetSiteId, plan.topic);
guest_posts++;
guested = true;
} else {
guest_posts_unavailable++;
}
}

if (!guested) await enqueueArticleGenerate(s.id);
enqueued++;
}

Expand All @@ -110,6 +138,12 @@ export async function POST(req: Request) {
repaired,
topped_up,
enqueued,
guest_posts,
// Slots that were due a guest post and published normally instead, because
// no partner was available. Reported rather than swallowed: a number that
// climbs every day means the network has stopped producing matches, and
// that is invisible from the article count alone.
guest_posts_unavailable,
skipped_no_webhook,
});
}
164 changes: 164 additions & 0 deletions lib/lx/autoGuestPost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Choosing, without a human, which slot becomes a guest post and where it goes.
//
// Guest posting was a manual loop: open the project, read the ranked
// opportunities, pick a partner, pick one of the crossed topics, click. Every
// piece of judgement in that loop is already computed — `findGuestPostOpportunities`
// ranks the partners and crosses the seed keywords — so what the click actually
// contributed was a decision to proceed and a choice of the top item. This makes
// both automatic and puts them on the publishing schedule.
//
// Two things are deliberately *not* automated here, because they are the parts
// where being wrong is expensive:
//
// * **Nothing is published to a site outside the network.** A target has to be
// `backlinks_enabled`, `active` and not flagged for inappropriate content —
// the same filter the manual matcher applies. Partners opted in to receiving
// guest posts; strangers did not.
// * **A slot is never wasted on a failed guest post.** If there is no partner,
// no usable topic, or the author has recently written for everyone
// available, the caller falls back to an ordinary article. The publishing
// cadence a customer is paying for comes first.

import type { SupabaseClient } from "@supabase/supabase-js";
import { findGuestPostOpportunities } from "./guestPostMatcher";

/**
* One in every this many published posts is a guest post.
*
* Ten because it is the ratio that keeps guest posting a *supplement* to a
* site's own blog rather than a redirect of it: nine posts still land on the
* author's own domain, building the thing the customer is actually paying to
* grow, and the tenth buys a contextual backlink from a partner. Raising it
* dilutes the author's own output; lowering it makes the partner network look
* like a link farm to anybody reading it, which it must not become.
*/
export const GUEST_POST_EVERY = 10;

/**
* Don't write for the same partner again inside this window.
*
* A network of a dozen partners and a daily schedule would otherwise send the
* top-ranked partner a post every ten days for ever, because ranking is stable —
* the matcher has no memory. Thirty days spreads the output across the network
* and stops any one partner's blog filling up with our bylines, which is the
* shape that gets a link network discounted.
*/
const PARTNER_COOLDOWN_DAYS = 30;

export type GuestPostPlan = {
targetSiteId: string;
targetDomain: string;
topic: string;
};

/**
* Is the next post this site publishes due to be a guest post?
*
* Counted from articles already on file rather than from a counter column, so
* it is self-correcting: a failed generation, a manual post or a restored
* backup all move the count, and the cadence follows the reality rather than a
* number somebody has to remember to increment. Guest posts count toward the
* ten themselves — "every 10th post" is every 10th post, not every 10th
* own-blog post plus extras.
*
* @returns true when the next article would be the 10th, 20th, ...
*/
export async function isGuestPostSlot(
supabase: SupabaseClient<any>,
authorSiteId: string,
): Promise<boolean> {
const { count, error } = await supabase
.from("lx_article")
.select("id", { count: "exact", head: true })
.eq("site_id", authorSiteId);

// A failed count must not silently turn every slot into a guest post, nor
// silently turn none of them into one. Falling back to "no" keeps the
// customer's own blog publishing, which is the obligation that matters.
if (error) {
console.warn("[lx] guest-post cadence count failed:", error.message);
return false;
}

return ((count ?? 0) + 1) % GUEST_POST_EVERY === 0;
}

/**
* Pick a partner and a topic, or return null and let the caller publish
* normally.
*
* The ranking is the matcher's; the only judgement added here is skipping
* partners written for recently, which the matcher cannot know about because it
* is stateless by design.
*
* @param supabase service client — this runs from cron, with no user session
* @param authorSiteId the site whose slot is being filled
*/
export async function planGuestPost(
supabase: SupabaseClient<any>,
authorSiteId: string,
): Promise<GuestPostPlan | null> {
const opportunities = await findGuestPostOpportunities(supabase, authorSiteId).catch(
(err: unknown) => {
console.warn("[lx] guest-post matcher failed:", err);
return [];
},
);
if (opportunities.length === 0) return null;

const since = new Date(
Date.now() - PARTNER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000,
).toISOString();

// Who we have written for lately. One query for the whole cooldown rather
// than one per candidate: the list is short and the cron is walking every
// active site.
const { data: recent } = await supabase
.from("lx_article")
.select("target_site_id")
.eq("site_id", authorSiteId)
.eq("is_guest_post", true)
.gte("created_at", since);

const cooling = new Set(
(recent ?? [])
.map((r: { target_site_id: string | null }) => r.target_site_id)
.filter((id: string | null): id is string => Boolean(id)),
);

// Topics already requested for a partner, so a repeat run does not queue the
// same article twice. `lx_guest_post_request` is the manual path's ledger and
// is reused rather than duplicated — a human and the cron should not be able
// to commission the same post independently.
const { data: existing } = await supabase
.from("lx_guest_post_request")
.select("target_site_id, topic")
.eq("author_site_id", authorSiteId);

const taken = new Set(
(existing ?? []).map(
(r: { target_site_id: string; topic: string }) =>
`${r.target_site_id}::${r.topic.trim().toLowerCase()}`,
),
);

for (const opportunity of opportunities) {
if (cooling.has(opportunity.partner_site_id)) continue;

const topic = (opportunity.suggested_topics ?? []).find(
(t) => t && !taken.has(`${opportunity.partner_site_id}::${t.trim().toLowerCase()}`),
);
if (!topic) continue;

return {
targetSiteId: opportunity.partner_site_id,
targetDomain: opportunity.partner_domain,
topic,
};
}

// Every partner is either cooling off or has had all its crossed topics
// used. That is a healthy network doing its job, not an error — the caller
// publishes to the author's own blog instead.
return null;
}
Loading
Loading