2933 lines
112 KiB
Python
Raw Normal View History

# retoor <retoor@molodetz.nl>
import asyncio
import hashlib
import logging
import random
import re
import time
from datetime import datetime
from typing import Any, Optional
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,
MAX_SIBLING_COMMENTS,
MAX_THREAD_REPLIES,
MENTION_REPLIES_PER_SESSION,
NOTIFICATION_CHECK_PROB,
PERSONAS,
PROJECT_STATUSES,
PROJECT_TYPES,
REACT_RATE_DEFAULT,
REACT_RATES,
SEARCH_TERMS,
SESSION_COMMENT_CAP_MAX,
SESSION_COMMENT_CAP_MIN,
SIBLING_SNIPPET_LEN,
USER_AGENTS,
VIEWPORTS,
persona_article_score,
pick_category,
)
from devplacepy.services.bot.llm import LLMClient
from devplacepy.services.bot.news_fetcher import NewsFetcher
from devplacepy.services.bot.registry import ArticleRegistry
from devplacepy.services.bot.runtime import BotRuntimeConfig
from devplacepy.services.bot.state import BotState
logger = logging.getLogger(__name__)
class DevPlaceBot:
def __init__(self, cfg: BotRuntimeConfig, on_event=None):
self.cfg = cfg
self.base_url = cfg.base_url
self.state_path = cfg.state_path
self.on_event = on_event
self.state = BotState.load(cfg.state_path)
if not self.state.persona:
self.state.persona = random.choice(PERSONAS)
self.llm = LLMClient(
cfg.api_key,
cfg.api_url,
cfg.model,
cfg.input_cost_per_1m,
cfg.output_cost_per_1m,
gist_min_lines=cfg.gist_min_lines,
)
self.llm.total_cost = self.state.total_cost
self.llm.total_calls = self.state.total_calls
self.llm.total_in_tokens = self.state.total_in_tokens
self.llm.total_out_tokens = self.state.total_out_tokens
self.news = NewsFetcher(cfg.news_api)
self.registry = ArticleRegistry(
cfg.registry_path, cfg.max_per_article, cfg.article_ttl_days
)
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()
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._session_mention_replies = 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]] = []
self._session_start_cost: float = 0.0
self._session_start_calls: int = 0
self._session_history: list[str] = []
self._session_energy: float = 0.5
self._session_stop_after: int = 0
def _identity(self) -> str:
return f"{self.state.username or '?'}/{self.state.persona}"
def _cost_tag(self) -> str:
return f"${self.llm.total_cost:.4f} / {self.llm.total_calls} calls"
def _notify(self, line: str) -> None:
if self.on_event:
try:
self.on_event(line)
except Exception as e:
logger.debug("on_event failed: %s", e)
@staticmethod
def _thread_id(url: str) -> str:
m = re.search(r"/(?:posts|gists|news|projects)/([^/?#]+)", url)
return m.group(1) if m else ""
def _sync_cost(self) -> None:
self.state.total_cost = self.llm.total_cost
self.state.total_calls = self.llm.total_calls
self.state.total_in_tokens = self.llm.total_in_tokens
self.state.total_out_tokens = self.llm.total_out_tokens
def _save(self) -> None:
self._sync_cost()
self.state.save(self.state_path)
def _log(self, msg: str) -> None:
self.state.log.append(f"[{time.strftime('%H:%M:%S')}] {msg}")
if len(self.state.log) > 500:
self.state.log = self.state.log[-250:]
logger.info("[%s] %s", self._identity(), msg)
def _action(self, tag: str, detail: str) -> None:
line = f"{tag}: {detail}"
self.state.log.append(f"[{time.strftime('%H:%M:%S')}] {line}")
if len(self.state.log) > 500:
self.state.log = self.state.log[-250:]
logger.info("[%s] %s (%s)", self._identity(), line, self._cost_tag())
self._notify(f"[{self._identity()}] {line}")
async def _generate(self, fn: Any, *args: Any) -> Any:
try:
return await asyncio.to_thread(fn, *args)
except Exception as e:
logger.warning(
"LLM generation failed (%s): %s", getattr(fn, "__name__", fn), e
)
return None
def _cost_summary(self, label: str) -> None:
self._notify(
f"[{self._identity()}] cost ({label}): ${self.llm.total_cost:.6f} "
f"over {self.llm.total_calls} calls (in={self.llm.total_in_tokens} out={self.llm.total_out_tokens})"
)
def _session_summary(self, mood: str, actions: int) -> None:
s = self.state
sess_cost = self.llm.total_cost - self._session_start_cost
sess_calls = self.llm.total_calls - self._session_start_calls
self._notify(
f"[{self._identity()}] session done mood={mood} actions={actions} "
f"posts={s.created_posts} comments={s.comments_posted} votes={s.votes_cast} "
f"session=${sess_cost:.4f}/{sess_calls} lifetime=${self.llm.total_cost:.4f}"
)
def _session_banner(self, mood: str, session_len: int) -> None:
self._notify(
f"[{self._identity()}] session start mood={mood} target~{session_len} actions"
)
def _pick_article(self, articles: list[dict]) -> Optional[dict]:
if not articles:
return None
ranked = sorted(
articles,
key=lambda a: persona_article_score(a, self.state.persona)
+ random.random(),
reverse=True,
)
top = ranked[: max(3, len(ranked) // 3)]
return random.choice(top)
async def _refill_cache_async(self, kind: str) -> None:
try:
if kind == "post":
articles = self.news.fetch()
a = self._pick_article(articles)
if a:
desc = a.get("description", "")[:500]
cat = pick_category(self.state.persona)
post_title = await asyncio.to_thread(
self.llm.generate_post_title,
a["title"],
self.state.persona,
cat,
)
content = await asyncio.to_thread(
self.llm.generate_post,
a["title"],
desc,
self.state.persona,
cat,
)
self._post_cache.append(
(a["title"], post_title or "", desc, content, cat)
)
elif kind == "project":
t = await asyncio.to_thread(
self.llm.generate_project_title, self.state.persona
)
desc = await asyncio.to_thread(
self.llm.generate_project_desc, t, self.state.persona
)
self._project_cache.append((t, desc))
elif kind == "bug":
topic = random.choice(["UI glitch", "Broken link", "Performance issue"])
bug = await asyncio.to_thread(self.llm.generate_bug, topic)
self._bug_cache.append(bug)
except Exception as e:
logger.warning("Cache refill (%s) failed: %s", kind, e)
async def _warm_cache(self) -> None:
self._log("Warming content cache (background)...")
self._post_cache = []
self._project_cache = []
self._bug_cache = []
self.news.fetch()
asyncio.create_task(self._refill_cache_async("post"))
asyncio.create_task(self._refill_cache_async("project"))
asyncio.create_task(self._refill_cache_async("bug"))
def _generate_handle(self) -> str:
first = re.sub(r"[^a-z]", "", self.faker.first_name().lower()) or "dev"
last = re.sub(r"[^a-z]", "", self.faker.last_name().lower()) or "coder"
styles = [
first,
f"{first}.{last}",
f"{first}{last}",
f"{first}_{last}",
f"{first[0]}{last}",
f"{first}{last[0]}",
f"{last}{first[0]}",
]
handle = random.choice(styles)
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"
async def _ensure_auth(self) -> bool:
b = self.b
if not self.state.username:
self.state.username = self._generate_handle()
self.state.email = self.faker.email()
self.state.password = self.faker.password(length=random.randint(12, 18))
self.state.started_at = datetime.now().isoformat()
self._log(f"Identity: {self.state.username} / {self.state.email}")
curl = await b.url()
if "/feed" in curl:
return True
await b.goto(f"{self.base_url}/auth/login")
curl = await b.url()
if "/feed" in curl:
return True
if await b.fill("#email", self.state.email):
await b._idle(0.2, 0.5)
await b.fill("#password", self.state.password)
await b._idle(0.3, 0.8)
await b.click(".auth-submit")
await asyncio.sleep(2)
curl = await b.url()
if "/feed" in curl:
self._log("Logged in")
return True
self._log("Login failed, registering")
for attempt in range(4):
await b.goto(f"{self.base_url}/auth/signup")
if not await b.fill("#username", self.state.username):
return False
await b._idle(0.2, 0.5)
await b.fill("#email", self.state.email)
await b._idle(0.2, 0.5)
await b.fill("#password", self.state.password)
await b._idle(0.2, 0.5)
await b.fill("#confirm_password", self.state.password)
await b._idle(0.5, 1.0)
await b.click(".auth-submit")
await asyncio.sleep(2)
curl = await b.url()
if "/feed" in curl:
self._log(f"Registered as {self.state.username}")
return True
if attempt >= 2:
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()
self._log(f"Signup failed, retrying as {self.state.username}")
return False
async def _read_like_human(self, min_s: float = 3.0, max_s: float = 90.0) -> None:
if not self.state.reading_speed_wpm:
self.state.reading_speed_wpm = random.randint(180, 320)
wpm = self.state.reading_speed_wpm
sec_per_word = 60.0 / wpm
curl = await self.b.url()
if "/posts/" in curl:
body = (
await self.b.text("article") or await self.b.text(".post-content") or ""
)
wc = len(body.split())
jitter = random.uniform(0.7, 1.25)
pause = min(max(wc * sec_per_word * jitter, min_s), max_s)
self._log(f"Reading {wc} words ({pause:.0f}s @ {wpm}wpm)")
scrolls = max(1, int(pause / 12))
chunk = pause / scrolls
for _ in range(scrolls):
await asyncio.sleep(chunk)
if random.random() < 0.55:
await self.b.scroll(random.randint(150, 450))
else:
await asyncio.sleep(random.uniform(1.0, 4.0))
async def _type_like_human(self, sel: str, val: str) -> bool:
el = self.b.page.locator(sel).first
if await el.count() == 0:
return False
try:
await el.click(timeout=3000)
await self.b._idle(0.1, 0.3)
await el.fill("")
words = val.split(" ")
for i, word in enumerate(words):
for ch in word:
delay = random.randint(15, 80)
if ch in ".!?":
delay = random.randint(200, 500)
elif ch == ",":
delay = random.randint(100, 250)
await self.b.page.keyboard.type(ch, delay=delay)
if i < len(words) - 1:
space_delay = random.randint(30, 120)
await self.b.page.keyboard.type(" ", delay=space_delay)
if random.random() < 0.08:
await self.b._idle(0.3, 1.0)
if (i + 1) % 5 == 0 and random.random() < 0.3:
await self.b._idle(0.2, 0.8)
return True
except Exception as e:
logger.debug("_type_like_human failed for '%s': %s", sel, e)
return False
async def _fatigue_check(self, session_actions: int) -> Optional[str]:
if session_actions < 5:
return None
chance = session_actions / 100.0
if random.random() < chance:
if session_actions < 10:
return "casual"
elif session_actions < 20:
return "normal"
else:
return "deep"
return None
async def _session_type(self) -> tuple[str, int, tuple]:
roll = random.random()
if roll < 0.25:
return ("lurking", random.randint(2, 5), (3600, 21600))
elif roll < 0.60:
return ("casual", random.randint(4, 10), (1800, 7200))
elif roll < 0.85:
return ("normal", random.randint(8, 18), (3600, 14400))
else:
return ("deep", random.randint(15, 30), (14400, 43200))
async def _ensure_identity(self) -> None:
if self.state.identity:
return
identity = await self._generate(
self.llm.generate_identity, self.state.persona
)
if identity:
self.state.identity = identity
self._log(
f"Identity card: {identity.get('name', '?')} ({self.state.persona})"
)
self._save()
async def _page_summary(self, page: str) -> str:
b = self.b
title = ""
for sel in ("h1", ".post-title", ".content-title", "title"):
title = (await b.text(sel) or "").strip()
if title:
break
summary = f'title="{title[:100]}"' if title else "no title"
try:
comments = await b.page.locator(".comment, .comment-item").count()
if comments:
summary += f", {comments} comments"
except Exception as e:
logger.debug("page summary count failed: %s", e)
return summary
async def _unread_notification_count(self) -> int:
try:
badge = self.b.page.locator(
"a[data-counter='notifications'] .nav-badge[data-counter-badge]"
).first
if await badge.count() == 0:
return 0
if await badge.get_attribute("hidden") is not None:
return 0
text = (await badge.inner_text() or "").strip()
digits = re.sub(r"[^0-9]", "", text)
return int(digits) if digits else 0
except Exception as e:
logger.debug("_unread_notification_count failed: %s", e)
return 0
async def _page_state(
self,
curl: str,
page_text: str,
*,
did_post: bool,
follows: int,
gists: int,
can_project: bool,
can_post: bool,
can_message: bool,
) -> dict:
pages = [
("post", "/posts/" in curl),
("profile", "/profile/" in curl),
("news_detail", "/news/" in curl),
("news_list", bool(re.search(r"/news(\?|$)", curl))),
("project_detail", "/projects/" in curl),
("projects_list", bool(re.search(r"/projects(\?|$)", curl))),
("gist_detail", "/gists/" in curl),
("gists_list", bool(re.search(r"/gists(\?|$)", curl))),
("notifications", "/notifications" in curl),
]
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,
"gists": gists,
"can_project": can_project,
"can_post": can_post,
"can_message": can_message,
"unread_notifications": await self._unread_notification_count(),
}
def _build_menu(self, page_state: dict) -> list[dict]:
page = page_state["page"]
menu: list[dict] = []
def add(action: str, desc: str) -> None:
menu.append({"action": action, "desc": desc})
if page == "post":
if not page_state["own_post"] or page_state["mentioned"]:
add("comment", "write a comment on this post")
add("reply", "reply to an existing comment in the thread")
add("vote", "upvote this post")
add("vote_comment", "upvote a good comment in the thread")
add("vote_poll", "answer the attached poll if there is one")
add("react", "add an emoji reaction to this post")
elif page == "news_detail":
add("comment", "write a comment reacting to this news article")
elif page == "project_detail":
add("vote", "star this project")
add("react", "add an emoji reaction")
add("comment", "write a comment on this project")
elif page == "gist_detail":
add("comment", "write a comment on this code snippet")
add("vote", "star this gist")
add("react", "add an emoji reaction")
elif page == "profile":
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"]:
add("create_project", "create a new project of your own")
add("open_project", "open a project to read it")
elif page == "gists_list":
add("vote", "star a gist in the list")
if not page_state["gists"]:
add("create_gist", "publish a new code snippet of your own")
add("open_gist", "open a gist to read it")
elif page == "news_list":
add("open_news", "open a news article to read it")
elif page == "notifications":
add("check_notifications", "read and act on your notifications")
else:
add("open_post", "open a post from the feed to read it")
add("vote", "upvote a post in the feed")
add("react", "react to a post in the feed")
add("vote_poll", "answer a poll in the feed")
if page_state["can_post"] and not page_state["did_post"]:
add("create_post", "write a new post reacting to a tech news article")
if not page_state["gists"]:
add("create_gist", "publish a new code snippet of your own")
add("open_profile", "open another developer's profile")
add("search", "search the platform for a topic you care about")
add("browse", "browse a category or section")
if page != "notifications" and page_state.get("unread_notifications"):
add(
"check_notifications",
"open your notifications and reply to anyone who mentioned or replied to you",
)
add("navigate", "move to another section: feed, projects, gists, or news")
return menu
def _record_history(self, action: str, rationale: str) -> None:
note = action.replace("_", " ")
if rationale:
note = f"{note} ({rationale[:60]})"
self._session_history.append(note)
if len(self._session_history) > 20:
self._session_history = self._session_history[-20:]
async def _vote_for_page(self, page: str) -> bool:
if page in ("project_detail", "projects_list"):
return await self._vote_on_project()
if page in ("gist_detail", "gists_list"):
return await self._vote_on_gist()
return await self._vote_on_feed()
async def _navigate_to(self, target: str) -> bool:
routes = {
"feed": "/feed",
"projects": "/projects",
"gists": "/gists",
"news": "/news",
"notifications": "/notifications",
}
path = routes.get((target or "").lower())
if path:
await self.b.goto(f"{self.base_url}{path}")
return True
return await self._random_nav_click()
async def _dispatch_action(self, action: str, target: str, page_state: dict) -> bool:
if action == "navigate":
return await self._navigate_to(target)
if action == "vote":
return await self._vote_for_page(page_state["page"])
handlers = {
"comment": self._comment_on_post,
"reply": self._reply_to_random_comment,
"vote_comment": self._vote_on_comments,
"vote_poll": self._vote_on_poll,
"react": self._react_to_object,
"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,
"open_gist": self._click_gist_link,
"open_profile": self._click_profile_link,
"open_project": lambda: self._open_content("project"),
"open_news": lambda: self._open_content("news"),
"check_notifications": self._check_notifications,
"search": lambda: self._search("feed"),
"browse": self._browse_section,
}
handler = handlers.get(action)
if handler is None:
return False
try:
result = await handler()
except Exception as e:
logger.debug("dispatch %s failed: %s", action, e)
return False
return True if result is None else bool(result)
async def _run_plan(
self, plan: list[dict], page_state: dict
) -> tuple[int, bool, int, int, int]:
b = self.b
actions = 0
did_post = False
projs = 0
follows = 0
gists = 0
if not plan:
await self._navigate_to("feed")
return 0, did_post, projs, follows, gists
for entry in plan:
action = entry["action"]
if action == "create_post" and (did_post or page_state["did_post"]):
continue
if action == "create_gist" and (gists or page_state["gists"]):
continue
if action == "create_project" and projs:
continue
if await self._dispatch_action(action, entry.get("target", ""), page_state):
actions += 1
self._record_history(action, entry.get("rationale", ""))
if action == "create_post":
did_post = True
elif action == "create_project":
projs += 1
elif action == "follow":
follows += 1
elif action == "create_gist":
gists += 1
await b._idle(0.5, 1.5)
return actions, did_post, projs, follows, gists
async def _cycle_ai(
self,
mood: str = "normal",
*,
session_posted: bool = False,
session_projects: int = 0,
session_follows: int = 0,
session_gists: int = 0,
can_project: bool = False,
can_post: bool = False,
can_message: bool = False,
) -> tuple[int, bool, int, int, int]:
b = self.b
curl = await b.url()
page_text = (await b.html()).lower()
page_state = await self._page_state(
curl,
page_text,
did_post=session_posted,
follows=session_follows,
gists=session_gists,
can_project=can_project,
can_post=can_post,
can_message=can_message,
)
if page_state["page"] in (
"post",
"news_detail",
"project_detail",
"gist_detail",
):
await self._read_like_human(4, 90)
menu = self._build_menu(page_state)
decision = await self._generate(
self.llm.decide,
self.state.identity,
page_state,
menu,
self._session_history,
self._decision_temperature,
)
if not decision:
decision = {"plan": [], "energy": self._session_energy, "stop_after": 0}
self._session_energy = decision.get("energy", self._session_energy)
self._session_stop_after = decision.get("stop_after", 0)
actions, did_post, projs, follows, gists = await self._run_plan(
decision["plan"], page_state
)
return (
actions,
did_post or session_posted,
session_projects + projs,
session_follows + follows,
session_gists + gists,
)
def _scaled_pause(self) -> int:
base = random.randint(self._pause_min, self._pause_max)
factor = 1.4 - 0.8 * self._session_energy
return max(1, int(base * factor))
async def _vote_on_feed(self) -> bool:
b = self.b
sel = "button.vote-up, button.post-action-btn.vote-up, form[action*='/votes/'] button[type='submit']"
btns = b.page.locator(sel)
count = await btns.count()
if count == 0:
return False
voted_set = set(self.state.voted_post_ids)
known_set = set(self.state.known_users)
candidates: list[tuple[int, str, bool]] = []
for i in range(min(count, 30)):
btn = btns.nth(i)
post_id = ""
try:
form = btn.locator("xpath=ancestor::form[1]").first
if await form.count() > 0:
action = await form.get_attribute("action") or ""
m = re.search(r"/votes/(?:post|comment|gist)/([a-f0-9-]+)", action)
if m:
post_id = m.group(1)
except Exception:
pass
if not post_id:
try:
card = btn.locator(
"xpath=ancestor::*[contains(@class,'post-card') or self::article][1]"
).first
if await card.count() > 0:
link = card.locator("a[href*='/posts/']").first
if await link.count() > 0:
href = await link.get_attribute("href") or ""
m = re.search(r"/posts/([^/?#]+)", href)
if m:
post_id = m.group(1)
except Exception:
pass
if post_id and post_id in voted_set:
continue
is_known_author = False
try:
card = btn.locator(
"xpath=ancestor::*[contains(@class,'post-card') or self::article][1]"
).first
if await card.count() > 0:
author = card.locator("a[href*='/profile/']").first
if await author.count() > 0:
ah = await author.get_attribute("href") or ""
uname = ah.split("/profile/")[-1].split("?")[0].split("/")[0]
if uname and uname in known_set:
is_known_author = True
except Exception:
pass
candidates.append((i, post_id, is_known_author))
if not candidates:
return False
reciprocal = [c for c in candidates if c[2]]
pool = reciprocal if reciprocal and random.random() < 0.65 else candidates
idx, post_id, _ = random.choice(pool)
try:
await btns.nth(idx).click(timeout=3000)
self.state.votes_cast += 1
if post_id:
self.state.voted_post_ids.append(post_id)
if len(self.state.voted_post_ids) > 1000:
self.state.voted_post_ids = self.state.voted_post_ids[-500:]
self._action(
"VOTE", f"post {post_id[:12] or '?'} (total {self.state.votes_cast})"
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
logger.debug("_vote_on_feed failed: %s", e)
return False
async def _vote_on_comments(self) -> bool:
b = self.b
sel = "button.comment-vote-btn, form[action*='/vote/comment'] button[type='submit']"
btns = b.page.locator(sel)
count = await btns.count()
if count < 2:
return False
idx = random.randrange(count)
try:
await btns.nth(idx).click(timeout=3000)
self.state.votes_cast += 1
self._action("VOTE", f"comment (total {self.state.votes_cast})")
await b._idle(1.0, 2.0)
return True
except Exception as e:
logger.debug("_vote_on_comments failed: %s", e)
return False
async def _is_own_post(self) -> bool:
curl = await self.b.url()
if "/posts/" not in curl:
return False
if curl in self.state.own_post_urls:
return True
author_selectors = [
".post-detail-header a[href*='/profile/']",
".post-detail .post-detail-header a[href*='/profile/']",
".post-author a[href*='/profile/']",
".post-header a[href*='/profile/']",
]
for sel in author_selectors:
loc = self.b.page.locator(sel).first
if await loc.count() == 0:
continue
href = await loc.get_attribute("href") or ""
if "/profile/" not in href:
continue
author = href.split("/profile/")[-1].split("?")[0].split("/")[0].lower()
is_own = author == self.state.username.lower()
if is_own and curl not in self.state.own_post_urls:
self.state.own_post_urls.append(curl)
return is_own
try:
author = await self.b.page.evaluate(
"""(uname) => {
const links = document.querySelectorAll('a[href*="/profile/"]');
for (const a of links) {
let p = a;
let in_nav = false;
while (p && p !== document.body) {
const cn = (p.className || '').toString();
if (p.tagName === 'HEADER' || p.tagName === 'NAV' || /topnav|navbar|sidebar/i.test(cn)) {
in_nav = true;
break;
}
p = p.parentElement;
}
if (in_nav) continue;
const m = a.getAttribute('href').match(/\\/profile\\/([^?/]+)/);
if (m) return m[1];
}
return '';
}""",
self.state.username,
)
except Exception as e:
logger.debug("_is_own_post evaluate failed: %s", e)
return False
if not author:
return False
is_own = author.lower() == self.state.username.lower()
if is_own and curl not in self.state.own_post_urls:
self.state.own_post_urls.append(curl)
return is_own
async def _existing_comments(self) -> list[str]:
bodies = self.b.page.locator(".comment-body")
try:
count = await bodies.count()
except Exception:
return []
if count == 0:
return []
own = self.state.username.lower()
collected: list[str] = []
start = max(0, count - MAX_SIBLING_COMMENTS)
for idx in range(start, count):
node = bodies.nth(idx)
try:
text = (await node.locator(".comment-text").first.inner_text()).strip()
except Exception:
continue
if not text:
continue
author = ""
try:
link = node.locator("a[href*='/profile/']").first
if await link.count() > 0:
href = await link.get_attribute("href") or ""
author = href.split("/profile/")[-1].split("?")[0].split("/")[0]
except Exception:
author = ""
if author and author.lower() == own:
continue
snippet = text[:SIBLING_SNIPPET_LEN]
collected.append(f"{author}: {snippet}" if author else snippet)
return collected
async def _comment_on_post(self) -> bool:
b = self.b
curl = await b.url()
if "/posts/" in curl:
kind = "post"
elif "/gists/" in curl:
kind = "gist"
elif "/news/" in curl:
kind = "news"
elif "/projects/" in curl:
kind = "project"
else:
return False
page_text = (await b.html()).lower()
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")
return False
elif curl in self.state.own_post_urls:
self._log(f"Own {kind} - not commenting")
return False
if (
thread_id
and thread_id in self.state.commented_post_ids
and not mentioned_here
):
self._log(f"Already commented on {thread_id[:12]}, not repeating")
return False
post_body = (
await b.text(".post-content")
or await b.text(".gist-detail-desc")
or await b.text(".news-detail-content")
or await b.text(".news-detail-desc")
or await b.text(".project-detail-desc")
or await b.text(".rendered-content")
or await b.text("article")
or ""
)
if not post_body:
self._log("Comment: no content found")
return False
known = list(
{u for u in self.state.known_users if u and u != self.state.username}
)
mention_target = ""
if known and random.random() < 0.3:
mention_target = random.choice(known)
style = self.llm.pick_comment_style()
siblings = await self._existing_comments()
siblings_block = "\n".join(f"- {c}" for c in siblings)
siblings_text = " ".join(siblings)
comment = ""
for attempt in range(2):
candidate = await self._generate(
self.llm.generate_comment,
post_body[:1500],
self.state.persona,
mention_target,
"",
style,
siblings_block,
)
if not candidate:
continue
verdict = await self._generate(
self.llm.quality_check,
"comment",
candidate,
post_body[:1500],
style,
siblings_text,
)
if verdict is None or verdict[0]:
comment = candidate
break
self.state.quality_rejections += 1
action = "regenerating" if attempt == 0 else "skipping"
self._log(f"Comment quality reject ({verdict[1]}), {action}")
if not comment:
self._log("Comment: no quality candidate")
return False
if mention_target and f"@{mention_target.lower()}" not in comment.lower():
words = comment.split(" ", 1)
if len(words) > 1:
comment = f"{words[0]} @{mention_target} {words[1]}"
else:
comment = f"@{mention_target} {comment}"
comment = comment[:2000]
mention_log = f" @{mention_target}" if mention_target else ""
textarea_sels = [
"form.comment-form textarea[name='content']",
"textarea#comment-content",
"textarea.emoji-picker-target",
"textarea[name='content']",
"#comment-content",
]
ok = False
used_sel = ""
for sel in textarea_sels:
if await self._type_like_human(sel, comment):
ok = True
used_sel = sel
break
if not ok:
self._log("Comment: no textarea found")
return False
self._log(f"Typed comment into {used_sel}")
await b._idle(0.5, 1.5)
submit_sels = [
"form.comment-form button[type='submit']",
"button.btn-primary:has-text('Post')",
"form[action*='/comments/create'] button[type='submit']",
".comment-form button[type='submit']",
]
ok = False
for sel in submit_sels:
if await b.click(sel):
ok = True
break
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:
self.state.commented_post_ids = self.state.commented_post_ids[-500:]
if kind == "news":
self.state.news_comments += 1
tag = "NEWS" if kind == "news" else "COMMENT"
self._action(
tag,
f"on {kind}{mention_log} [{self.state.comments_posted}]: {comment[:60]}",
)
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
async def _reply_to_random_comment(self) -> bool:
b = self.b
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"
)
count = await comments.count()
if count == 0:
return False
limit = min(count, 10)
for _ in range(limit):
idx = random.randrange(limit)
target = comments.nth(idx)
try:
text = (await target.inner_text() or "")[:1000]
except Exception:
continue
if not text:
continue
if f"@{self.state.username.lower()}" in text.lower():
continue
mentioner = ""
try:
author_link = target.locator("a[href*='/profile/']").first
if await author_link.count() > 0:
ah = await author_link.get_attribute("href") or ""
if "/profile/" in ah:
mentioner = (
ah.split("/profile/")[-1].split("?")[0].split("/")[0]
)
except Exception:
pass
if not mentioner or mentioner.lower() == self.state.username.lower():
continue
try:
await target.scroll_into_view_if_needed(timeout=2000)
await b._idle(0.3, 0.8)
reply_btn = target.locator(
".comment-action-btn, button:has-text('Reply'), a:has-text('Reply')"
).first
if await reply_btn.count() == 0:
continue
await reply_btn.click(timeout=3000)
await b._idle(0.5, 1.5)
except Exception as e:
logger.debug("reply-btn click failed: %s", e)
continue
post_body = await b.text(".post-content") or await b.text("article") or ""
siblings = [c for c in await self._existing_comments() if text[:60] not in c]
siblings_block = "\n".join(f"- {c}" for c in siblings)
reply = await self._generate(
self.llm.generate_comment,
post_body[:1200],
self.state.persona,
mentioner,
text,
self.llm.pick_comment_style(),
siblings_block,
)
if not reply:
self._log("Reply: generation failed/empty")
return False
if mentioner and f"@{mentioner.lower()}" not in reply.lower():
reply = f"@{mentioner} {reply}"
reply = reply[:2000]
textarea_sels = [
".reply-form textarea[name='content']",
".comment-form textarea[name='content']",
"textarea[name='content']",
]
typed = False
for sel in textarea_sels:
if await self._type_like_human(sel, reply):
typed = True
break
if not typed:
return False
await b._idle(0.5, 1.5)
submit_sels = [
".reply-form button[type='submit']",
"form.comment-form button[type='submit']",
"button.btn-primary:has-text('Reply')",
"button.btn-primary:has-text('Post')",
]
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(
random.uniform(
COMMENT_COOLDOWN_MIN_SECONDS, COMMENT_COOLDOWN_MAX_SECONDS
)
)
return True
return False
return False
async def _vote_on_gist(self) -> bool:
b = self.b
sel = (
"form[action*='/votes/gist/'] button.gist-card-star, "
"form[action*='/votes/gist/'] button[type='submit']"
)
btns = b.page.locator(sel)
count = await btns.count()
if count == 0:
return False
voted = set(self.state.voted_post_ids)
candidates: list[tuple[int, str]] = []
for i in range(min(count, 30)):
gist_id = ""
try:
form = btns.nth(i).locator("xpath=ancestor::form[1]").first
if await form.count() > 0:
action = await form.get_attribute("action") or ""
m = re.search(r"/votes/gist/([a-f0-9-]+)", action)
if m:
gist_id = m.group(1)
except Exception:
pass
if gist_id and gist_id in voted:
continue
candidates.append((i, gist_id))
if not candidates:
return False
idx, gist_id = random.choice(candidates)
try:
await btns.nth(idx).click(timeout=3000)
self.state.votes_cast += 1
if gist_id:
self.state.voted_post_ids.append(gist_id)
if len(self.state.voted_post_ids) > 1000:
self.state.voted_post_ids = self.state.voted_post_ids[-500:]
self._action(
"VOTE", f"gist {gist_id[:12] or '?'} (total {self.state.votes_cast})"
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
logger.debug("_vote_on_gist failed: %s", e)
return False
async def _vote_on_project(self) -> bool:
b = self.b
sel = (
"form[action*='/votes/project/'] button.project-star-btn, "
"form[action*='/votes/project/'] button[type='submit']"
)
btns = b.page.locator(sel)
count = await btns.count()
if count == 0:
return False
voted = set(self.state.voted_post_ids)
candidates: list[tuple[int, str]] = []
for i in range(min(count, 30)):
project_id = ""
try:
form = btns.nth(i).locator("xpath=ancestor::form[1]").first
if await form.count() > 0:
action = await form.get_attribute("action") or ""
m = re.search(r"/votes/project/([a-f0-9-]+)", action)
if m:
project_id = m.group(1)
except Exception:
pass
if project_id and project_id in voted:
continue
candidates.append((i, project_id))
if not candidates:
return False
idx, project_id = random.choice(candidates)
try:
await btns.nth(idx).click(timeout=3000)
self.state.votes_cast += 1
self.state.projects_starred += 1
if project_id:
self.state.voted_post_ids.append(project_id)
if len(self.state.voted_post_ids) > 1000:
self.state.voted_post_ids = self.state.voted_post_ids[-500:]
self._action(
"STAR",
f"project {project_id[:12] or '?'} (total {self.state.votes_cast})",
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
logger.debug("_vote_on_project failed: %s", e)
return False
async def _vote_on_poll(self) -> bool:
b = self.b
polls = b.page.locator(".poll[data-poll-uid]")
count = await polls.count()
if count == 0:
return False
voted = set(self.state.polls_voted)
for i in range(min(count, 20)):
poll = polls.nth(i)
poll_uid = await poll.get_attribute("data-poll-uid") or ""
if not poll_uid or poll_uid in voted:
continue
if await poll.locator(".poll-option.chosen").count() > 0:
self.state.polls_voted.append(poll_uid)
continue
options = poll.locator(".poll-option:not([disabled])")
opt_count = await options.count()
if opt_count == 0:
continue
choice = options.nth(random.randrange(opt_count))
option_uid = await choice.get_attribute("data-option-uid") or ""
try:
await choice.scroll_into_view_if_needed(timeout=2000)
await b._idle(0.3, 0.8)
await choice.click(timeout=3000)
except Exception as e:
logger.debug("_vote_on_poll click failed: %s", e)
continue
try:
await poll.locator(".poll-option.chosen").first.wait_for(
state="visible", timeout=4000
)
except Exception as e:
logger.debug("_vote_on_poll confirm failed: %s", e)
self.state.polls_voted.append(poll_uid)
if len(self.state.polls_voted) > 1000:
self.state.polls_voted = self.state.polls_voted[-500:]
self.state.poll_votes_cast += 1
self._action(
"POLL",
f"voted {poll_uid[:12]} opt {option_uid[:8] or '?'} (total {self.state.poll_votes_cast})",
)
await b._idle(1.0, 2.0)
return True
return False
async def _reaction_content(self, bar: Any) -> str:
try:
container = bar.locator(
"xpath=ancestor::article[contains(concat(' ',normalize-space(@class),' '),' post-card ')][1]"
" | ancestor::div[contains(concat(' ',normalize-space(@class),' '),' comment ')][1]"
).first
if await container.count() > 0:
text = (await container.inner_text() or "").strip()
if text:
return text[:1200]
except Exception as e:
logger.debug("_reaction_content container failed: %s", e)
body = (
await self.b.text(".post-content")
or await self.b.text(".gist-detail-desc")
or await self.b.text(".news-detail-content")
or await self.b.text(".news-detail-desc")
or await self.b.text(".project-detail-desc")
or await self.b.text(".rendered-content")
or await self.b.text("article")
or ""
)
return body[:1200]
async def _react_to_object(self) -> bool:
b = self.b
bars = b.page.locator(".reaction-bar[data-reaction-uid]")
count = await bars.count()
if count == 0:
return False
decided = set(self.state.reacted_targets)
react_rate = REACT_RATES.get(self.state.persona, REACT_RATE_DEFAULT)
for i in range(min(count, 20)):
bar = bars.nth(i)
uid = await bar.get_attribute("data-reaction-uid") or ""
rtype = await bar.get_attribute("data-reaction-type") or ""
if not uid or uid in decided:
continue
self.state.reacted_targets.append(uid)
if len(self.state.reacted_targets) > 2000:
self.state.reacted_targets = self.state.reacted_targets[-1000:]
if random.random() > react_rate:
self._log(f"Reaction: skipping {rtype} {uid[:12]} (persona reticence)")
return False
snippet = await self._reaction_content(bar)
if not snippet:
self._log(f"Reaction: no content for {rtype} {uid[:12]}")
return False
emoji = await self._generate(
self.llm.select_reaction, snippet, self.state.persona
)
if not emoji:
self._log(f"Reaction: nothing fitting for {rtype} {uid[:12]}")
return False
add_btn = bar.locator(".reaction-add-btn").first
if await add_btn.count() == 0:
return False
palette_btn = bar.locator(
f".reaction-palette-btn[data-reaction-emoji='{emoji}']"
).first
if await palette_btn.count() == 0:
return False
try:
await add_btn.scroll_into_view_if_needed(timeout=2000)
await b._idle(0.2, 0.6)
await add_btn.click(timeout=3000)
await b._idle(0.3, 0.7)
await palette_btn.click(timeout=3000)
except Exception as e:
logger.debug("_react_to_object click failed: %s", e)
return False
try:
await bar.locator(".reaction-chip.reacted").first.wait_for(
state="visible", timeout=4000
)
except Exception as e:
logger.debug("_react_to_object confirm failed: %s", e)
self.state.reactions_cast += 1
self._action(
"REACT",
f"{emoji} on {rtype} {uid[:12]} (total {self.state.reactions_cast})",
)
await b._idle(1.0, 2.0)
return True
return False
async def _create_gist(self) -> bool:
b = self.b
await b.goto(f"{self.base_url}/gists")
if not await b.click("#create-gist-btn, .gists-fab, .feed-fab"):
self._log("Create gist: trigger not found")
return False
await b._idle(0.8, 1.5)
if await b.page.locator("#create-gist-modal").count() == 0:
self._log("Create gist: modal not found")
return False
generated = None
for attempt in range(2):
candidate = await self._generate(self.llm.generate_gist, self.state.persona)
if not candidate:
continue
cand_title, cand_desc, cand_lang, cand_code = candidate
if not cand_code.strip():
continue
verdict = await self._generate(
self.llm.gist_quality_check, cand_title, cand_code, cand_lang
)
if verdict is None or verdict[0]:
generated = candidate
break
self.state.quality_rejections += 1
action = "regenerating" if attempt == 0 else "skipping"
self._log(f"Gist quality reject ({verdict[1]}), {action}")
if not generated:
self._log("Create gist: no quality candidate")
return False
title, desc, language, code = generated
title = self.llm.strip_md(title)[:200] or title[:200]
if not await b.fill("#gist-title", title):
self._log("Create gist: title field not found")
return False
await b._idle(0.2, 0.5)
await b.fill("#gist-description", desc[:500])
await b._idle(0.2, 0.5)
try:
await b.page.select_option("#gist-language", language)
except Exception as e:
logger.debug("gist language select failed: %s", e)
await b._idle(0.3, 0.7)
cm = b.page.locator("#create-gist-modal .CodeMirror")
if await cm.count() == 0:
self._log("Create gist: code editor not found")
return False
try:
await cm.first.click()
await b._idle(0.3, 0.6)
await b.page.evaluate(
"""(code) => {
const el = document.querySelector('#create-gist-modal .CodeMirror');
if (el && el.CodeMirror) { el.CodeMirror.setValue(code); el.CodeMirror.save(); }
const ta = document.querySelector('#gist-source-editor');
if (ta) { ta.value = code; ta.dispatchEvent(new Event('input', {bubbles: true})); }
}""",
code,
)
except Exception as e:
logger.debug("gist code fill failed: %s", e)
return False
await b._idle(0.5, 1.2)
ok = await b.click(
"#create-gist-modal button[type='submit'], #create-gist-modal .btn-primary"
)
if not ok:
self._log("Create gist: submit not found")
return False
await asyncio.sleep(2)
url = await b.url()
if "/gists/" in url:
if url not in self.state.own_post_urls:
self.state.own_post_urls.append(url)
self.state.gists_created += 1
self._action(
"GIST",
f"{title[:40]} [{language}] [{self.state.gists_created}] -> {url[-50:]}",
)
return True
self._log("Create gist: no redirect to gist detail")
return False
async def _create_post(self) -> bool:
b = self.b
await b.goto(f"{self.base_url}/feed")
fab_sel = "#create-post-btn, .feed-fab, button.feed-fab, a.feed-fab"
ok = await b.click(fab_sel)
if not ok:
self._log("Create post: FAB button not found")
return False
self._log("Create post: FAB clicked, waiting for modal")
await b._idle(1.0, 2.0)
modal = await b.page.locator("#create-post-modal").count()
if modal == 0:
self._log("Create post: modal #create-post-modal not in DOM")
return False
hidden = await b.page.locator("#create-post-modal").get_attribute("class") or ""
if "hidden" in hidden or await b.page.locator("#create-post-modal").is_hidden():
self._log("Create post: modal is hidden")
return False
self._log("Create post: modal visible")
article_title = ""
article_desc = ""
post_title = ""
if self._post_cache:
article_title, post_title, article_desc, content, topic = (
self._post_cache.pop(0)
)
asyncio.create_task(self._refill_cache_async("post"))
else:
articles = self.news.fetch()
topic = pick_category(self.state.persona)
article = self.registry.reserve_unused(
articles,
self.state.username,
self.llm.clean,
topic,
self.state.persona,
)
if not article:
self._log("Create post: no unused articles available")
return False
desc = article.get("description", "")[:500]
post_title = await self._generate(
self.llm.generate_post_title,
article["title"],
self.state.persona,
topic,
)
content = await self._generate(
self.llm.generate_post,
article["title"],
desc,
self.state.persona,
topic,
)
if not content:
self._log("Create post: generation failed/empty")
return False
article_title = article["title"]
article_desc = desc
verdict = await self._generate(
self.llm.quality_check, "post", content, article_desc
)
if verdict is not None and not verdict[0]:
self.state.quality_rejections += 1
self._log(f"Post quality reject ({verdict[1]}), regenerating")
content = await self._generate(
self.llm.generate_post,
article_title,
article_desc,
self.state.persona,
topic,
)
if not content:
self._log("Create post: regeneration failed/empty")
return False
verdict = await self._generate(
self.llm.quality_check, "post", content, article_desc
)
if verdict is not None and not verdict[0]:
self.state.quality_rejections += 1
self._log(f"Post quality reject ({verdict[1]}), skipping")
return False
content_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
if content_hash in self.state.posted_content_hashes:
self._log(f"Create post: duplicate content {content_hash}, skipping")
return False
clean_title = self.llm.clean(article_title[:200]) if article_title else ""
if clean_title:
if not self.registry.reserve(clean_title, self.state.username, topic):
self._log(
f"Create post: article '{clean_title[:50]}...' already covered, skipping"
)
return False
type_title = self.llm.strip_md(post_title)[:120] if post_title else ""
if not type_title:
type_title = clean_title
ts = f"input[name='topic'][value='{topic}']"
if await b.page.locator(ts).count() == 0:
self._log(f"Create post: topic radio '{topic}' not found")
else:
await b.click(ts)
await b._idle(0.2, 0.5)
if type_title:
ok = await b.fill("input[name='title']", type_title)
if not ok:
self._log("Create post: title input not found")
await b._idle(0.2, 0.4)
content_sel = "textarea[name='content']"
if await b.page.locator(content_sel).count() == 0:
self._log("Create post: content textarea not found")
return False
await self._type_like_human(content_sel, content[:5000])
await b._idle(0.5, 1.5)
submit_sels = [
"#create-post-modal button[type='submit']",
"#create-post-modal .btn-primary",
".modal-overlay#create-post-modal button[type='submit']",
"#create-post-modal button:has-text('Post')",
]
ok = False
for sel in submit_sels:
if await b.click(sel):
ok = True
break
if not ok:
self._log("Create post: submit button not found")
return False
self.state.created_posts += 1
self.state.posted_content_hashes.append(content_hash)
await asyncio.sleep(2)
post_url = await b.url()
if "/posts/" in post_url and post_url not in self.state.own_post_urls:
self.state.own_post_urls.append(post_url)
if "/posts/" in post_url and post_url not in self.state.known_posts:
self.state.known_posts.append(post_url)
self._action(
"POST",
f"{topic} [{self.state.created_posts}]: {type_title[:50] or content[:50]} -> {post_url[-45:]}",
)
return True
async def _click_post_link(self) -> bool:
b = self.b
links = b.page.locator("a[href*='/posts/']")
count = await links.count()
if count == 0:
return False
candidates = []
for i in range(count):
h = await links.nth(i).get_attribute("href")
if h and "/posts/" in h and len(h) > 20:
candidates.append(i)
if not candidates:
return False
idx = random.choice(candidates)
href = await links.nth(idx).get_attribute("href")
try:
await links.nth(idx).click(timeout=5000)
await b._idle(1.0, 2.0)
if href and href not in self.state.known_posts:
self.state.known_posts.append(href)
self._action("OPEN", f"post {href[-45:] if href else '?'}")
return True
except Exception as e:
logger.debug("_click_post_link failed: %s", e)
return False
async def _click_gist_link(self) -> bool:
b = self.b
links = b.page.locator("a[href*='/gists/']")
count = await links.count()
if count == 0:
return False
candidates = []
for i in range(count):
h = await links.nth(i).get_attribute("href")
if h and "/gists/" in h and "?" not in h and h.rstrip("/") != "/gists":
candidates.append(i)
if not candidates:
return False
idx = random.choice(candidates)
href = await links.nth(idx).get_attribute("href")
try:
await links.nth(idx).click(timeout=5000)
await b._idle(1.0, 2.0)
self._action("OPEN", f"gist {href[-45:] if href else '?'}")
return True
except Exception as e:
logger.debug("_click_gist_link failed: %s", e)
return False
async def _open_content(self, kind: str) -> bool:
b = self.b
base = "/news/" if kind == "news" else f"/{kind}s/"
links = b.page.locator(f"a[href*='{base}']")
count = await links.count()
if count == 0:
return False
candidates = []
for i in range(count):
h = await links.nth(i).get_attribute("href")
if h and base in h and "?" not in h and h.rstrip("/") != base.rstrip("/"):
candidates.append(i)
if not candidates:
return False
idx = random.choice(candidates)
href = await links.nth(idx).get_attribute("href")
try:
await links.nth(idx).click(timeout=5000)
await b._idle(1.0, 2.0)
self._action("OPEN", f"{kind} {href[-45:] if href else '?'}")
return True
except Exception as e:
logger.debug("_open_content(%s) failed: %s", kind, e)
return False
async def _click_profile_link(self) -> bool:
b = self.b
links = b.page.locator("a[href*='/profile/']")
count = await links.count()
if count == 0:
return False
for i in range(count):
h = await links.nth(i).get_attribute("href")
if h and self.state.username not in h:
try:
await links.nth(i).click(timeout=5000)
await b._idle(1.0, 2.0)
uname = h.split("/profile/")[-1].split("?")[0]
if uname not in self.state.known_users:
self.state.known_users.append(uname)
self.state.profiles_viewed += 1
self._action(
"PROFILE", f"viewed @{uname} [{self.state.profiles_viewed}]"
)
return True
except Exception as e:
logger.debug("_click_profile_link failed: %s", e)
continue
return False
async def _click_create_project(self) -> bool:
b = self.b
await b.goto(f"{self.base_url}/projects")
ok = await b.click("#create-project-btn")
if not ok:
return False
await b._idle(0.8, 1.5)
if not await b.text("#create-project-modal"):
return False
if self._project_cache:
raw_title, desc = self._project_cache.pop(0)
asyncio.create_task(self._refill_cache_async("project"))
else:
raw_title = await self._generate(
self.llm.generate_project_title, self.state.persona
)
if not raw_title:
self._log("Create project: title generation failed")
return False
raw_title = raw_title[:200]
desc = await self._generate(
self.llm.generate_project_desc, raw_title, self.state.persona
)
if not desc:
self._log("Create project: description generation failed")
return False
desc = desc[:5000]
title = self.llm.strip_md(raw_title)[:120] or raw_title[:120]
types = ["game", "software", "mobile_app", "website", "game_asset"]
ptype = random.choice(types)
status = random.choice(PROJECT_STATUSES)
await b.fill("#title", title)
await b._idle(0.2, 0.5)
await b.fill("#description", desc)
await b._idle(0.2, 0.5)
await b.click(f"input[name='project_type'][value='{ptype}']")
await b._idle(0.2, 0.5)
await b.click(f"input[name='status'][value='{status}']")
await b._idle(0.2, 0.5)
ok = await b.click("#create-project-modal .btn-primary")
if ok:
self.state.projects_created += 1
self._action(
"PROJECT", f"{title} ({ptype}) [{self.state.projects_created}]"
)
await asyncio.sleep(2)
return True
return False
async def _report_bug(self) -> bool:
b = self.b
await b.goto(f"{self.base_url}/bugs")
ok = await b.click("[data-modal='create-bug-modal']")
if not ok:
return False
await b._idle(0.5, 1.5)
if not await b.text("#create-bug-modal"):
return False
topic = random.choice(
[
"UI glitch",
"Broken link",
"Performance issue",
"Auth problem",
"Formatting error",
]
)
if self._bug_cache:
title, desc = self._bug_cache.pop(0)
asyncio.create_task(self._refill_cache_async("bug"))
else:
generated = await self._generate(self.llm.generate_bug, topic)
if not generated:
self._log("Report bug: generation failed")
return False
title, desc = generated
await b.fill("#bug-title", title[:200])
await b._idle(0.2, 0.5)
await b.fill("textarea[name='description']", desc[:2000])
await b._idle(0.5, 1.0)
ok = await b.click(
"button:has-text('Submit Report'), #create-bug-modal .btn-primary"
)
if ok:
self.state.bugs_filed += 1
self._action("BUG", f"{title[:50]} [{self.state.bugs_filed}]")
await asyncio.sleep(2)
return True
return False
async def _update_profile(self) -> bool:
b = self.b
await b.goto(f"{self.base_url}/profile/{self.state.username}")
ok = await b.click("[data-edit-field='bio']")
if not ok:
return False
await b._idle(0.3, 0.8)
bio = await self._generate(self.llm.generate_bio)
if not bio:
self._log("Update profile: bio generation failed")
return False
bio = bio[:500]
await b.fill("textarea[name='bio']", bio)
await b._idle(0.3, 0.8)
filled = ["bio"]
fields = await self._generate(
self.llm.generate_profile_fields, self.state.username
)
if fields:
location, git_link, website = fields
for field_name, value in (
("location", location),
("git_link", git_link),
("website", website),
):
if not value:
continue
await b.click(f"[data-edit-field='{field_name}']")
await b._idle(0.2, 0.5)
if await b.fill(
f"input[name='{field_name}'], textarea[name='{field_name}']", value
):
filled.append(field_name)
await b._idle(0.2, 0.5)
ok = await b.click(
"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
return False
async def _follow_user(self) -> bool:
b = self.b
curl = await b.url()
if "/profile/" not in curl:
return False
uname = curl.split("/profile/")[-1].split("?")[0]
if uname == self.state.username:
return False
form = b.page.locator("form[action*='/follow/'] button[type='submit']")
if await form.count() == 0:
return False
text = (await form.first.inner_text()).strip()
if text.lower() not in ("follow", "unfollow"):
return False
try:
await form.first.click(timeout=3000)
self._action(
"FOLLOW",
f"{'followed' if text.lower() == 'follow' else 'unfollowed'} @{uname}",
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
logger.debug("_follow_user failed: %s", e)
return False
def _classify_notification(self, text: str) -> bool:
tl = text.lower()
return (
f"@{self.state.username.lower()}" in tl
or "mentioned you" in tl
or "replied to your" in tl
or "replied to you" in tl
or "tagged you" in tl
)
async def _collect_notifications(self) -> list[dict]:
b = self.b
cards = b.page.locator(".notification-card")
try:
count = await cards.count()
except Exception:
count = 0
if count == 0:
return await self._collect_notifications_fallback()
items: list[dict] = []
seen_keys: set = set()
for i in range(min(count, 30)):
card = cards.nth(i)
text = ""
try:
body = card.locator(".notification-text").first
if await body.count() > 0:
text = (await body.inner_text() or "")[:600]
except Exception:
text = ""
if not text:
try:
text = (await card.inner_text() or "")[:600]
except Exception:
text = ""
href = ""
try:
link = card.locator("a.card-link").first
if await link.count() > 0:
href = (await link.get_attribute("href")) or ""
except Exception:
href = ""
unread = False
try:
cls = (await card.get_attribute("class")) or ""
unread = "unread" in cls.split()
except Exception:
unread = False
if not text and not href:
continue
key = hashlib.sha256(f"{href}|{text[:200]}".encode()).hexdigest()[:24]
if key in seen_keys:
continue
seen_keys.add(key)
items.append(
{
"key": key,
"href": href,
"text": text,
"is_mention": self._classify_notification(text),
"unread": unread,
}
)
return items
async def _collect_notifications_fallback(self) -> list[dict]:
b = self.b
loc = b.page.locator(".notification-item, .notif-item, [data-notification-id]")
try:
count = await loc.count()
except Exception:
count = 0
items: list[dict] = []
seen_keys: set = set()
for i in range(min(count, 30)):
el = loc.nth(i)
try:
text = (await el.inner_text() or "")[:600]
except Exception:
text = ""
href = ""
try:
inner = el.locator("a[href]").first
if await inner.count() > 0:
href = (await inner.get_attribute("href")) or ""
except Exception:
href = ""
if not text and not href:
continue
key = hashlib.sha256(f"{href}|{text[:200]}".encode()).hexdigest()[:24]
if key in seen_keys:
continue
seen_keys.add(key)
items.append(
{
"key": key,
"href": href,
"text": text,
"is_mention": self._classify_notification(text),
"unread": True,
}
)
return items
async def _check_notifications(self) -> bool:
b = self.b
await b.goto(f"{self.base_url}/notifications")
await b._idle(0.8, 1.8)
notifications = await self._collect_notifications()
if not notifications:
self._log("Notifications: none")
return True
unseen_mentions = [
n
for n in notifications
if n["is_mention"]
and n["unread"]
and n["key"] not in self.state.notifications_seen
]
self._log(
f"Notifications: {len(notifications)} ({len(unseen_mentions)} unseen mentions)"
)
self.state.mentions_received += len(unseen_mentions)
replied = 0
for n in unseen_mentions:
if self._session_mention_replies >= MENTION_REPLIES_PER_SESSION:
self._log(
f"Mention reply cap reached ({MENTION_REPLIES_PER_SESSION}/session), leaving the rest"
)
break
ok = await self._respond_to_mention(n["href"], n["text"])
if ok:
self.state.mentions_replied += 1
self._session_mention_replies += 1
replied += 1
self.state.notifications_seen.append(n["key"])
if len(self.state.notifications_seen) > 500:
self.state.notifications_seen = self.state.notifications_seen[-250:]
await b._idle(2.0, 5.0)
if replied == 0:
remaining = [
n
for n in notifications
if n["key"] not in self.state.notifications_seen
]
if remaining:
pick = random.choice(remaining)
target = pick["href"]
if target and not target.startswith("http"):
target = self.base_url + (
target if target.startswith("/") else "/" + target
)
if target:
try:
await b.goto(target)
self._log(f"Notifications: opened {target[-60:]}")
except Exception as e:
logger.debug("notification goto failed: %s", e)
self.state.notifications_seen.append(pick["key"])
try:
mark = b.page.locator(
"button:has-text('Mark all read'), form[action*='/mark-all-read'] button"
)
if await mark.count() > 0:
await mark.first.click(timeout=3000)
await b._idle(0.5, 1.5)
except Exception as e:
logger.debug("mark-read failed: %s", e)
return True
async def _respond_to_mention(self, href: str, snippet: str) -> bool:
b = self.b
target = href
if target and not target.startswith("http"):
target = self.base_url + (href if href.startswith("/") else "/" + href)
if not target:
return False
try:
await b.goto(target)
except Exception as e:
logger.debug("mention goto failed: %s", e)
return False
await b._idle(2.0, 4.0)
curl = await b.url()
if not any(k in curl for k in ("/posts/", "/gists/", "/projects/", "/news/")):
self._log("Mention target has no comment thread, skipping")
return False
thread_id = self._thread_id(curl)
if (
thread_id
and self.state.thread_reply_counts.get(thread_id, 0) >= MAX_THREAD_REPLIES
):
self._log(
f"Conversation taper on {thread_id[:12]} ({MAX_THREAD_REPLIES} replies), letting it rest"
)
return False
needle = f"@{self.state.username}"
comment_loc = None
comment_selectors = [
f".comment:has-text('{needle}')",
f"li.comment:has-text('{needle}')",
f".comment-item:has-text('{needle}')",
f"article.comment:has-text('{needle}')",
]
for sel in comment_selectors:
try:
loc = b.page.locator(sel).first
if await loc.count() > 0:
comment_loc = loc
break
except Exception:
continue
parent_text = ""
mentioner = ""
if comment_loc is not None:
try:
parent_text = (await comment_loc.inner_text() or "")[:1000]
except Exception:
parent_text = ""
try:
author_link = comment_loc.locator("a[href*='/profile/']").first
if await author_link.count() > 0:
ah = await author_link.get_attribute("href") or ""
if "/profile/" in ah:
mentioner = (
ah.split("/profile/")[-1].split("?")[0].split("/")[0]
)
except Exception:
mentioner = ""
try:
await comment_loc.scroll_into_view_if_needed(timeout=2000)
await b._idle(0.3, 0.9)
except Exception as e:
logger.debug("scroll mention failed: %s", e)
try:
reply_btn = comment_loc.locator(
".comment-action-btn, button:has-text('Reply'), a:has-text('Reply')"
).first
if await reply_btn.count() > 0:
await reply_btn.click(timeout=3000)
await b._idle(0.5, 1.5)
except Exception as e:
logger.debug("reply-btn click failed: %s", e)
try:
post_body = (
await b.text(".post-content")
or await b.text(".gist-detail-desc")
or await b.text(".news-detail-content")
or await b.text(".news-detail-desc")
or await b.text(".project-detail-desc")
or await b.text(".rendered-content")
or await b.text("article")
or snippet
)
except Exception:
post_body = snippet
reply = await self._generate(
self.llm.generate_comment,
post_body[:1500],
self.state.persona,
mentioner,
parent_text,
)
if not reply:
self._log("Mention reply: generation failed/empty")
return False
if mentioner and f"@{mentioner.lower()}" not in reply.lower():
reply = f"@{mentioner} {reply}"
reply = reply[:2000]
textarea_sels = [
".comment .reply-form textarea[name='content']",
".reply-form textarea[name='content']",
"form.comment-form textarea[name='content']",
"textarea#comment-content",
"textarea.emoji-picker-target",
"textarea[name='content']",
]
typed = False
for sel in textarea_sels:
if await self._type_like_human(sel, reply):
typed = True
break
if not typed:
self._log("Mention reply: no textarea found")
return False
await b._idle(0.5, 1.5)
submit_sels = [
".reply-form button[type='submit']",
"form.comment-form button[type='submit']",
"button.btn-primary:has-text('Reply')",
"button.btn-primary:has-text('Post')",
"form[action*='/comments/create'] button[type='submit']",
".comment-form button[type='submit']",
]
for sel in submit_sels:
if await b.click(sel):
self.state.comments_posted += 1
self.state.replies_to_own_posts += 1
if thread_id:
self.state.thread_reply_counts[thread_id] = (
self.state.thread_reply_counts.get(thread_id, 0) + 1
)
if len(self.state.thread_reply_counts) > 500:
recent = list(self.state.thread_reply_counts.items())[-250:]
self.state.thread_reply_counts = dict(recent)
self._action("MENTION", f"replied to @{mentioner or '?'}: {reply[:60]}")
await asyncio.sleep(2)
return True
self._log("Mention reply: submit failed")
return False
async def _check_messages(self) -> bool:
b = self.b
await b.goto(f"{self.base_url}/messages")
await b._idle()
conv = b.page.locator(
".messages-conversations a, .conversation-item, .message-item, .messages-list a"
)
if await conv.count() == 0:
self._log("Messages: no conversations")
return True
try:
await conv.first.click(timeout=3000)
await b._idle(1.0, 2.0)
except Exception as e:
logger.debug("_check_messages open failed: %s", e)
return True
if random.random() < 0.7:
return await self._compose_message(reply=True)
self._log("Messages: read conversation")
return True
async def _spoke_last_in_thread(self) -> bool:
bubbles = self.b.page.locator(".messages-thread .message-bubble")
count = await bubbles.count()
if count == 0:
return False
try:
classes = await bubbles.nth(count - 1).get_attribute("class") or ""
except Exception as e:
logger.debug("_spoke_last_in_thread read failed: %s", e)
return False
return "mine" in classes.split()
async def _compose_message(self, reply: bool = False) -> bool:
b = self.b
box = b.page.locator(
"form[action*='/messages/send'] input[name='content'], "
"input[name='content'][placeholder*='message'], "
"form[action*='/messages/send'] textarea[name='content']"
)
if await box.count() == 0:
self._log("Message: compose box not found")
return False
if await self._spoke_last_in_thread():
self._log("Message: I spoke last; waiting for a reply (dialog, not monologue)")
return False
context = ""
try:
context = (
await b.text(".messages-thread") or await b.text(".message-list") or ""
)[:400]
except Exception:
context = ""
text = await self._generate(self.llm.generate_dm, self.state.persona, context)
if not text:
return False
text = text[:500]
if not await self._type_like_human(
"form[action*='/messages/send'] input[name='content'], input[name='content'], "
"form[action*='/messages/send'] textarea[name='content']",
text,
):
return False
await b._idle(0.4, 1.0)
if await b.click(
"button.messages-send-btn, form[action*='/messages/send'] button[type='submit']"
):
self.state.messages_sent += 1
self._action(
"MESSAGE",
f"{'replied' if reply else 'sent'} [{self.state.messages_sent}]: {text[:50]}",
)
await asyncio.sleep(2)
return True
return False
async def _send_message(self) -> bool:
b = self.b
curl = await b.url()
if "/profile/" not in curl:
return False
btn = b.page.locator("a[href*='with_uid=']").first
if await btn.count() == 0:
return False
href = await btn.get_attribute("href") or ""
if "with_uid=" not in href:
return False
try:
await b.goto((self.base_url + href) if href.startswith("/") else href)
await b._idle(1.0, 2.0)
except Exception as e:
logger.debug("_send_message goto failed: %s", e)
return False
return await self._compose_message(reply=False)
async def _scroll_page(self) -> None:
await self.b.scroll(random.randint(300, 900))
self._log("Scrolled down")
async def _random_nav_click(self) -> bool:
b = self.b
nav_links = b.page.locator(".topnav-links a, .topnav a, a.topnav-link")
count = await nav_links.count()
if count == 0:
nav_links = b.page.locator(
"a[href='/feed'], a[href='/projects'], a[href='/messages'], a[href='/notifications']"
)
count = await nav_links.count()
if count == 0:
return False
picked = random.randrange(count)
try:
text = await nav_links.nth(picked).inner_text()
href = await nav_links.nth(picked).get_attribute("href")
await nav_links.nth(picked).click(timeout=5000)
await b._idle(1.0, 2.0)
self._log(f"Nav: {text.strip()[:30] if text else href}")
return True
except Exception as e:
logger.debug("_random_nav_click failed: %s", e)
return False
async def _browse_section(self) -> bool:
section, query = random.choice(
[
("feed", f"?tab={random.choice(['trending', 'recent', 'following'])}"),
("feed", f"?topic={random.choice(FEED_TOPICS)}"),
("projects", f"?type={random.choice(PROJECT_TYPES)}"),
("gists", f"?language={random.choice(GIST_LANGUAGES)}"),
]
)
await self.b.goto(f"{self.base_url}/{section}{query}")
self._action("BROWSE", f"/{section}{query}")
await self.b._idle(0.8, 1.8)
return True
async def _search(self, section: str = "feed") -> bool:
terms = list(
SEARCH_TERMS.get(self.state.persona, ["python", "rust", "ai", "web"])
)
for href in self.state.known_posts[-10:]:
slug = href.rsplit("/", 1)[-1]
words = [w for w in re.split(r"[-_]", slug) if len(w) > 4]
if words:
terms.append(random.choice(words))
term = random.choice(terms)
await self.b.goto(f"{self.base_url}/{section}?search={term.replace(' ', '+')}")
self.state.searches_run += 1
self._action("SEARCH", f"/{section} '{term}'")
await self.b._idle(0.8, 1.8)
return True
async def _engage_community(self) -> bool:
b = self.b
tab = random.choice(["recent", "recent", "trending"])
await b.goto(f"{self.base_url}/feed?tab={tab}")
await b._idle(0.8, 1.8)
links = b.page.locator("a[href*='/posts/']")
count = await links.count()
if count == 0:
return False
own = set(self.state.own_post_urls)
known = {u.lower() for u in self.state.known_users if u}
me = self.state.username.lower()
ranked: list[tuple[float, int, str]] = []
for i in range(min(count, 30)):
link = links.nth(i)
href = await link.get_attribute("href") or ""
if "/posts/" not in href or len(href) < 20:
continue
if any(href in u for u in own):
continue
score = random.random()
try:
card = link.locator(
"xpath=ancestor::*[contains(@class,'post-card') or self::article][1]"
).first
if await card.count() > 0:
author = card.locator("a[href*='/profile/']").first
if await author.count() > 0:
ah = await author.get_attribute("href") or ""
uname = (
ah.split("/profile/")[-1].split("?")[0].split("/")[0].lower()
)
if uname and uname == me:
continue
if uname in known:
score += 2.0
except Exception:
pass
ranked.append((score, i, href))
if not ranked:
return False
ranked.sort(key=lambda r: r[0], reverse=True)
idx, href = ranked[0][1], ranked[0][2]
try:
await links.nth(idx).click(timeout=5000)
except Exception as e:
logger.debug("_engage_community open failed: %s", e)
return False
await b._idle(1.0, 2.0)
if href and href not in self.state.known_posts:
self.state.known_posts.append(href)
await self._read_like_human(3, 60)
acted = False
if await self._comment_on_post():
acted = True
await b._idle(0.5, 1.5)
if random.random() < 0.6 and await self._reply_to_random_comment():
acted = True
await b._idle(0.4, 1.2)
if random.random() < 0.7:
await self._vote_on_feed()
if random.random() < 0.5:
await self._react_to_object()
if acted:
self._action("ENGAGE", f"community thread {href[-40:]}")
return acted
async def _cycle(
self,
mood: str = "normal",
*,
session_posted: bool = False,
session_projects: int = 0,
session_follows: int = 0,
session_gists: int = 0,
can_project: bool = False,
can_post: bool = False,
can_message: bool = False,
) -> tuple[int, bool, int, int, int]:
b = self.b
curl = await b.url()
page_text = (await b.html()).lower()
on_post = "/posts/" in curl
on_profile = "/profile/" in curl
on_project_detail = "/projects/" in curl
on_projects_list = bool(re.search(r"/projects(\?|$)", curl))
on_gist_detail = "/gists/" in curl
on_gists_list = bool(re.search(r"/gists(\?|$)", curl))
on_news_detail = "/news/" in curl
on_news_list = bool(re.search(r"/news(\?|$)", curl))
on_notifications = "/notifications" in curl
actions = 0
did_post = session_posted
projs = session_projects
follows = session_follows
gists = session_gists
msgs = 0
mood_factor = {"lurking": 0.3, "casual": 0.6, "normal": 1.0, "deep": 1.5}
burst = max(1, int(random.gauss(3, 1) * mood_factor[mood]))
for _ in range(burst):
if mood == "lurking" and not on_post and random.random() < 0.4:
await self._scroll_page()
await b._idle(0.3, 1.0)
actions += 1
curl = await b.url()
page_text = (await b.html()).lower()
on_post = "/posts/" in curl
continue
if on_post:
page_text = (await b.html()).lower()
own_post = await self._is_own_post()
mentioned = own_post and f"@{self.state.username.lower()}" in page_text
await self._read_like_human(4, 90)
if own_post and not mentioned:
self._log("Own post, no mention - skipping interactions")
await self._random_nav_click()
await b._idle(1.0, 2.5)
else:
comment_p = {
"lurking": 0.5,
"casual": 0.75,
"normal": 0.85,
"deep": 0.9,
}[mood]
if random.random() < comment_p:
if await self._comment_on_post():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.5:
if await self._reply_to_random_comment():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.7:
if await self._vote_on_feed():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.3:
if await self._vote_on_comments():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.6:
if await self._vote_on_poll():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.6:
if await self._react_to_object():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.75:
await b.goto(f"{self.base_url}/feed")
await b._idle(0.8, 2.0)
elif random.random() < 0.3:
await self._random_nav_click()
await b._idle(1.0, 2.5)
elif on_profile:
if not follows and random.random() < 0.4:
if await self._follow_user():
follows += 1
actions += 1
await b._idle(0.5, 1.5)
if can_message and not msgs and random.random() < 0.3:
if await self._send_message():
msgs += 1
actions += 1
await b._idle(0.5, 1.5)
await self._scroll_page()
await b._idle(0.5, 1.0)
if random.random() < 0.3:
if await self._click_post_link():
actions += 1
await self._random_nav_click()
await b._idle(1.0, 2.0)
actions += 1
elif on_news_detail:
await self._read_like_human(4, 60)
comment_p = {"lurking": 0.4, "casual": 0.6, "normal": 0.7, "deep": 0.8}[
mood
]
if random.random() < comment_p:
if await self._comment_on_post():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.7:
await b.goto(f"{self.base_url}/feed")
await b._idle(0.8, 1.8)
else:
await self._random_nav_click()
await b._idle(1.0, 2.5)
elif on_news_list:
await self._scroll_page()
await b._idle(0.4, 1.0)
if random.random() < 0.7:
if await self._open_content("news"):
actions += 1
else:
await self._random_nav_click()
await b._idle(0.8, 1.8)
actions += 1
elif on_project_detail:
await self._read_like_human(4, 60)
if random.random() < 0.6:
if await self._vote_on_project():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.5:
if await self._react_to_object():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.4:
if await self._comment_on_post():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.7:
await b.goto(f"{self.base_url}/projects")
await b._idle(0.8, 1.8)
else:
await self._random_nav_click()
await b._idle(1.0, 2.5)
elif on_projects_list:
if random.random() < 0.55:
if await self._vote_on_project():
actions += 1
await b._idle(0.5, 1.5)
if can_project and projs < 1 and random.random() < 0.3:
if await self._click_create_project():
projs += 1
actions += 1
await b._idle(0.5, 1.0)
elif random.random() < 0.6:
if await self._open_content("project"):
actions += 1
else:
await self._scroll_page()
await b._idle(0.8, 1.8)
actions += 1
elif on_gist_detail:
await self._read_like_human(4, 60)
if random.random() < 0.45:
if await self._comment_on_post():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.6:
if await self._vote_on_feed():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.5:
if await self._react_to_object():
actions += 1
await b._idle(0.5, 1.5)
if random.random() < 0.4:
await self._random_nav_click()
await b._idle(1.0, 2.5)
elif on_gists_list:
if random.random() < 0.6:
if await self._vote_on_gist():
actions += 1
await b._idle(0.5, 1.5)
if gists < 1 and random.random() < 0.25:
if await self._create_gist():
gists += 1
actions += 1
await b._idle(0.5, 1.0)
elif random.random() < 0.5:
if await self._click_gist_link():
actions += 1
else:
await self._scroll_page()
await b._idle(0.8, 1.8)
actions += 1
elif on_notifications:
await self._check_notifications()
await b._idle(1.0, 2.0)
await self._random_nav_click()
actions += 1
else:
clicked = False
if random.random() < 0.3:
acted = await self._react_to_object()
if not acted:
acted = await self._vote_on_poll()
if acted:
actions += 1
clicked = True
if not clicked and random.random() < 0.12:
if random.random() < 0.5:
await self._search(
"feed" if random.random() < 0.7 else "projects"
)
else:
await self._browse_section()
actions += 1
clicked = True
if not clicked and random.random() < 0.85:
if await self._click_post_link():
clicked = True
actions += 1
if not clicked and can_post and not did_post and random.random() < 0.5:
if await self._create_post():
did_post = True
actions += 1
clicked = True
if not clicked and random.random() < 0.2:
if await self._click_profile_link():
clicked = True
actions += 1
if not clicked and gists < 1 and random.random() < 0.1:
if await self._create_gist():
gists += 1
actions += 1
clicked = True
if not clicked:
if await self._vote_on_feed():
actions += 1
clicked = True
if not clicked:
if await self._random_nav_click():
clicked = True
if not clicked:
await self._scroll_page()
actions += 1
await b._idle(1.0, 2.5)
curl = await b.url()
page_text = (await b.html()).lower()
on_post = "/posts/" in curl
on_profile = "/profile/" in curl
on_project_detail = "/projects/" in curl
on_projects_list = bool(re.search(r"/projects(\?|$)", curl))
on_gist_detail = "/gists/" in curl
on_gists_list = bool(re.search(r"/gists(\?|$)", curl))
on_news_detail = "/news/" in curl
on_news_list = bool(re.search(r"/news(\?|$)", curl))
on_notifications = "/notifications" in curl
return actions, did_post, projs, follows, gists
async def run_forever(self, action_limit: int = 0) -> None:
b = self.b
self._save()
lifetime_actions = 0
crash_streak = 0
session_number = 0
sessions_since_project = 99
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()
self._session_start_cost = self.llm.total_cost
self._session_start_calls = self.llm.total_calls
self._session_banner(mood, session_len)
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
)
self._session_mention_replies = 0
use_ai = False
try:
await b.launch()
except Exception as e:
self._log(f"Launch failed: {e}")
crash_streak += 1
wait = min(30 * crash_streak, 300)
await asyncio.sleep(wait)
continue
session_ok = False
try:
if not await self._ensure_auth():
self._log("Auth failed")
await b.close()
await asyncio.sleep(60)
continue
if self._ai_decisions:
await self._ensure_identity()
use_ai = self._ai_decisions and bool(self.state.identity)
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)
unread = await self._unread_notification_count()
if unread:
self._log(f"Unread notifications badge: {unread}")
check_prob = NOTIFICATION_CHECK_PROB.get(mood, 1.0)
want_notifications = mood in ("normal", "deep") or (
unread > 0 and random.random() < check_prob
)
if mood in ("normal", "deep"):
await self._check_messages()
await b._idle(0.5, 1.5)
if want_notifications:
await self._check_notifications()
await b._idle(1.0, 3.0)
if mood in ("normal", "deep"):
await self._engage_community()
await b._idle(0.8, 2.0)
await b.goto(f"{self.base_url}/feed")
session_actions = 0
max_actions = session_len + random.randint(0, 5)
session_posted = False
session_projects = 0
session_follows = 0
session_gists = 0
session_messages_start = self.state.messages_sent
sessions_since_project += 1
sessions_since_post += 1
sessions_since_message += 1
can_create_project = (
sessions_since_project >= 3 and random.random() < 0.3
)
can_create_post = sessions_since_post >= 2 and random.random() < 0.5
can_create_message = (
sessions_since_message >= 2 and random.random() < 0.4
)
while session_actions < max_actions:
if action_limit and lifetime_actions >= action_limit:
self._log(f"Hit max actions ({action_limit}), stopping")
self._save()
self._cost_summary("final")
return
(
taken,
session_posted,
session_projects,
session_follows,
session_gists,
) = await (self._cycle_ai if use_ai else self._cycle)(
mood=mood,
session_posted=session_posted,
session_projects=session_projects,
session_follows=session_follows,
session_gists=session_gists,
can_project=can_create_project,
can_post=can_create_post,
can_message=can_create_message,
)
if session_posted:
sessions_since_post = 0
if session_projects:
sessions_since_project = 0
if self.state.messages_sent > session_messages_start:
sessions_since_message = 0
session_actions += max(taken, 1)
lifetime_actions += max(taken, 1)
self._save()
crash_streak = 0
if use_ai:
if (
self._session_stop_after
and session_actions >= self._session_stop_after
):
self._log(
f"Energy spent (stop_after={self._session_stop_after}): ending session"
)
break
intra_pause = self._scaled_pause()
else:
fatigue = await self._fatigue_check(session_actions)
if fatigue:
self._log(f"Fatigue ({fatigue}): ending session")
break
intra_pause = random.randint(self._pause_min, self._pause_max)
self._log(
f"Session pause {intra_pause}s [{session_actions}/{max_actions}] {self._cost_tag()}"
)
await asyncio.sleep(intra_pause)
session_ok = session_actions > 0
self._session_summary(mood, session_actions)
except asyncio.CancelledError:
self._save()
try:
await b.close()
except Exception as e:
logger.debug("close on cancel failed: %s", e)
raise
except Exception as e:
crash_streak += 1
self._log(f"Session crash ({crash_streak}): {e}")
self._save()
finally:
try:
await b.close()
except Exception as e:
logger.debug("close in finally failed: %s", e)
if crash_streak > 3:
self._log("Too many consecutive crashes, exiting")
self._save()
self._cost_summary("final")
break
if not session_ok and crash_streak > 0:
break_s = random.randint(30, 120)
self._log(f"Recovery wait {break_s}s")
else:
break_s = max(5, int(random.randint(*break_range) * self._break_scale))
if use_ai:
factor = 1.5 - 1.0 * self._session_energy
break_s = max(60, int(break_s * factor))
break_h = break_s / 3600
self._log(f"Session done. Break {break_h:.1f}h")
self._save()
try:
await asyncio.sleep(break_s)
except asyncio.CancelledError:
self._save()
raise
self._cost_summary("final")
self._log("Bot process exiting")