Skip to content

Make every tenth published post a guest post, chosen without a human - #208

Merged
ralyodio merged 1 commit into
masterfrom
feature/auto-guest-posts
Aug 19, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feature/auto-guest-posts

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Guest posting was a manual loop: open the project, read the ranked opportunities, pick a partner, pick a crossed topic, click. Every piece of judgement in that loop was already computed — findGuestPostOpportunities ranks partners and crosses seed keywords. What the click contributed was a decision to proceed and a choice of the top item. Both are now automatic, on the publishing schedule.

Cadence

Counted from articles already on file, not a counter column — so it self-corrects. A failed generation, a manual post, a restored backup all move the count, and the rhythm follows reality rather than a number someone must remember to increment. Guest posts count toward the ten themselves: every 10th post means every 10th post, not every 10th own-blog post plus extras.

Three decisions worth arguing with (all tested)

  • A slot is never spent on a guest post that can't be placed. No partner, no unused topic, or everyone written for recently → the site publishes to its own blog as always. The schedule is what the customer pays for; silence is the worse failure. It falls through rather than skipping.
  • Nothing is published outside the network. A target must be backlinks_enabled, active, and not flagged for inappropriate content — the same filter the manual matcher applies. Partners opted in; strangers didn't, and an automated loop mustn't be the thing that forgets the difference.
  • No partner is written for twice inside 30 days. Ranking is stable and the matcher is stateless by design, so without a cooldown the top partner gets a post every ten slots forever — the shape that gets a link network discounted. That's exactly the failure automation introduces; a human clicking would have noticed.

Smaller things

  • The manual path's ledger (lx_guest_post_request) is reused, not duplicated, so a human and the cron can't independently commission the same article.
  • A cadence count that errors resolves to "not a guest post" — failing closed keeps the customer's own blog publishing; failing open would redirect every scheduled post to someone else's domain.
  • guest_posts_unavailable is reported by the cron rather than swallowed. A number climbing daily means the partner network has stopped producing matches, which is invisible from the article count alone.

1,675 tests pass (8 new); tsc --noEmit clean.

🤖 Generated with Claude Code

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 was 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. Both are now
automatic and both run on the publishing schedule.

The cadence is 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 rhythm follows reality instead of a
number somebody has to remember to increment. Guest posts count toward the ten
themselves — "every 10th post" means every 10th post, not every 10th own-blog
post plus extras.

Three decisions worth arguing with, all tested:

  * **A slot is never spent on a guest post that cannot be placed.** No partner,
    no unused topic, or everyone written for recently, and the site publishes to
    its own blog as it always did. The schedule is what a customer is paying
    for; silence would be the worse failure, so this falls through rather than
    skipping.
  * **Nothing is published outside the network.** A target must be
    backlinks_enabled, active and not flagged for inappropriate content — the
    same filter the manual matcher applies. Partners opted into receiving guest
    posts; strangers did not, and an automated loop must not be the thing that
    forgets the difference.
  * **A partner is not written for twice inside thirty days.** Ranking is stable
    and the matcher is stateless by design, so without a cooldown the
    top-ranked partner would receive a post every ten slots for ever. That is
    the shape that gets a link network discounted, and it is exactly the failure
    mode automation introduces — a human clicking would have noticed.

The manual path's ledger (lx_guest_post_request) is reused rather than
duplicated, so a human and the cron cannot independently commission the same
article.

A cadence count that errors resolves to "not a guest post". That direction is
deliberate: failing closed keeps the customer's own blog publishing, where
failing open would redirect every post on the schedule to somebody else's
domain.

`guest_posts_unavailable` is reported by the cron rather than swallowed. A
number that climbs every day means the partner network has stopped producing
matches, and that is invisible from the article count alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

35 finding(s)

HIGH/CRITICAL: 3 | MEDIUM: 23 | LOW: 9

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:47
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
HIGH sh-remote-script-execution prober/deploy/provision.sh:30
MEDIUM js-unescaped-html-sink app/(app)/dashboard/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM js-unescaped-html-sink app/(app)/dashboard/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM redos-nested-quantifier lib/careers/jobs.ts:139
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:130
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:98
LOW secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
LOW secret-generic-credential lib/sp/platforms/linkedin.ts:25
LOW js-dynamic-code-execution tests/careers-page-templates.test.ts:21
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:19
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:69
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW secret-generic-credential tests/contract/posthog-integration.test.ts:13
LOW secret-generic-credential tests/lead-campaign.test.ts:16

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 456970e into master Aug 19, 2026
8 checks passed
@ralyodio
ralyodio deleted the feature/auto-guest-posts branch August 19, 2026 03:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant