ピナクルのライブオッズでDiscordピックボットを作る
このボットはPinnFeedのWebSocketでピナクルの価格を監視し、マーケットがスチームしたとき、つまり最大ステークが100以上のマーケットでオッズが2分以内に5%以上下がったときに、Discordチャンネルへembedを投稿します。約90行の1ファイルです。チャンネルの受信Webhookで投稿するため、Discordライブラリもボットアカウントも不要です。
TelegramピックボットのDiscord版です。検知ルールはスチーム検知ガイドで説明しています。18歳以上の方向けの学習用ツールであり、予想の提供ではありません。
ステップ 1 — Webhookを作成する#
- Discordサーバーで サーバー設定 → 連携サービス → ウェブフック を開き、新しいウェブフック を選びます。
- 名前を付け、投稿先のチャンネルを選び、ウェブフックURLをコピー を使います。
- このURLはパスワードと同じように扱ってください。URLを知っている人は誰でもそのチャンネルに投稿できます。漏れた場合はWebhookを削除し、新しく作り直してください。
ステップ 2 — 環境を設定する#
export PINNFEED_KEY="ps_live_..." # Builderプラン(WebSocket)
export DISCORD_WEBHOOK="https://discord.com/api/webhooks/..." # ステップ1で取得
npm install ws
DISCORD_WEBHOOK を設定しないと、ボットはドライランモードで動き、アラートを投稿せずに表示だけします。
ストリームには Builder(月額99ユーロ)以上が必要です。FreeとHobbyのキーは /v1/stream で 403 plan_lacks_ws を受け取ります。料金をご覧ください。
ステップ 3 — ボット本体#
discord-picks-bot.mjs として保存します。
// discord-picks-bot.mjs: post Pinnacle steam moves to a Discord channel through an incoming webhook.
// npm install ws Node 18+ (global fetch)
// env: PINNFEED_KEY (Builder or higher), DISCORD_WEBHOOK (channel webhook URL; unset = dry run)
import WebSocket from 'ws';
const WS_URL = process.env.PINNFEED_WS || 'wss://pinnfeed.io/v1/stream';
const { PINNFEED_KEY, DISCORD_WEBHOOK } = process.env;
const MIN_DROP = Number(process.env.MIN_DROP || 0.05); // 5% shortening...
const WINDOW_MS = Number(process.env.WINDOW_MS || 120000); // ...inside 2 minutes
const MIN_STAKE = Number(process.env.MIN_STAKE || 100); // skip thin markets
const SIDES = new Set(['home', 'away', 'draw', 'over', 'under']);
const events = new Map();
const highs = new Map(); // "marketId|outcome" -> [{t, p}]
const sent = new Map(); // "marketId|outcome" -> last alert time
const queue = []; // embeds waiting to be posted
let busy = false;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// One worker drains the queue, so a slow or rate-limited webhook never blocks the stream.
async function drain() {
if (busy) return;
busy = true;
while (queue.length) {
const embed = queue.shift();
if (!DISCORD_WEBHOOK) { console.log('[dry run]', embed.title, '|', embed.description.replace(/\n/g, ' | ')); continue; }
for (let attempt = 0; attempt < 3; attempt++) {
const r = await fetch(DISCORD_WEBHOOK, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ embeds: [embed], allowed_mentions: { parse: [] } }),
}).catch((e) => ({ ok: false, status: 0, text: async () => e.message }));
if (r.status === 429) { const b = await r.json().catch(() => ({})); await sleep(1000 * (b.retry_after ?? 1)); continue; }
if (!r.ok) console.error('discord', r.status, await r.text());
break;
}
}
busy = false;
}
function post(embed) {
queue.push(embed);
if (queue.length > 50) queue.shift(); // an alert from minutes ago is no longer news
drain();
}
function check(m) {
const ev = events.get(m.eventId);
if (!ev || (m.maxStake ?? 0) < MIN_STAKE) return;
const now = Date.now();
for (const o of m.outcomes) {
if (!SIDES.has(o.name)) continue;
const k = `${m.id}|${o.name}`;
const h = (highs.get(k) || []).filter((x) => now - x.t < WINDOW_MS);
h.push({ t: now, p: o.decimal });
highs.set(k, h);
const high = Math.max(...h.map((x) => x.p));
const drop = 1 - o.decimal / high;
if (drop < MIN_DROP || now - (sent.get(k) || 0) < WINDOW_MS) continue;
sent.set(k, now);
const pick = o.name === 'home' ? ev.home : o.name === 'away' ? ev.away : o.name;
const line = o.points == null ? '' : m.type === 'total' ? ` ${o.points}` : ` ${o.points > 0 ? '+' : ''}${o.points}`;
post({
title: `📉 ${ev.home} v ${ev.away}`,
description: `**${pick}** (${m.type}${line})\nPinnacle ${high.toFixed(2)} → ${o.decimal.toFixed(2)} (−${(drop * 100).toFixed(1)}%)\n` +
`Fair now ${o.fair.toFixed(2)} · max stake ${m.maxStake}`,
color: 0xe8590c,
footer: { text: `${ev.sport.toUpperCase()} · ${ev.league?.name ?? ''} · 18+ · not a tip` },
timestamp: new Date(now).toISOString(),
});
}
}
function connect(delay = 1000) {
const ws = new WebSocket(WS_URL, { headers: { 'x-api-key': PINNFEED_KEY }, perMessageDeflate: true });
ws.on('open', () => {
delay = 1000;
ws.send(JSON.stringify({ type: 'subscribe', sports: ['soccer', 'tennis', 'basketball'], markets: ['moneyline', 'spread', 'total'], main: true, specials: false }));
});
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.type === 'ping') return ws.send('{"type":"pong"}');
if (msg.type === 'snapshot') msg.events.forEach((e) => events.set(e.id, e));
if (msg.type === 'delta') for (const c of msg.changes) {
if (c.kind === 'event') c.op === 'upsert' ? events.set(c.event.id, c.event) : events.delete(c.id);
if (c.kind === 'market' && c.op === 'upsert') check(c.market);
}
});
ws.on('close', () => setTimeout(() => connect(Math.min(delay * 2, 30000)), delay));
ws.on('error', (e) => console.error('ws error:', e.message));
}
connect();
処理の流れは次のとおりです。
- サッカー、テニス、バスケットボールのメインライン(マネーライン、スプレッド、トータル)を購読します。
sportsはプランの13競技のどれにでも変更できます。すべてのフィルターはWebSocketプロトコル(英語)に載っています。 - マーケットとアウトカムごとに2分間のウィンドウを保持し、ウィンドウ内の最高値から
MIN_DROP以上下がったら検知します。限度額が低いマーケットの動きは意味が薄いため、MIN_STAKE未満のマーケットは無視します。 - 投稿をキューに入れます。 1つのワーカーがWebhookへembedを送るので、Webhookが遅くても制限されても、ストリームの受信は止まりません。待機中のアラートが50件を超えると、最も古いものを捨てます。
- 429に従います。 Discordは連続投稿に対して
429を返し、JSON本文に秒単位のretry_afterを含めます。ボットはその時間だけ待って再送し、最大3回まで試します。 - メンションしません。
allowed_mentions: { parse: [] }により、チーム名や選手名でチャンネルの誰かがメンションされることはありません。
ステップ 4 — 実行してテストする#
node discord-picks-bot.mjs
ドライランモードでは、アラートは次のように表示されます。
[dry run] 📉 Udinese v Fiorentina | **Udinese** (spread +0.25) | Pinnacle 1.88 → 1.70 (−9.6%) | Fair now 1.75 · max stake 150
Discordでは同じアラートがembedになります。タイトルに試合、本文にサイドとオッズの動き、最終行にマージンを除いたフェアオッズと最大ステーク、フッターに競技、リーグ、「18+ · not a tip」の注記が入ります。
量は環境変数で調整します。たとえば MIN_DROP=0.08 で件数を減らして強い動きだけにしたり、MIN_STAKE=500 で厚いマーケットだけに絞ったりできます。
ステップ 5 — 常時稼働させる#
systemd、pm2、または失敗時に再起動するコンテナで動かします。ボットはバックオフ付きでWebSocketに自動再接続し、新しいスナップショットからイベント一覧を作り直します。- Webhookはチャンネルごとに1つで足ります。サッカーとテニスを別チャンネルにしたい場合は、
sportsとWebhookを変えた2つのコピーを動かしてください。Builderのキーでは3本のWebSocket接続が使えます。 - 成績は正直に記録しましょう。各アラートを記録し、的中率ではなくクロージングラインバリューで測定してください。
- サーバーで何を投稿し、どうモデレートするかはDiscordサーバーのユースケース(英語)を参照してください。
よくある質問#
Discordボットに必要なPinnFeedのプランは?#
WebSocketを読むため、Builder(月額99ユーロ)以上が必要です。比較にはライブとプレマッチの両方を配信するWebSocketの料金ページをご覧ください。有料プランには3日間の無料トライアルがあります。カードの登録が必要で、解約しない限り3日目の後に課金されます。
Freeプランで動かせますか?#
このままでは動きません。FreeはRESTのみで30秒の遅延があります。FreeやHobbyで更新頻度の低いボットを作る場合は、/v1/drops を1分ごとにポーリングし、結果を同じWebhookに投稿してください。RESTドキュメント(英語)をご覧ください。
Discordのボットアカウントやdiscord.jsは必要ですか?#
不要です。受信Webhookは単純なJSONの POST を受け付けるので、ボットに必要なのは fetch だけです。スラッシュコマンドや返信が欲しい場合に限り、discord.jsとボットアカウントを使ってください。
チャンネルがアラートで埋まらないようにするには?#
MIN_DROP や MIN_STAKE を上げ、sports を絞り、アウトカムごとのクールダウン(マーケットのサイドごとに WINDOW_MS あたり1件)を維持してください。キューの上限により、古いアラートは遅れて投稿されずに捨てられます。
Discordサーバーで予想を公開することは合法ですか?#
予想の公開や賭博の広告に関するルールは国によって異なります。日本では刑法により賭博は原則として禁止されており、スポーツに関するベッティングで認められているのは、競馬・競輪・競艇・オートレースといった公営競技やスポーツくじ(toto)など、法律で定められたものに限られます。Discordにも独自のコミュニティガイドラインがあります。ご自身とメンバーがいる地域のルールを確認し、サーバーは18歳以上に限定し、アラートを確実な利益として紹介しないでください。これは法的助言ではありません。