diff --git a/app/api/cron/lx-autoblog/route.ts b/app/api/cron/lx-autoblog/route.ts index 2939a18b..73f7dc64 100644 --- a/app/api/cron/lx-autoblog/route.ts +++ b/app/api/cron/lx-autoblog/route.ts @@ -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"; @@ -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) { @@ -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++; } @@ -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, }); } diff --git a/lib/lx/autoGuestPost.ts b/lib/lx/autoGuestPost.ts new file mode 100644 index 00000000..e2238f81 --- /dev/null +++ b/lib/lx/autoGuestPost.ts @@ -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, + authorSiteId: string, +): Promise { + 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, + authorSiteId: string, +): Promise { + 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; +} diff --git a/tests/contract/auto-guest-post.test.ts b/tests/contract/auto-guest-post.test.ts new file mode 100644 index 00000000..39c1cb7a --- /dev/null +++ b/tests/contract/auto-guest-post.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { + GUEST_POST_EVERY, + isGuestPostSlot, + planGuestPost, +} from "@/lib/lx/autoGuestPost"; + +vi.mock("@/lib/lx/guestPostMatcher", () => ({ + findGuestPostOpportunities: vi.fn(), +})); + +import { findGuestPostOpportunities } from "@/lib/lx/guestPostMatcher"; + +const matcher = vi.mocked(findGuestPostOpportunities); + +/** + * A Supabase stub that answers each table with whatever the test hands it. + * + * The chain shapes here mirror the real calls exactly — `.select(...,{count,head})` + * for the cadence count and `.select().eq().gte()` for the cooldown — because + * getting one of those wrong is precisely the kind of mistake that returns + * `undefined` and silently disables the feature rather than failing. + */ +function stubSupabase(opts: { + articleCount?: number; + countError?: string; + recentGuestTargets?: string[]; + existingRequests?: Array<{ target_site_id: string; topic: string }>; +}) { + return { + from(table: string) { + if (table === "lx_article") { + return { + select(_cols: string, o?: { count?: string; head?: boolean }) { + if (o?.head) { + return { + eq: () => + Promise.resolve( + opts.countError + ? { count: null, error: { message: opts.countError } } + : { count: opts.articleCount ?? 0, error: null }, + ), + }; + } + return { + eq: () => ({ + eq: () => ({ + gte: () => + Promise.resolve({ + data: (opts.recentGuestTargets ?? []).map((id) => ({ + target_site_id: id, + })), + }), + }), + }), + }; + }, + }; + } + if (table === "lx_guest_post_request") { + return { + select: () => ({ + eq: () => Promise.resolve({ data: opts.existingRequests ?? [] }), + }), + }; + } + throw new Error(`unexpected table ${table}`); + }, + } as never; +} + +const opportunity = (id: string, topics: string[], score = 1) => ({ + partner_site_id: id, + partner_domain: `${id}.example`, + partner_niche: null, + partner_blog_root_url: null, + score, + suggested_topics: topics, +}); + +beforeEach(() => { + matcher.mockReset(); +}); + +describe("guest post cadence", () => { + it("fires on every tenth post and no others", async () => { + // Counted from articles already on file, so the cadence follows reality + // rather than a counter somebody has to remember to increment. + const fired: number[] = []; + for (let published = 0; published < 30; published += 1) { + if (await isGuestPostSlot(stubSupabase({ articleCount: published }), "author")) { + fired.push(published + 1); + } + } + expect(fired).toEqual([10, 20, 30]); + expect(GUEST_POST_EVERY).toBe(10); + }); + + it("does not turn every slot into a guest post when the count fails", async () => { + // The safe direction. Failing "no" keeps the customer's own blog + // publishing, which is the obligation that matters; failing "yes" would + // redirect every post on the schedule to somebody else's domain. + const slot = await isGuestPostSlot( + stubSupabase({ countError: "connection reset" }), + "author", + ); + expect(slot).toBe(false); + }); +}); + +describe("choosing a partner", () => { + it("takes the highest-ranked partner and its first crossed topic", async () => { + matcher.mockResolvedValue([ + opportunity("best", ["ai for logistics", "fleet telemetry"], 9), + opportunity("second", ["something else"], 3), + ]); + + const plan = await planGuestPost(stubSupabase({}), "author"); + + expect(plan).toEqual({ + targetSiteId: "best", + targetDomain: "best.example", + topic: "ai for logistics", + }); + }); + + it("skips a partner written for inside the cooldown", async () => { + // Ranking is stable and the matcher has no memory, so without this the + // top partner would receive a post every ten slots for ever -- which is + // the shape that gets a link network discounted. + matcher.mockResolvedValue([ + opportunity("recent", ["topic a"], 9), + opportunity("fresh", ["topic b"], 4), + ]); + + const plan = await planGuestPost( + stubSupabase({ recentGuestTargets: ["recent"] }), + "author", + ); + + expect(plan?.targetSiteId).toBe("fresh"); + }); + + it("does not commission a topic a human already requested", async () => { + // The manual path and the cron share one ledger so they cannot + // independently order the same article. + matcher.mockResolvedValue([opportunity("partner", ["Taken Topic", "free topic"])]); + + const plan = await planGuestPost( + stubSupabase({ + existingRequests: [{ target_site_id: "partner", topic: "taken topic" }], + }), + "author", + ); + + expect(plan?.topic).toBe("free topic"); + }); + + it("returns null rather than a bad target when nothing is available", async () => { + // The caller publishes an ordinary post instead. A slot is never spent on + // a guest post that cannot be placed -- the schedule is what the customer + // is paying for. + matcher.mockResolvedValue([opportunity("only", ["one topic"])]); + + const plan = await planGuestPost( + stubSupabase({ + recentGuestTargets: ["only"], + }), + "author", + ); + + expect(plan).toBeNull(); + }); + + it("returns null when the network is empty", async () => { + matcher.mockResolvedValue([]); + expect(await planGuestPost(stubSupabase({}), "author")).toBeNull(); + }); + + it("survives the matcher throwing", async () => { + // Discovery reaching out across the network is the part most likely to + // fail, and it must not take the publishing schedule with it. + matcher.mockRejectedValue(new Error("partner query exploded")); + expect(await planGuestPost(stubSupabase({}), "author")).toBeNull(); + }); +});