Configure browsing session pacing and size

This commit is contained in:
jjbrunton committed 2026-08-11 22:17:18 +01:00
1 parent 1fd154e1eb
commit e890f74478
14 files changed
+201 -56

No files matched your search

+62 -42
View File
@@ -12,6 +12,7 @@ import type { Logger } from '../utils/logger.js';
const MAX_CONTENT_AGE_MS = 7 * 24 * 60 * 60_000;
const MIN_POST_CONTEXT = 12;
const MIN_COMMENT_CONTEXT = 20;
const MAX_DAILY_SELECTIONS = 12;
export interface BrowsingDecisionSink {
recordSimulation(decision: BrowsingDecisionRecord): Promise<void>;
@@ -42,7 +43,13 @@ export interface AccountBrowsingSimulationServiceOptions {
export type BrowsingSimulationOutcome =
| { kind: 'idle'; reason: string }
| { kind: 'scheduled'; nextDueAt: number }
| { kind: 'simulated'; decision: BrowsingDecisionRecord; nextDueAt: number };
| {
kind: 'simulated';
/** Retained for callers that only need the first selection. */
decision: BrowsingDecisionRecord;
decisions: BrowsingDecisionRecord[];
nextDueAt: number;
};
/**
* Selects hypothetical votes without exposing any Reddit write dependency.
@@ -87,7 +94,13 @@ export class AccountBrowsingSimulationService {
const today = startOfLocalDay(now);
const target = dailyTarget(this.options.accountId, today, settings);
if (!force && this.options.repository.countSelectedSince(this.options.accountId, today) >= target) {
const selectedToday = this.options.repository.countSelectedSince(this.options.accountId, today);
if (selectedToday >= MAX_DAILY_SELECTIONS) {
const nextDueAt = moveOutsideQuietHours(today + 24 * 60 * 60_000, settings);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
return { kind: 'scheduled', nextDueAt };
}
if (!force && selectedToday >= target) {
const nextDueAt = moveOutsideQuietHours(today + 24 * 60 * 60_000, settings);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
return { kind: 'scheduled', nextDueAt };
@@ -107,46 +120,57 @@ export class AccountBrowsingSimulationService {
const preferredKind: BrowsingThingKind = this.random() * (settings.postWeight + settings.commentWeight)
< settings.postWeight ? 'post' : 'comment';
let candidates: BrowsingThing[] = [];
let candidates: BrowsingThing[] = eligiblePosts;
let fallbackCandidates: BrowsingThing[] = [];
if (preferredKind === 'comment' && eligiblePosts.length > 0) {
const parent = eligiblePosts[Math.floor(this.random() * eligiblePosts.length)];
const comments = await this.options.reddit.getBrowsingComments(parent.subreddit, parent.parentPostId, 25);
candidates = await this.screenThings(comments, settings, sessionId, now);
fallbackCandidates = eligiblePosts;
}
if (candidates.length === 0) candidates = eligiblePosts;
const nextDueAt = this.scheduleNext(settings, now);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
if (candidates.length === 0) return { kind: 'scheduled', nextDueAt };
if (candidates.length === 0 && fallbackCandidates.length === 0) return { kind: 'scheduled', nextDueAt };
const selected = candidates[Math.floor(this.random() * candidates.length)];
const sessionLimit = force
? Math.min(settings.selectionsPerSession, MAX_DAILY_SELECTIONS - selectedToday)
: Math.min(settings.selectionsPerSession, target - selectedToday);
const selected = pickRandom(candidates, sessionLimit, this.random);
if (selected.length < sessionLimit) {
selected.push(...pickRandom(fallbackCandidates, sessionLimit - selected.length, this.random));
}
const voter = settings.autoVoteEnabled && allowVoting && !this.options.dryRun
? this.options.voter
: undefined;
const decision = toDecision(
this.options.accountId, selected, 'simulated-upvote',
voter ? 'Upvote pending Reddit confirmation' : 'Eligible browsing selection',
sessionId, now, voter ? 'pending' : 'simulated',
);
await this.options.sink.recordSimulation(decision);
if (voter) {
const result = await voter.upvote(selected.fullname);
if (result.status === 'submitted') {
this.options.repository.markVoteSubmitted(this.options.accountId, selected.fullname);
decision.voteSubmitted = true;
decision.voteStatus = 'submitted';
decision.reason = 'Reddit upvote submitted';
this.options.logger.info(`Submitted browsing upvote for ${selected.fullname} in r/${selected.subreddit}`);
} else {
decision.reason = `Reddit upvote not submitted: ${result.reason ?? result.status}`;
decision.voteStatus = 'failed';
this.options.repository.markVoteFailed(this.options.accountId, selected.fullname, decision.reason);
this.options.logger.warn(`Browsing upvote for ${selected.fullname} was not submitted: ${result.reason ?? result.status}`);
const decisions: BrowsingDecisionRecord[] = [];
for (const thing of selected) {
const decision = toDecision(
this.options.accountId, thing, 'simulated-upvote',
voter ? 'Upvote pending Reddit confirmation' : 'Eligible browsing selection',
sessionId, now, voter ? 'pending' : 'simulated',
);
await this.options.sink.recordSimulation(decision);
if (voter) {
const result = await voter.upvote(thing.fullname);
if (result.status === 'submitted') {
this.options.repository.markVoteSubmitted(this.options.accountId, thing.fullname);
decision.voteSubmitted = true;
decision.voteStatus = 'submitted';
decision.reason = 'Reddit upvote submitted';
this.options.logger.info(`Submitted browsing upvote for ${thing.fullname} in r/${thing.subreddit}`);
} else {
decision.reason = `Reddit upvote not submitted: ${result.reason ?? result.status}`;
decision.voteStatus = 'failed';
this.options.repository.markVoteFailed(this.options.accountId, thing.fullname, decision.reason);
this.options.logger.warn(`Browsing upvote for ${thing.fullname} was not submitted: ${result.reason ?? result.status}`);
}
}
this.options.onActivity?.(decision);
if (!decision.voteSubmitted) this.options.logger.info(`Simulated browsing selection for ${thing.fullname} in r/${thing.subreddit}; no Reddit vote was submitted`);
decisions.push(decision);
}
this.options.onActivity?.(decision);
if (!decision.voteSubmitted) this.options.logger.info(`Simulated browsing selection for ${selected.fullname} in r/${selected.subreddit}; no Reddit vote was submitted`);
return { kind: 'simulated', decision, nextDueAt };
return { kind: 'simulated', decision: decisions[0], decisions, nextDueAt };
}
private async screenPosts(
@@ -207,22 +231,23 @@ export class AccountBrowsingSimulationService {
}
private scheduleNext(settings: AccountBrowsingSettingsRecord, from: number): number {
const target = dailyTarget(this.options.accountId, startOfLocalDay(from), settings);
const quietHours = settings.quietHours.enabled
? quietDurationHours(settings.quietHours.startHour, settings.quietHours.endHour)
: 0;
const wakingMs = (24 - quietHours) * 60 * 60_000;
// Spread the day's target across the waking window while retaining bounded
// randomness. The hard minimum remains authoritative for unusual policies.
const distributedGap = (wakingMs / target) * (0.75 + this.random() * 0.5);
const jitter = Math.floor(this.random() * (settings.jitterMs + 1));
return moveOutsideQuietHours(
from + Math.max(settings.minimumGapMs, Math.floor(distributedGap)) + jitter,
from + settings.minimumGapMs + jitter,
settings,
);
}
}
function pickRandom<T>(items: readonly T[], limit: number, random: () => number): T[] {
const remaining = [...items];
const selected: T[] = [];
while (selected.length < limit && remaining.length > 0) {
selected.push(remaining.splice(Math.floor(random() * remaining.length), 1)[0]);
}
return selected;
}
export function dailyTarget(
accountId: string,
localDayStart: number,
@@ -264,11 +289,6 @@ function startOfLocalDay(timestamp: number): number {
return date.getTime();
}
function quietDurationHours(startHour: number, endHour: number): number {
if (startHour === endHour) return 0;
return endHour > startHour ? endHour - startHour : 24 - startHour + endHour;
}
function weightedPick<T>(items: readonly T[], weight: (item: T) => number, random: () => number): T {
const total = items.reduce((sum, item) => sum + weight(item), 0);
let cursor = random() * total;