feat: add startup jitter, randomized browser fingerprints, and comment style variants to bot fleet

Introduce `startup_jitter_seconds` config field so each bot delays its first session by a random amount up to the configured maximum, spreading fleet activity on service start. Persist a randomly chosen user-agent and viewport per bot in `BotState` and pass them to `BotBrowser` constructor, replacing the hardcoded defaults with a varied pool defined in `config.py`. Add `HANDLE_SUFFIX_WORDS` list and remove numeric suffixes from handle generation to avoid bot-farm appearance. Implement `pick_comment_style` and `is_short_comment_style` static methods in `LLMClient`, extend `generate_comment` with a `style` parameter that produces one-liner or question variants using `MIN_SHORT_COMMENT_LEN`, and wire session comment caps (`SESSION_COMMENT_CAP_MIN/MAX`) with cooldown intervals (`COMMENT_COOLDOWN_MIN/MAX_SECONDS`) into the bot loop. Update `BotRuntimeConfig`, `BotsService` config field registration, and architecture docs to reflect the new identity and pacing logic.
This commit is contained in:
2026-06-13 11:19:32 +00:00
parent 89635dd7e1
commit ce3cae1433
11 changed files with 226 additions and 35 deletions
+92 -18
View File
@@ -13,14 +13,21 @@ from faker import Faker
from devplacepy.services.bot.browser import BotBrowser
from devplacepy.services.bot.config import (
COMMENT_COOLDOWN_MAX_SECONDS,
COMMENT_COOLDOWN_MIN_SECONDS,
FEED_TOPICS,
GIST_LANGUAGES,
HANDLE_SUFFIX_WORDS,
PERSONAS,
PROJECT_STATUSES,
PROJECT_TYPES,
REACT_RATE_DEFAULT,
REACT_RATES,
SEARCH_TERMS,
SESSION_COMMENT_CAP_MAX,
SESSION_COMMENT_CAP_MIN,
USER_AGENTS,
VIEWPORTS,
persona_article_score,
pick_category,
)
@@ -61,10 +68,21 @@ class DevPlaceBot:
self._pause_min = min(cfg.action_pause_min, cfg.action_pause_max)
self._pause_max = max(cfg.action_pause_min, cfg.action_pause_max)
self._break_scale = max(0.1, cfg.break_scale)
self._startup_jitter = max(0, cfg.startup_jitter_seconds)
self._ai_decisions = cfg.ai_decisions
self._decision_temperature = cfg.decision_temperature
self.faker = Faker()
self.b = BotBrowser(headless=cfg.headless)
if not self.state.user_agent:
self.state.user_agent = random.choice(USER_AGENTS)
if not self.state.viewport:
self.state.viewport = random.choice(VIEWPORTS)
self.b = BotBrowser(
headless=cfg.headless,
user_agent=self.state.user_agent,
viewport=self.state.viewport,
)
self._session_comments = 0
self._session_comment_cap = 0
self._post_cache: list[tuple[str, str, str, str, str]] = []
self._project_cache: list[tuple[str, str]] = []
self._bug_cache: list[tuple[str, str]] = []
@@ -220,12 +238,10 @@ class DevPlaceBot:
f"{last}{first[0]}",
]
handle = random.choice(styles)
if random.random() < 0.15:
suffix = random.choice(
[str(random.randint(1, 99)), str(random.randint(1970, 2005))]
)
sep = random.choice(["", "_", "."])
handle = f"{handle}{sep}{suffix}"
if random.random() < 0.18:
word = random.choice(HANDLE_SUFFIX_WORDS)
sep = random.choice(["", "_", ".", "-"])
handle = f"{handle}{sep}{word}"
handle = re.sub(r"[^a-z0-9._-]", "", handle)[:20]
return handle or "dev"
@@ -277,9 +293,12 @@ class DevPlaceBot:
self._log(f"Registered as {self.state.username}")
return True
if attempt >= 2:
self.state.username = (
f"{self._generate_handle()}{random.randint(10, 999)}"
)
base = self._generate_handle()
word = random.choice(HANDLE_SUFFIX_WORDS)
sep = random.choice(["", "_", ".", "-"])
self.state.username = re.sub(
r"[^a-z0-9._-]", "", f"{base}{sep}{word}"
)[:20]
else:
self.state.username = self._generate_handle()
self.state.email = self.faker.email()
@@ -417,11 +436,16 @@ class DevPlaceBot:
page = next((name for name, hit in pages if hit), "feed")
own_post = page == "post" and await self._is_own_post()
mentioned = own_post and f"@{self.state.username.lower()}" in page_text
own_profile = (
page == "profile"
and f"/profile/{self.state.username.lower()}" in curl.lower()
)
return {
"page": page,
"url": curl,
"visible": await self._page_summary(page),
"own_post": own_post,
"own_profile": own_profile,
"mentioned": mentioned,
"did_post": did_post,
"follows": follows,
@@ -457,11 +481,16 @@ class DevPlaceBot:
add("vote", "star this gist")
add("react", "add an emoji reaction")
elif page == "profile":
if not page_state["follows"]:
add("follow", "follow this developer")
if page_state["can_message"]:
add("message", "send this developer a direct message")
add("open_post", "open one of their posts to read it")
if page_state.get("own_profile"):
if not self.state.profile_filled:
add("update_profile", "fill in your own profile bio and links")
add("open_post", "open one of your posts to read it")
else:
if not page_state["follows"]:
add("follow", "follow this developer")
if page_state["can_message"]:
add("message", "send this developer a direct message")
add("open_post", "open one of their posts to read it")
elif page == "projects_list":
add("vote", "star a project in the list")
if page_state["can_project"]:
@@ -534,6 +563,7 @@ class DevPlaceBot:
"create_post": self._create_post,
"create_gist": self._create_gist,
"create_project": self._click_create_project,
"update_profile": self._update_profile,
"follow": self._follow_user,
"message": self._send_message,
"open_post": self._click_post_link,
@@ -817,6 +847,12 @@ class DevPlaceBot:
mentioned_here = f"@{self.state.username.lower()}" in page_text
thread_id = self._thread_id(curl)
if self._session_comments >= self._session_comment_cap and not mentioned_here:
self._log(
f"Comment cap reached ({self._session_comment_cap}/session), skipping"
)
return False
if kind == "post":
if await self._is_own_post() and not mentioned_here:
self._log("Own post, no mention - not commenting")
@@ -854,6 +890,7 @@ class DevPlaceBot:
if known and random.random() < 0.3:
mention_target = random.choice(known)
style = self.llm.pick_comment_style()
comment = ""
for attempt in range(2):
candidate = await self._generate(
@@ -862,11 +899,12 @@ class DevPlaceBot:
self.state.persona,
mention_target,
"",
style,
)
if not candidate:
continue
verdict = await self._generate(
self.llm.quality_check, "comment", candidate, post_body[:1500]
self.llm.quality_check, "comment", candidate, post_body[:1500], style
)
if verdict is None or verdict[0]:
comment = candidate
@@ -925,6 +963,7 @@ class DevPlaceBot:
if ok:
self.state.comments_posted += 1
self._session_comments += 1
if thread_id and thread_id not in self.state.commented_post_ids:
self.state.commented_post_ids.append(thread_id)
if len(self.state.commented_post_ids) > 1000:
@@ -936,7 +975,11 @@ class DevPlaceBot:
tag,
f"on {kind}{mention_log} [{self.state.comments_posted}]: {comment[:60]}",
)
await asyncio.sleep(2)
await asyncio.sleep(
random.uniform(
COMMENT_COOLDOWN_MIN_SECONDS, COMMENT_COOLDOWN_MAX_SECONDS
)
)
return True
self._log("Comment: submit button not found/enabled")
return False
@@ -946,6 +989,11 @@ class DevPlaceBot:
curl = await b.url()
if "/posts/" not in curl:
return False
if self._session_comments >= self._session_comment_cap:
self._log(
f"Comment cap reached ({self._session_comment_cap}/session), skipping reply"
)
return False
comments = b.page.locator(
".comment, li.comment, article.comment, .comment-item"
)
@@ -997,6 +1045,7 @@ class DevPlaceBot:
self.state.persona,
mentioner,
text,
self.llm.pick_comment_style(),
)
if not reply:
self._log("Reply: generation failed/empty")
@@ -1026,13 +1075,18 @@ class DevPlaceBot:
for sel in submit_sels:
if await b.click(sel):
self.state.comments_posted += 1
self._session_comments += 1
if mentioner not in self.state.known_users:
self.state.known_users.append(mentioner)
self._action(
"REPLY",
f"to @{mentioner} [{self.state.comments_posted}]: {reply[:60]}",
)
await asyncio.sleep(2)
await asyncio.sleep(
random.uniform(
COMMENT_COOLDOWN_MIN_SECONDS, COMMENT_COOLDOWN_MAX_SECONDS
)
)
return True
return False
return False
@@ -1753,6 +1807,8 @@ class DevPlaceBot:
"form[action*='/profile/update'] button[type='submit'], button[type='submit']"
)
if ok:
self.state.profile_filled = True
self._save()
self._action("PROFILE", f"updated own profile ({', '.join(filled)})")
await asyncio.sleep(2)
return True
@@ -2534,6 +2590,15 @@ class DevPlaceBot:
sessions_since_post = 99
sessions_since_message = 99
if self._startup_jitter:
delay = random.uniform(0, self._startup_jitter)
self._log(f"Startup jitter: first session in {delay / 60:.1f}m")
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
self._save()
raise
while True:
session_number += 1
mood, session_len, break_range = await self._session_type()
@@ -2543,6 +2608,10 @@ class DevPlaceBot:
self._session_history = []
self._session_energy = 0.5
self._session_stop_after = 0
self._session_comments = 0
self._session_comment_cap = random.randint(
SESSION_COMMENT_CAP_MIN, SESSION_COMMENT_CAP_MAX
)
use_ai = False
try:
@@ -2569,6 +2638,11 @@ class DevPlaceBot:
await self._warm_cache()
self._log(f"Session active ({mood}) as {self._identity()}")
if not self.state.profile_filled:
await self._update_profile()
await b.goto(f"{self.base_url}/feed")
await b._idle(0.8, 2.0)
if mood in ("normal", "deep"):
await self._check_messages()
await b._idle(0.5, 1.5)
+17 -4
View File
@@ -9,10 +9,23 @@ from playwright.async_api import Page, Playwright, async_playwright
logger = logging.getLogger(__name__)
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
)
DEFAULT_VIEWPORT = {"width": 1280, "height": 900}
class BotBrowser:
def __init__(self, headless: bool = True):
def __init__(
self,
headless: bool = True,
user_agent: str = "",
viewport: Optional[dict] = None,
):
self._headless = headless
self._user_agent = user_agent or DEFAULT_USER_AGENT
self._viewport = dict(viewport) if viewport else dict(DEFAULT_VIEWPORT)
self._playwright: Optional[Playwright] = None
self._browser = None
self._context = None
@@ -26,12 +39,12 @@ class BotBrowser:
"--disable-blink-features=AutomationControlled",
"--no-first-run",
"--disable-dev-shm-usage",
"--window-size=1280,900",
f"--window-size={self._viewport['width']},{self._viewport['height']}",
],
)
self._context = await self._browser.new_context(
user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36",
viewport={"width": 1280, "height": 900},
user_agent=self._user_agent,
viewport=self._viewport,
locale="en-US",
)
self._page = await self._context.new_page()
+41 -1
View File
@@ -22,9 +22,48 @@ PROJECT_STATUSES = ["In Development", "Released"]
ARTICLE_TTL_DAYS = 7
ACTION_PAUSE_MIN_SECONDS = 5
ACTION_PAUSE_MAX_SECONDS = 30
ACTION_PAUSE_MAX_SECONDS = 45
BREAK_SCALE_DEFAULT = 1.0
STARTUP_JITTER_SECONDS_DEFAULT = 14400
SESSION_COMMENT_CAP_MIN = 1
SESSION_COMMENT_CAP_MAX = 3
COMMENT_COOLDOWN_MIN_SECONDS = 60
COMMENT_COOLDOWN_MAX_SECONDS = 240
HANDLE_SUFFIX_WORDS = [
"dev",
"codes",
"hq",
"io",
"lab",
"build",
"works",
"sh",
"fn",
"exe",
]
USER_AGENTS = [
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",
]
VIEWPORTS = [
{"width": 1920, "height": 1080},
{"width": 1536, "height": 864},
{"width": 1440, "height": 900},
{"width": 1366, "height": 768},
{"width": 1280, "height": 800},
{"width": 1600, "height": 900},
]
AI_DECISIONS_DEFAULT = False
DECISION_TEMPERATURE_DEFAULT = 0.4
@@ -92,6 +131,7 @@ SEARCH_TERMS = {
}
MIN_COMMENT_LEN = 40
MIN_SHORT_COMMENT_LEN = 12
MIN_POST_LEN = 120
RESTATEMENT_OVERLAP_THRESHOLD = 0.6
+47 -5
View File
@@ -256,12 +256,25 @@ class LLMClient:
return emoji
return ""
@staticmethod
def pick_comment_style() -> str:
return random.choices(
["standard", "oneliner", "question"],
weights=[55, 23, 22],
k=1,
)[0]
@staticmethod
def is_short_comment_style(style: str) -> bool:
return style in ("oneliner", "question")
def generate_comment(
self,
post_snippet: str,
persona: str = "",
mention_target: str = "",
parent_context: str = "",
style: str = "",
) -> str:
extras = {
"enthusiastic_junior": "Be excited. Use **bold** for agreement. Short replies.",
@@ -277,6 +290,20 @@ class LLMClient:
else " Be casual. No markdown."
)
preserve = persona in ("enthusiastic_junior", "mentor", "storyteller")
if style == "oneliner":
length_rule = (
"Write ONE short casual line, under 12 words, like a quick reply "
"you would type without thinking too hard. Lowercase is fine, "
"contractions are fine, no markdown."
)
preserve = False
elif style == "question":
length_rule = (
"Write a SINGLE pointed question about a specific detail in the post. "
"One sentence. No preamble."
)
else:
length_rule = f"Write 1-3 short sentences.{extra}"
mention_rule = ""
if mention_target:
mention_rule = (
@@ -287,7 +314,7 @@ class LLMClient:
if parent_context:
ctx = f"\n\nReplying to this comment:\n{parent_context[:800]}"
system = (
f"You are a dev replying to a post. Write 1-3 short sentences.{extra}{mention_rule} "
f"You are a dev replying to a post. {length_rule}{mention_rule} "
"No em dashes. Reference a specific detail from the post. "
"Do not open with or rely on generic filler like 'great point', 'I agree', 'nice', "
"'thanks for sharing', or 'interesting'. Add something real: a concrete experience, "
@@ -313,17 +340,25 @@ class LLMClient:
return hits / len(candidate)
def quality_check(
self, kind: str, text: str, context: str = ""
self, kind: str, text: str, context: str = "", style: str = ""
) -> tuple[bool, str]:
from devplacepy.services.bot.config import (
MIN_COMMENT_LEN,
MIN_SHORT_COMMENT_LEN,
MIN_POST_LEN,
RESTATEMENT_OVERLAP_THRESHOLD,
GENERIC_COMMENT_PHRASES,
)
stripped = self.clean(text or "").strip()
min_len = MIN_COMMENT_LEN if kind == "comment" else MIN_POST_LEN
if kind == "comment":
min_len = (
MIN_SHORT_COMMENT_LEN
if self.is_short_comment_style(style)
else MIN_COMMENT_LEN
)
else:
min_len = MIN_POST_LEN
if len(stripped) < min_len:
return False, f"too short ({len(stripped)} < {min_len})"
lowered = stripped.lower()
@@ -593,10 +628,17 @@ class LLMClient:
"rebel": "Be bold about why the usual approach is wrong.",
"storyteller": "Open with the motivation behind it.",
}.get(persona, "")
shape = random.choice(
[
"Write one terse sentence. Just what it is. No tech stack.",
"Write two sentences: what it does and the main tech behind it.",
"Write 2 to 3 sentences including the tech stack and a trade-off you made.",
"Write a short blurb, under 25 words, plain and understated.",
]
)
return self.clean(
self._call(
"You are a developer. Write a compelling 2 to 3 sentence project description "
f"including the tech stack and purpose. {flavor} No em dashes.",
f"You are a developer describing your own project. {shape} {flavor} No em dashes.",
f"Project: {title}",
)
)[:5000]
+1
View File
@@ -24,5 +24,6 @@ class BotRuntimeConfig:
action_pause_min: int = config.ACTION_PAUSE_MIN_SECONDS
action_pause_max: int = config.ACTION_PAUSE_MAX_SECONDS
break_scale: float = config.BREAK_SCALE_DEFAULT
startup_jitter_seconds: int = config.STARTUP_JITTER_SECONDS_DEFAULT
ai_decisions: bool = config.AI_DECISIONS_DEFAULT
decision_temperature: float = config.DECISION_TEMPERATURE_DEFAULT
+11
View File
@@ -169,6 +169,16 @@ class BotsService(BaseService):
help="Scales the between-session break. Below 1 makes the fleet more active (and costlier); above 1 calmer.",
group="Pacing",
),
ConfigField(
"bot_startup_jitter_seconds",
"Startup jitter (s)",
type="int",
default=config.STARTUP_JITTER_SECONDS_DEFAULT,
minimum=0,
maximum=86400,
help="Each bot waits a random delay up to this many seconds before its first session, spreading fleet activity instead of bursting together when the service starts.",
group="Pacing",
),
ConfigField(
"bot_ai_decisions",
"AI-driven decisions",
@@ -269,6 +279,7 @@ class BotsService(BaseService):
action_pause_min=cfg["bot_action_pause_min_seconds"],
action_pause_max=cfg["bot_action_pause_max_seconds"],
break_scale=cfg["bot_break_scale"],
startup_jitter_seconds=cfg["bot_startup_jitter_seconds"],
ai_decisions=cfg["bot_ai_decisions"],
decision_temperature=cfg["bot_decision_temperature"],
)
+3
View File
@@ -15,6 +15,9 @@ class BotState:
password: str = ""
uid: str = ""
persona: str = ""
profile_filled: bool = False
user_agent: str = ""
viewport: dict = field(default_factory=dict)
identity: dict = field(default_factory=dict)
log: list[str] = field(default_factory=list)
known_users: list[str] = field(default_factory=list)