Configure browsing session pacing and size
This commit is contained in:
1 parent
1fd154e1eb
commit
e890f74478
14 files changed
+201
-56
No files matched your search
@@ -15,6 +15,7 @@ export interface AccountBrowsingSettingsRecord {
|
||||
quietHours: GrowthQuietHours;
|
||||
minimumGapMs: number;
|
||||
jitterMs: number;
|
||||
selectionsPerSession: number;
|
||||
allowNsfw: boolean;
|
||||
autoVoteEnabled: boolean;
|
||||
nextDueAt: number | null;
|
||||
@@ -30,6 +31,7 @@ export interface AccountBrowsingSettingsPatch {
|
||||
quietHours?: GrowthQuietHours;
|
||||
minimumGapMs?: number;
|
||||
jitterMs?: number;
|
||||
selectionsPerSession?: number;
|
||||
allowNsfw?: boolean;
|
||||
autoVoteEnabled?: boolean;
|
||||
}
|
||||
@@ -78,6 +80,7 @@ export class AccountBrowsingSimulationRepository {
|
||||
quietHours: { ...DEFAULT_QUIET_HOURS },
|
||||
minimumGapMs: 45 * 60_000,
|
||||
jitterMs: 15 * 60_000,
|
||||
selectionsPerSession: 1,
|
||||
allowNsfw: false,
|
||||
autoVoteEnabled: false,
|
||||
nextDueAt: null,
|
||||
@@ -101,9 +104,9 @@ export class AccountBrowsingSimulationRepository {
|
||||
this.db.prepare(`
|
||||
INSERT INTO account_browsing_settings (
|
||||
account_id, enabled, daily_target_min, daily_target_max, post_weight,
|
||||
comment_weight, quiet_hours_json, minimum_gap_ms, jitter_ms, allow_nsfw, auto_vote_enabled,
|
||||
comment_weight, quiet_hours_json, minimum_gap_ms, jitter_ms, selections_per_session, allow_nsfw, auto_vote_enabled,
|
||||
next_due_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
daily_target_min = excluded.daily_target_min,
|
||||
@@ -113,13 +116,15 @@ export class AccountBrowsingSimulationRepository {
|
||||
quiet_hours_json = excluded.quiet_hours_json,
|
||||
minimum_gap_ms = excluded.minimum_gap_ms,
|
||||
jitter_ms = excluded.jitter_ms,
|
||||
selections_per_session = excluded.selections_per_session,
|
||||
allow_nsfw = excluded.allow_nsfw,
|
||||
auto_vote_enabled = excluded.auto_vote_enabled,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(
|
||||
accountId, merged.enabled ? 1 : 0, merged.dailyTargetMin, merged.dailyTargetMax,
|
||||
merged.postWeight, merged.commentWeight, JSON.stringify(merged.quietHours),
|
||||
merged.minimumGapMs, merged.jitterMs, merged.allowNsfw ? 1 : 0, merged.autoVoteEnabled ? 1 : 0,
|
||||
merged.minimumGapMs, merged.jitterMs, merged.selectionsPerSession,
|
||||
merged.allowNsfw ? 1 : 0, merged.autoVoteEnabled ? 1 : 0,
|
||||
merged.nextDueAt, now,
|
||||
);
|
||||
return this.getSettings(accountId, now);
|
||||
@@ -274,6 +279,10 @@ function validateSettings(settings: AccountBrowsingSettingsRecord): void {
|
||||
if (!Number.isInteger(settings.jitterMs) || settings.jitterMs < 0) {
|
||||
throw new Error('Browsing jitter must be a non-negative whole number');
|
||||
}
|
||||
if (!Number.isInteger(settings.selectionsPerSession)
|
||||
|| settings.selectionsPerSession < 1 || settings.selectionsPerSession > 12) {
|
||||
throw new Error('Browsing selections per session must be a whole number between 1 and 12');
|
||||
}
|
||||
const { startHour, endHour } = settings.quietHours;
|
||||
if (!Number.isInteger(startHour) || startHour < 0 || startHour > 23
|
||||
|| !Number.isInteger(endHour) || endHour < 0 || endHour > 23) {
|
||||
@@ -292,6 +301,7 @@ function mapSettings(row: Record<string, unknown>): AccountBrowsingSettingsRecor
|
||||
quietHours: JSON.parse(row.quiet_hours_json as string) as GrowthQuietHours,
|
||||
minimumGapMs: row.minimum_gap_ms as number,
|
||||
jitterMs: row.jitter_ms as number,
|
||||
selectionsPerSession: row.selections_per_session as number,
|
||||
allowNsfw: row.allow_nsfw === 1,
|
||||
autoVoteEnabled: row.auto_vote_enabled === 1,
|
||||
nextDueAt: (row.next_due_at as number | null) ?? null,
|
||||
|
||||
@@ -616,6 +616,9 @@ export function runMigrations(db: Database.Database): void {
|
||||
addColumnIfNotExists(db, 'account_browsing_decisions', 'vote_status', "TEXT NOT NULL DEFAULT 'simulated' CHECK(vote_status IN ('simulated', 'pending', 'submitted', 'failed'))");
|
||||
db.exec("UPDATE account_browsing_decisions SET vote_status = 'submitted' WHERE vote_submitted = 1");
|
||||
},
|
||||
() => {
|
||||
addColumnIfNotExists(db, 'account_browsing_settings', 'selections_per_session', 'INTEGER NOT NULL DEFAULT 1 CHECK(selections_per_session BETWEEN 1 AND 12)');
|
||||
},
|
||||
];
|
||||
|
||||
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -27,6 +27,7 @@ export const BrowsingSettingsUpdateRequestSchema = z.strictObject({
|
||||
quietHours: GrowthQuietHoursSchema.optional(),
|
||||
minimumGapMs: z.number().int().min(45 * 60_000).max(DAY_MS).optional(),
|
||||
jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
|
||||
selectionsPerSession: z.number().int().min(1).max(12).optional(),
|
||||
allowNsfw: z.boolean().optional(),
|
||||
autoVoteEnabled: z.boolean().optional(),
|
||||
});
|
||||
@@ -34,7 +35,7 @@ export const BrowsingSettingsUpdateRequestSchema = z.strictObject({
|
||||
export const BrowsingSettingsSchema = z.strictObject({
|
||||
enabled: z.boolean(), dailyTargetMin: z.number().int(), dailyTargetMax: z.number().int(),
|
||||
postWeight: z.number(), commentWeight: z.number(), quietHours: GrowthQuietHoursSchema,
|
||||
minimumGapMs: z.number().int(), jitterMs: z.number().int(), allowNsfw: z.boolean(),
|
||||
minimumGapMs: z.number().int(), jitterMs: z.number().int(), selectionsPerSession: z.number().int(), allowNsfw: z.boolean(),
|
||||
autoVoteEnabled: z.boolean(),
|
||||
nextDueAt: z.number().nullable(), updatedAt: z.number(),
|
||||
});
|
||||
|
||||
@@ -315,6 +315,7 @@ function presentBrowsing(
|
||||
quietHours: settings.quietHours,
|
||||
minimumGapMs: settings.minimumGapMs,
|
||||
jitterMs: settings.jitterMs,
|
||||
selectionsPerSession: settings.selectionsPerSession,
|
||||
allowNsfw: settings.allowNsfw,
|
||||
autoVoteEnabled: settings.autoVoteEnabled,
|
||||
nextDueAt: settings.nextDueAt,
|
||||
|
||||
Reference in new issue
Block a user