Добавить бота копилки huEVO.
Сессия через Bun.WebView, в каждом интервале уходит 190–240 кликов с паузой под лимит сервера. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
env
|
||||||
|
profile/
|
||||||
17
huevo-clicker.service
Normal file
17
huevo-clicker.service
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=huEVO clicker
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/huevo-clicker
|
||||||
|
EnvironmentFile=/opt/huevo-clicker/env
|
||||||
|
Environment=HOME=/root
|
||||||
|
ExecStart=/usr/local/bin/bun /opt/huevo-clicker/index.ts
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
194
index.ts
Normal file
194
index.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
const username = process.env.HUEVO_USER || "";
|
||||||
|
const password = process.env.HUEVO_PASSWORD || "";
|
||||||
|
const profile = process.env.HUEVO_PROFILE || "/opt/huevo-clicker/profile";
|
||||||
|
const CLICKS_MIN = 190;
|
||||||
|
const CLICKS_MAX = 240;
|
||||||
|
const CLICKS_PER_SEC = 15.5;
|
||||||
|
const MIN_GAP_MS = 8_000;
|
||||||
|
const WAIT_PAD_MS = 300;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
console.error("HUEVO_USER and HUEVO_PASSWORD are required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(message: string) {
|
||||||
|
console.log(`${new Date().toISOString()} ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchSize() {
|
||||||
|
return CLICKS_MIN + Math.floor(Math.random() * (CLICKS_MAX - CLICKS_MIN + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitMsFor(clicks: number) {
|
||||||
|
return Math.max(MIN_GAP_MS, Math.ceil((clicks / CLICKS_PER_SEC) * 1000) + WAIT_PAD_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openView() {
|
||||||
|
return new Bun.WebView({
|
||||||
|
width: 1280,
|
||||||
|
height: 900,
|
||||||
|
dataStore: { directory: profile },
|
||||||
|
backend: {
|
||||||
|
type: "chrome",
|
||||||
|
url: false,
|
||||||
|
path: "/usr/bin/google-chrome-stable",
|
||||||
|
argv: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||||
|
stderr: "ignore",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pageState(view: Bun.WebView) {
|
||||||
|
return view.evaluate(`(() => ({
|
||||||
|
url: location.href,
|
||||||
|
challenge: ((document.body && document.body.innerText) || "").includes("Проверка браузера"),
|
||||||
|
login: !!document.getElementById("login-form"),
|
||||||
|
clicker: !!document.getElementById("ck-zone"),
|
||||||
|
}))()`) as Promise<{ url: string; challenge: boolean; login: boolean; clicker: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitLogin(view: Bun.WebView) {
|
||||||
|
await view.evaluate(`(() => {
|
||||||
|
const form = document.getElementById("login-form");
|
||||||
|
if (!form) return false;
|
||||||
|
form.username.value = ${JSON.stringify(username)};
|
||||||
|
form.password.value = ${JSON.stringify(password)};
|
||||||
|
const remember = form.querySelector("input[name=remember]");
|
||||||
|
if (remember) remember.checked = true;
|
||||||
|
form.requestSubmit();
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSession(view: Bun.WebView) {
|
||||||
|
await view.navigate("https://huevo.su/clicker");
|
||||||
|
let submitted = false;
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
let state: { url: string; challenge: boolean; login: boolean; clicker: boolean };
|
||||||
|
try {
|
||||||
|
state = await pageState(view);
|
||||||
|
} catch {
|
||||||
|
await Bun.sleep(400);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (state.clicker) return;
|
||||||
|
if (state.login) {
|
||||||
|
if (!submitted) {
|
||||||
|
await submitLogin(view);
|
||||||
|
submitted = true;
|
||||||
|
log("login submitted");
|
||||||
|
}
|
||||||
|
} else if (!state.challenge) {
|
||||||
|
submitted = false;
|
||||||
|
try {
|
||||||
|
await view.navigate("https://huevo.su/clicker");
|
||||||
|
} catch {
|
||||||
|
// navigation already in flight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Bun.sleep(400);
|
||||||
|
}
|
||||||
|
throw new Error("clicker session was not established");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendClicks(view: Bun.WebView, clicks: number) {
|
||||||
|
return view.evaluate(`(async () => {
|
||||||
|
const response = await fetch("/api/clicker", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: "clicks=" + ${clicks},
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let data = null;
|
||||||
|
try { data = JSON.parse(text); } catch {}
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
login: !!document.getElementById("login-form"),
|
||||||
|
data,
|
||||||
|
text: text.slice(0, 300),
|
||||||
|
};
|
||||||
|
})()`) as Promise<{
|
||||||
|
status: number;
|
||||||
|
login: boolean;
|
||||||
|
data: null | {
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string;
|
||||||
|
retry?: number;
|
||||||
|
message?: string;
|
||||||
|
counted?: number;
|
||||||
|
clicks?: number;
|
||||||
|
balance_text?: string;
|
||||||
|
day_coins?: number;
|
||||||
|
cap?: number;
|
||||||
|
earned?: number;
|
||||||
|
capped?: boolean;
|
||||||
|
};
|
||||||
|
text: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`huevo clicker started, bun ${Bun.version}, batch ${CLICKS_MIN}-${CLICKS_MAX}`);
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const view = openView();
|
||||||
|
try {
|
||||||
|
await ensureSession(view);
|
||||||
|
log("session ready");
|
||||||
|
for (;;) {
|
||||||
|
const clicks = batchSize();
|
||||||
|
const waitMs = waitMsFor(clicks);
|
||||||
|
await Bun.sleep(waitMs);
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof sendClicks>>;
|
||||||
|
try {
|
||||||
|
result = await sendClicks(view, clicks);
|
||||||
|
} catch (error) {
|
||||||
|
log(`request failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.login || result.status === 401 || result.status === 403) {
|
||||||
|
log(`auth lost (http ${result.status}), logging in again`);
|
||||||
|
await ensureSession(view);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.data;
|
||||||
|
if (!data) {
|
||||||
|
log(`http ${result.status} non-json: ${result.text.replace(/\s+/g, " ").slice(0, 180)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.ok) {
|
||||||
|
log(
|
||||||
|
`ok sent=${clicks} counted=${data.counted} wait=${waitMs}ms clicks=${data.clicks} balance=${data.balance_text} today=${data.day_coins}/${data.cap} earned=${data.earned ?? 0}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.error === "slow") {
|
||||||
|
const retryMs = Math.ceil((data.retry || 5) * 1000) + WAIT_PAD_MS;
|
||||||
|
log(`slow, retry in ${Math.round(retryMs / 1000)}s`);
|
||||||
|
await Bun.sleep(retryMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.error === "full" || data.capped) {
|
||||||
|
log("daily cap reached, pause 15m");
|
||||||
|
await Bun.sleep(15 * 60 * 1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`rejected ${data.error || "unknown"} ${data.message || ""}`.trim());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`restart: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
view.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
await Bun.sleep(5_000);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user