Бот з веб-інтерфейсом для апвоутів у Steemit
Пані та панове, хочу вам розповісти про один сервіс, який я запустив кілька днів тому. І можливо, він вас зацікавить.
Пів року тому я публікував допис про те, як зробити бота для пасивного заробітку в Steemit. Цей спосіб передбачав встановлення на ваш комп’ютер node.js і володіння певними навичками для запуску бота через командний рядок. Я нізащо не повірю, що всім це зручно - викликати команди через командний рядок. А тепер аналогічного бота можна запустити прямо з веб-сторінки! Це не зовсім те саме, але бот виконує ті ж функції. При цьому є можливість спрямувати його роботу в потрібному напрямку за допомогою інтуїтивно зрозумілих налаштувань.
Зібрав я цей "організм" із двох причин. Перша причина - це любов до процесу. Друга причина - моє ставлення до попередньої версії. От уявіть ситуацію: ви на роботі й у вас постійно ввімкнений комп’ютер, справ дуже багато і просто не встигаєте відволіктися на якісь особисті речі, тим паче відкрити Steemit і переглянути нові публікації. Але хочеться поставити апвоут відразу, як тільки допис був опублікований. Чому я пишу про своє ставлення до попередньої версії? Тому що в версії з роботою через Node на екрані весь час висить чорне вікно терміналу. А якщо начальник побачить, то це викличе певні питання: "Що це? Навіщо? Чим ти тут займаєшся?". А у відкритому браузері - це зовсім інше. Ще з’явилася можливість додати деякі налаштування не в коді, а візуальні, і це суперзручно.
До речі, вдома можлива така ж ситуація, як і на роботі. Увімкнений комп’ютер, транслює YouTube з онлайн-ТБ, а ви займаєтеся домашніми справами. Така ситуація у моєї матері. Я не виключаю, що в когось теж комп’ютер може бути ввімкнений цілий день для різних цілей, а бот може спокійно працювати за таких обставин.
Бот здатен підключатися до блокчейну Steem за допомогою бібліотеки (js) dsteem і отримувати інформацію про нові дії в реальному часі. Серед загального потоку подій у блокчейні скрипт "ловить" нові дописи, але перед голосуванням застосовує налаштування фільтрів, які задаються заздалегідь до запуску бота. Це або список тих, на кого ви підписані, або список тих, за кого ви хочете голосувати, який складається вручну. Якщо автор підходить під ті критерії, за якими можна ставити апвоут, скрипт перевіряє яку вагу голосу ви вказали, перевіряє скільки часу минуло після попереднього голосування, за замовчуванням витримується пауза в 4 секунди, і апвоут виконується за допомогою вашого постінг-ключа. Повторне голосування за один і той самий допис виключене. Усі виконані дії записуються в лог із датою і часом, з вдалими діями або помилками. Лог ніде не зберігається, він тільки відображається у браузері в момент роботи скрипта. Тобто якщо ви оновите сторінку, статистика буде втрачена. У разі виникнення проблем із підключенням, скрипт буде перепідключатися автоматично. Усі ваші налаштування (крім приватного ключа) можна зберегти у браузері.
Кілька слів про безпеку вашого приватного ключа. Ключ використовується тільки локально в браузері для підпису транзакцій. Ключ не відправляється на сервер і не зберігається у браузері. Для більшої безпеки я увімкнув захисні заголовки на хості сторінки.
У .htaccess додано код:
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "DENY"
Header set Referrer-Policy "no-referrer"
Header set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
</IfModule>
До речі, у тому браузері, де ви будете запускати бота, найкраще взагалі не використовувати розширення браузера, бо деякі з них можуть відслідковувати введення даних на сторінці, і ваш ключ може бути скомпрометований. Я не можу зрозумілою мовою пояснити механізм цього явища, але я про це читав і вважаю, що краще бути обережними. Якщо ви дуже стурбовані питанням безпеки вашого ключа, то можете створити файл із розширенням .HTML
на ПК на робочому столі та вставити в цей файл код, який буде вказаний у кінці цього допису. Бот працюватиме.
До сторінки підключений єдиний сторонній сервіс hit.ua для підрахунку статистики відвідувань. Я хочу зрозуміти й вирішити для себе, чи варто мені реєструвати окремий домен для бота, чи поки що зійде й так.
Для більш стабільної роботи на комп’ютері відкривайте бота в окремій вкладці браузера. Річ у тім, що коли вкладка стає неактивною (наприклад, згорнута), браузер знижує пріоритет виконування JavaScript. Але під час тестування я не помітив жодних проблем у роботі бота.
Незважаючи на те, що сторінка бота адаптована під мобільні пристрої, використовувати його на телефоні може бути проблематично. Коли ви згортаєте вкладки браузера або блокуєте екран смартфона, виконання JavaScript призупиняється, на Android та iOS браузери вивантажують вкладки з пам’яті, щоб економити заряд батареї. Я додав до бота функцію, яка запобігає "заморожуванню" js бота і не дає екрану мобільного пристрою гаснути. Це дасть вам можливість використовувати бота на мобільних телефонах, але за умови, що ви не будете вимикати екран або згортати браузер. Це єдина умова, за якої бот здатен працювати на смартфоні.
Продемонструю вам приклад. Бот працює з увімкненим дисплеєм і голосує за нові дописи. Але щойно я вимкнув дисплей і згорнув браузер, з’являється безліч повідомлень про проблеми з підключенням. А коли я вмикаю дисплей, робота бота відновлюється автоматично й він продовжує моніторити блокчейн на нові події.
Використовувати цей бот чи ні - вирішувати тільки вам, але на мій погляд він економить час.
Повний код:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Steem Upvote Bot</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b1020" media="(prefers-color-scheme: dark)">
(html comment removed: Polyfills (before dsteem) )
<script src="https://unpkg.com/core-js-bundle@3/minified.js"></script>
<script src="https://unpkg.com/regenerator-runtime/runtime.js"></script>
<script src="https://unpkg.com/dsteem/dist/dsteem.js"></script>
<style>
/* ====== THEMES/COLORS ====== */
:root {
--bg: #ffffff;
--border: #ddd;
--card: #fff;
--chip-bg: #eef;
--chip-br: #ccd;
--fg: #111;
--log-bg: #0b1020;
--log-fg: #c9e6ff;
--muted: #666;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b0f12;
--border: #1f2a33;
--card: #11161a;
--chip-bg: #1b2430;
--chip-br: #2a3743;
--fg: #e9eef2;
--log-bg: #0b1020;
--log-fg: #c9e6ff;
--muted: #a6b0bb;
}
}
html, body { height: 100%; }
body {
background: var(--bg);
box-sizing: border-box;
color: var(--fg);
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Helvetica, Arial, sans-serif;
line-height: 1.45;
margin: 24px auto;
max-width: 980px;
padding: 0 16px 24px;
}
@supports (padding: max(0px)) { body { padding-bottom: max(24px, env(safe-area-inset-bottom)); } }
h1 { font-size: 1.5rem; margin: 0 0 8px; }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
margin: 12px 0;
padding: 16px;
}
label { display: block; font-weight: 600; margin: 8px 0 6px; }
input, textarea, select, button {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
box-sizing: border-box;
color: var(--fg);
font-size: 16px;
padding: 12px 14px;
width: 100%;
}
textarea { font-family: ui-monospace, Consolas, monospace; min-height: 140px; }
select { background: var(--card); }
.controls button {
cursor: pointer;
flex: 1 1 auto;
min-height: 44px;
transition: opacity 0.2s ease;
}
.controls button:disabled {
opacity: 0.6;
cursor: not-allowed;
color: #666;
}
.row { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; }
.grid-3 {
display: grid;
gap: 12px;
grid-template-columns: 1fr 1fr 1fr;
}
.grid-2 {
display: grid;
gap: 12px;
grid-template-columns: 1fr 1fr;
}
.muted, .muted ul li { color: var(--muted); font-size: .95rem; }
.log {
-webkit-overflow-scrolling: touch;
background: var(--log-bg);
border-radius: 12px;
color: var(--log-fg);
font-family: ui-monospace, Consolas, monospace;
min-height: 220px;
max-height: min(50vh, 420px);
overflow-y: auto;
padding: 12px;
white-space: pre-wrap;
}
.pill {
background: var(--chip-bg);
border: 1px solid var(--chip-br);
border-radius: 999px;
display: inline-block;
font-size: .9rem;
margin: 0 8px 8px 0;
padding: 4px 10px;
}
.controls { display: flex; flex-wrap: wrap; gap: 12px; }
.controls button { cursor: pointer; flex: 1 1 auto; min-height: 44px; }
.inline {
display: flex; align-items: center; gap: 8px;
font-weight: 600; margin: 8px 0;
}
.inline input[type="checkbox"] { width: auto; min-width: 20px; height: 20px; }
.ok { color: #4cd964; }
.err { color: #ffb3b3; }
.warn { color: #ffd27d; }
.small { font-size: .9rem; }
@media (max-width: 820px) {
body { margin: 16px auto; padding: 0 12px 24px; }
h1 { font-size: 1.25rem; }
.row { grid-template-columns: 1fr; }
.grid-3 { grid-template-columns: 1fr; }
.controls { flex-direction: column; }
.log { min-height: 55vh; font-size: 14px; }
}
</style>
</head>
<body>
<h1>Steem Upvote Bot</h1>
(html comment removed: p class="muted">
Works in the browser. The tab must remain open. The posting key is used locally and is not sent anywhere.
</p)
<div class="muted">
This browser script helps you automatically upvote posts on the Steem blockchain according to your settings.
<ul>
<li><b>Safe</b> — your posting key is used only locally in your browser and never sent anywhere.</li>
<li><b>Saves time</b> — no need to manually track new posts, the bot handles it for you.</li>
<li><b>Flexible</b> — you can control vote weight, pause between votes, whitelist of authors, or follow-only mode.</li>
</ul>
Just keep the tab open, and the bot will do the work in the background.
</div>
<div class="card muted" style="border: 1px solid var(--border); padding: 12px; margin-bottom: 16px;">
ℹ️ <b>Note:</b> This script is made for desktop use. Please keep this tab open.<br />
To keep the bot running on your mobile device, please keep the screen <b>on</b> and don’t minimize the browser, and avoid switching to other tabs.<br />
💡 <b>Tip:</b> open a separate window/tab and keep it open for stability.
</div>
<div class="card">
<div class="row">
<div>
<label for="username">Login (voter)</label>
<input id="username" autocomplete="username" />
</div>
<div>
<label for="postingKey">Posting key (5K…)</label>
<input id="postingKey" autocomplete="current-password" placeholder="5K***********************" type="password" />
</div>
</div>
<div class="row">
<div>
<label for="rpc">RPC node</label>
<select id="rpc">
<option value="https://api.steemit.com">https://api.steemit.com</option>
<option value="https://api.justyy.com">https://api.justyy.com</option>
<option value="https://steem.yabapmatt.com">https://steem.yabapmatt.com</option>
</select>
</div>
<div>
<label for="weight">Vote weight (%)</label>
<input id="weight" inputmode="numeric" max="100" min="1" type="number" value="100" />
</div>
</div>
<div class="grid-2">
(html comment removed: div class="grid-3")
<div>
<label for="cooldown">Pause between votes (sec)</label>
<input id="cooldown" inputmode="numeric" min="0" step="1" type="number" value="4" />
</div>
<div>
<label for="autosave">Save settings (without key)</label>
<select id="autosave">
<option selected value="on">Yes</option>
<option value="off">No</option>
</select>
</div>
(html comment removed: div>
<label for=""> </label>
<select id="">
<option value=""></option>
<option value=""></option>
</select>
</div)
</div>
<label class="inline" for="onlyFollowed">
<input type="checkbox" id="onlyFollowed" />
<span>Upvotes only for accounts I follow</span>
</label>
<label for="whitelist">Whitelist of authors (one per line)</label>
<textarea id="whitelist" spellcheck="false"></textarea>
<div class="controls" style="margin-top: 12px">
<button id="startBtn">▶️ Start</button>
<button id="stopBtn" disabled>⏹ Stop</button>
<button id="clearLogBtn">🧹 Clear log</button>
</div>
</div>
<div class="card">
<div>
<span class="pill" id="statusPill">Status: stopped</span>
<span class="pill" id="nodePill">No connection</span>
</div>
<div aria-live="polite" class="log" id="log"></div>
</div>
<div style="height: 32px"></div>
<script>
/* ====== UI UTILITIES ====== */
const $ = (id) => document.getElementById(id);
const logEl = $("log");
function nowStamp() {
const d = new Date();
const time = new Intl.DateTimeFormat('en-GB', {
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).format(d);
const date = d.toLocaleDateString('en-GB').replace(/\//g, '-');
return `[${time}][${date}]`;
}
function log(msg, cls) {
const line = `${nowStamp()} ${msg}`;
const div = document.createElement("div");
if (cls) div.className = cls;
div.textContent = line;
logEl.appendChild(div);
requestAnimationFrame(() => {
logEl.scrollTop = logEl.scrollHeight;
});
console.log(line);
}
function setStatus(text) { $("statusPill").textContent = `Status: ${text}`; }
function setNode(text) { $("nodePill").textContent = text; }
/* ====== WAKE LOCK ====== */
let wakeLock = null;
async function enableWakeLock() {
if (!('wakeLock' in navigator)) {
log("Wake Lock API is not available in this browser", "warn");
return;
}
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => {
wakeLock = null;
});
} catch (e) {
log(`Failed to enable Wake Lock: ${e.message || e}`, "warn");
}
}
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible' && wakeLock === null) {
await enableWakeLock();
}
});
/* ====== STATE / VARIABLES ====== */
let client = null;
let stream = null;
let running = false;
let lastVoteAt = 0;
// Reconnect backoff
let backoffMs = 5000; // start at 5s
function growBackoff() {
backoffMs = Math.min(backoffMs * 2, 60000); // cap at 60s
}
function resetBackoff() {
backoffMs = 5000;
}
const seenPermlinks = new Set(); // de-dup events
const votedMap = new Map(); // author -> Set(permlink)
// Following cache (whom the voter follows)
let followingSet = new Set();
let followingLastFetch = 0;
/* ====== SETTINGS PERSISTENCE ====== */
function loadSaved() {
try {
const s = JSON.parse(localStorage.getItem("steem_bot_cfg") || "{}");
if (!s || typeof s !== "object") return;
if (s.rpc) $("rpc").value = s.rpc;
if (s.username) $("username").value = s.username;
if (Array.isArray(s.whitelist)) $("whitelist").value = s.whitelist.join("\n");
if (s.weight) $("weight").value = s.weight;
if (s.cooldown) $("cooldown").value = s.cooldown;
if (typeof s.onlyFollowed === "boolean") $("onlyFollowed").checked = s.onlyFollowed;
} catch (_) {}
}
function saveIfNeeded() {
if ($("autosave").value !== "on") return;
const cfg = {
cooldown: $("cooldown").value,
onlyFollowed: $("onlyFollowed").checked,
rpc: $("rpc").value.trim(),
username: $("username").value.trim(),
weight: $("weight").value,
whitelist: $("whitelist").value.split("\n").map((s) => s.trim()).filter(Boolean)
};
localStorage.setItem("steem_bot_cfg", JSON.stringify(cfg));
}
loadSaved();
// Disable/enable whitelist textarea based on checkbox
function toggleWhitelistDisabled() {
const on = $("onlyFollowed").checked;
const ta = $("whitelist");
ta.disabled = on;
ta.style.opacity = on ? "0.6" : "1";
ta.style.cursor = on ? "not-allowed" : "text";
}
toggleWhitelistDisabled();
/* ====== RPC CLIENT ====== */
function buildClient() {
const endpoint = $("rpc").value.trim();
client = new dsteem.Client(endpoint, { timeout: 8000, failoverThreshold: 3 });
setNode(`RPC: ${endpoint}`);
}
/* ====== FOLLOWING ====== */
async function fetchFollowingSet(voter, force = false) {
const TEN_MIN = 10 * 60 * 1000;
if (!force && (Date.now() - followingLastFetch) < TEN_MIN && followingSet.size > 0) {
return followingSet;
}
try {
const set = new Set();
let start = "";
const TYPE = "blog";
const LIMIT = 1000;
while (true) {
const batch = await client.call("condenser_api", "get_following", [voter, start, TYPE, LIMIT]);
if (!batch || batch.length === 0) break;
for (const f of batch) {
if (f && f.following) set.add(f.following.toLowerCase());
}
if (batch.length < LIMIT) break;
start = batch[batch.length - 1].following;
}
followingSet = set;
followingLastFetch = Date.now();
log(`Following list updated (${set.size})`, "ok");
} catch (err) {
log(`Failed to fetch following: ${err?.message || err}`, "err");
followingSet = new Set();
followingLastFetch = Date.now();
}
return followingSet;
}
/* ====== HELPERS ====== */
async function hasVoted(author, permlink, voter) {
try {
const content = await client.database.call("get_content", [author, permlink]);
return (content.active_votes || []).some((v) => v.voter === voter);
} catch (err) {
log(`Error checking votes: ${err?.message || err}`, "err");
return false;
}
}
function isNetErr(err) {
const m = String(err?.message || err).toLowerCase();
return /fetch|network|timeout|resolve|dns|failed|cors/.test(m);
}
function rotateRpc() {
const select = $("rpc");
const opts = Array.from(select.options);
if (opts.length > 1) {
select.selectedIndex = (select.selectedIndex + 1) % opts.length;
log(`Switching RPC to ${select.value}`, "warn");
}
}
async function upvote(author, permlink, voter, postingKey, weightPct, retries = 2) {
const pct = Math.max(1, Math.min(100, Number(weightPct) || 100));
const weight = Math.round(pct * 100);
const vote = { voter, author, permlink, weight };
try {
await client.broadcast.vote(vote, dsteem.PrivateKey.fromString(postingKey));
} catch (err) {
if (retries > 0 && isNetErr(err)) {
log(`Retrying vote (network): ${err?.message || err}`, "warn");
rotateRpc();
buildClient();
await sleep(1000);
return upvote(author, permlink, voter, postingKey, weightPct, retries - 1);
}
throw err;
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const nowMs = () => Date.now();
/* ====== START/STOP ====== */
async function start() {
if (running) return;
const username = $("username").value.trim();
const postingKey = $("postingKey").value.trim();
if (!username) { alert("Enter login"); $("username").focus(); return; }
if (!postingKey) { alert("Enter posting key"); $("postingKey").focus(); return; }
buildClient();
saveIfNeeded();
// --- instant posting key validation ---
let pk;
try {
pk = dsteem.PrivateKey.fromString(postingKey);
} catch (e) {
log("Invalid posting key format (WIF).", "err");
alert("Invalid posting key format.");
return;
}
try {
const accs = await client.database.call("get_accounts", [[username]]);
if (!accs || !accs.length) {
log(`Account @${username} not found.`, "err");
alert("Account not found.");
return;
}
const acc = accs[0];
const pub = pk.createPublic().toString(); // STM...
const hasPosting = (acc.posting?.key_auths || []).some(([k]) => k === pub);
if (!hasPosting) {
log("This private key does not match the account's POSTING authority.", "err");
alert("This posting key does not belong to this account (posting authority mismatch).");
return;
}
log("Posting key verified (matches account posting authority).", "ok");
} catch (e) {
log(`Could not verify key against account: ${e?.message || e}`, "warn");
}
// --- END / instant posting key validation ---
const useFollowedOnly = $("onlyFollowed").checked;
const whitelist = $("whitelist").value
.split("\n").map((s) => s.trim().toLowerCase()).filter(Boolean);
if (!useFollowedOnly && whitelist.length === 0) {
alert("Whitelist is empty");
$("whitelist").focus();
return;
}
if (useFollowedOnly) {
await fetchFollowingSet(username, true);
}
running = true;
lastVoteAt = 0;
setStatus("running");
$("startBtn").disabled = true;
$("stopBtn").disabled = false;
log(`Bot started for @${username}. Waiting for new posts…`, "ok");
resetBackoff();
await enableWakeLock();
stream = client.blockchain.getOperationsStream();
stream.on("data", async (operation) => {
if (!running) return;
try {
if (operation.op && operation.op[0] === "comment") {
const op = operation.op[1];
if (op.parent_author === "") {
const authorL = op.author.toLowerCase();
let allowed = false;
if ($("onlyFollowed").checked) {
await fetchFollowingSet(username, false);
if (!followingSet.has(authorL)) return;
allowed = true;
} else {
allowed = whitelist.includes(authorL);
}
if (!allowed) return;
const key = `${op.author}/${op.permlink}`;
if (seenPermlinks.has(key)) return;
seenPermlinks.add(key);
log(`New post: @${op.author}/${op.permlink}`);
const already = await hasVoted(op.author, op.permlink, username);
if (already) {
log(`Already voted: @${username} for @${op.author}/${op.permlink} — skipping`, "warn");
return;
}
const cdSec = parseInt($("cooldown").value || "0", 10);
const elapsed = (nowMs() - lastVoteAt) / 1000;
if (elapsed < cdSec) {
const wait = Math.ceil(cdSec - elapsed);
log(`Waiting ${wait}s before voting…`);
await sleep(wait * 1000);
}
try {
await upvote(op.author, op.permlink, username, postingKey, parseInt($("weight").value, 10));
lastVoteAt = nowMs();
if (!votedMap.has(op.author)) votedMap.set(op.author, new Set());
votedMap.get(op.author).add(op.permlink);
log(`✅ Upvote sent: @${op.author}/${op.permlink}`, "ok");
} catch (err) {
log(`❌ Voting error: ${err?.message || err}`, "err");
}
}
}
} catch (err) {
log(`Event processing error: ${err?.message || err}`, "err");
}
});
stream.on("error", async (err) => {
log(`Stream error: ${err?.message || err}`, "err");
setStatus("error (reconnecting)");
stop(false);
if (isNetErr(err)) rotateRpc();
const wait = Math.round(backoffMs / 1000);
log(`Attempting to reconnect in ${wait}s`, "warn");
await sleep(backoffMs);
growBackoff();
start();
});
}
function stop(showMsg = true) {
running = false;
$("startBtn").disabled = false;
$("stopBtn").disabled = true;
setStatus("stopped");
if (stream && stream.removeAllListeners) {
try {
stream.removeAllListeners("data");
stream.removeAllListeners("error");
} catch (_) {}
}
stream = null;
if (wakeLock) {
try { wakeLock.release(); } catch (_) {}
wakeLock = null;
}
if (showMsg) log("Bot stopped", "warn");
}
/* ====== UI EVENTS ====== */
$("startBtn").addEventListener("click", start);
$("stopBtn").addEventListener("click", () => stop(true));
$("clearLogBtn").addEventListener("click", () => {
logEl.textContent = "";
logEl.scrollTop = 0;
});
$("onlyFollowed").addEventListener("change", () => {
saveIfNeeded();
toggleWhitelistDisabled();
if (!$("onlyFollowed").checked) {
// переключились на whitelist
const whitelist = $("whitelist").value
.split("\n")
.map(s => s.trim())
.filter(Boolean);
if (whitelist.length === 0) {
log("Whitelist is empty — stopping bot.", "warn");
stop(true);
}
}
log($("onlyFollowed").checked ? "Mode changed: only following" : "Mode changed: whitelist", "warn");
});
["username", "postingKey", "weight", "cooldown"].forEach((id) => {
$(id).addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); start(); }
});
});
window.addEventListener("beforeunload", () => stop(false));
window.addEventListener("unhandledrejection", (e) => {
console.warn("⚠️ Suppressed unhandled promise rejection:", e.reason);
});
window.addEventListener("offline", () => {
if (!running) return;
log("Offline — pausing stream", "warn");
setStatus("offline");
stop(false);
});
window.addEventListener("online", () => {
log("Back online — connecting", "ok");
resetBackoff();
start();
});
setInterval(async () => {
if (!running || !client) return;
try {
await client.call("condenser_api", "get_dynamic_global_properties", []);
} catch (e) {
log("RPC not responding to heartbeat — reconnecting", "warn");
stop(false);
rotateRpc();
start();
}
}, 180000);
</script>
</body>
</html>
Вав!!! Бомбезний ресурс!!!! Дуже круто потрудився!!! Додав в закладки.
Не розумію чому ти так довго заморочувався описом безпеки якщо використовується тільки постінг (приватний) ключ? Чи я тут помиляюсь?
Дякую, мені приємно це чути! 🙏🏻
Тема безпеки дуже щепетильна в крипті. Якщо хтось щось втратить і будуть думки, що це через мене, то я не хочу бути тим, на кого впаде тінь підозри. Просто я це робив як для себе, і не хочеться неприємних сюрпризів 🤷