Serverless bot that generates a crypto market post every 6 hours and publishes it to Binance Square, using GitHub Actions as the scheduler/compute (no server, nothing that needs to stay running).
Cron (GitHub Actions)
→ fetch market data (Binance public spot API, data-api.binance.vision)
→ generate post text (Groq, Gemini fallback)
→ sanitize (em dashes, unicode quotes, stablecoin cashtags)
→ validate (length, cashtags, duplicates, banned patterns)
→ publish to Binance Square
→ commit post history back to the repo
- Pick a theme: one of 7, uniform random (
src/generate.mjs), excluding whichever themes appear in the last 4 published posts (getRecentThemes()invalidate.mjs). With 7 themes and at most 4 excluded, there are always at least 3 left to pick from — this never errors out, even with no history yet. - Fetch data for that theme (
src/sources/*.mjs). - Generate text with an LLM (Groq primary, Gemini fallback), using a per-theme prompt plus shared style rules (casual tone, cashtag format, anti-repetition, only-use-supplied-data, etc.).
- Sanitize the output deterministically (
sanitizeText()ingenerate.mjs), because prompt instructions alone aren't reliable. - Validate (
src/validate.mjs): length, banned patterns, cashtag count/presence, duplicate check against recent posts. - If validation fails, start over (up to 3 attempts). Each attempt is a fresh generation and may land on a different theme, it doesn't just re-roll the same draft.
- Publish to Binance Square (
src/publish.mjs), then record the post indata/posts.jsonfor future duplicate checks. - The workflow commits the updated
data/posts.jsonback to the repo so history persists across runs (GitHub Actions runners are ephemeral). Aconcurrencylock stops a manual run and the cron run from overlapping.
.github/workflows/square.yml cron + manual trigger, concurrency lock, runs src/run.mjs
skills/square-post/ Binance's official posting skill (publishing only)
src/
sources/
market.mjs Themes 1-5 and 7: Binance public market data
tokenized-stocks.mjs Theme 6: bStocks (tokenized equities), same API
generate.mjs theme picker, prompts, LLM calls, sanitizeText
validate.mjs pre-publish checks + post history
publish.mjs publishes to Square, records history
run.mjs entry point: generate → validate → publish (with retry)
data/posts.json auto-generated post history, don't create manually
package.json type: module, engines: node >=22
.gitignore node_modules/, .env, *.log
.env.example required env vars for local testing (no values)
README.md this file
LICENSE MIT
| Skill | Role | Auth |
|---|---|---|
square-post |
Publishing | BINANCE_SQUARE_OPENAPI_KEY |
It's the only skill this repo depends on. All market data comes straight from Binance's public spot API, with no key and no other skill involved.
| # | Theme | Source |
|---|---|---|
| 1 | Morning Market Brief | BTC/ETH/BNB 24h ticker (market.mjs) |
| 2 | Leaders & Laggards | Top gainers vs losers in the dynamic basket |
| 3 | Breakout Watch | Past-24h range vs 7-day average daily range (klines) |
| 4 | The Quiet Ones | Same as above, inverted: unusually compressed range |
| 5 | Relative Strength Check | ETH/BTC pair + average alt vs BTC (price performance, not capital flow) |
| 6 | Tokenized Stocks Corner | bStocks read as regular spot tickers (tokenized-stocks.mjs) |
| 7 | Daily Recap | BTC/ETH/BNB 24h ticker (market.mjs) |
All themes pull exclusively from Binance-listed USDT pairs
(data-api.binance.vision), with no DEX/on-chain token data anywhere in the
pipeline (see "Not included" for why). Theme selection is uniform random
(THEMES in src/generate.mjs) — all 7 themes now pull from the same
safe data source, so each gets an equal chance per run, minus whichever
were used in the last 4 posts (see "How it works" above).
The basket behind themes 2-5 is dynamic, not a hardcoded list. Every run
fetches all USDT pairs, drops stablecoin pairs and ranks the rest by 24h
quote volume. Stablecoins are caught two ways: a name list (USDC, USD1,
USDE, ...) plus a peg fingerprint (priced within 2 cents of $1 with a
sub-1% daily range), because the name list alone missed USD1 and it showed
up in a real "Quiet Ones" post. Delistings
and new listings are picked up automatically. A leveraged-token filter
(*UP/*DOWN/*BULL/*BEAR) is also in place but currently matches
nothing, since Binance discontinued those years ago.
Settings → Secrets and variables → Actions:
| Secret | Where to get it |
|---|---|
BINANCE_SQUARE_OPENAPI_KEY |
Binance Square Developer/OpenAPI settings |
GROQ_API_KEY |
console.groq.com |
GEMINI_API_KEY |
aistudio.google.com |
Secrets never carry over to forks or template copies, so anyone reusing
this repo needs their own keys (and their own Square key, otherwise posts
go to your account). Also delete data/posts.json in a fresh copy so it
starts with its own history.
.github/workflows/square.yml needs permissions: contents: write (already
set) so it can commit data/posts.json back after a successful publish.
Default cron is 0 */6 * * * (every 6 hours: 00:00/06:00/12:00/18:00 UTC =
07:00/13:00/19:00/01:00 WIB). Cron in GitHub Actions is always UTC, and runs
can start a few minutes late. Adjust to taste.
At 4 runs/day with up to 3 attempts each, worst case is ~12 LLM requests and roughly 60,000-70,000 tokens/day — comfortably under Groq's free-tier gpt-oss-120b limits (1,000 requests/day, 200,000 tokens/day) and Square's 100 posts/day cap. The anti-repeat check (below) matters more at this frequency: several runs can land within the same rolling-24h data window, so avoiding a repeated theme is what keeps back-to-back posts from reading near-identical.
cp .env.example .env # then fill in the keys
node --env-file=.env src/run.mjsTest individual sources in isolation:
node src/sources/market.mjs # themes 1-5 and 7
node src/sources/tokenized-stocks.mjs # theme 6Network notes. Binance's regular endpoints behave differently depending on where you call them from:
api.binance.comandfapi.binance.comreturn HTTP 451 from US IPs, which includes GitHub Actions runners (US Azure). That's why market data usesdata-api.binance.vision, Binance's public market-data mirror. Other developers report the block can vary by time of day, so don't assume a passing run means the block is gone.- Some local ISPs reset connections to
www.binance.com/web3.binance.com(ECONNRESET). That's a local network issue: the same domains worked from GitHub Actions, and publishing itself goes throughwww.binance.com/bapi/....
These aren't documented anywhere in the skills themselves. They were found by hitting the real errors during development:
- Cashtag limit: Square rejects posts referencing more than 3 distinct
$COINtickers (error220095, undocumented). Enforced in both the prompt andvalidate.mjs(MAX_CASHTAGS). Stablecoins written as cashtags count too, so$USDTis stripped toUSDT. - Post length limit is confirmed at 1900 characters, tested directly
(the skill itself only documents error
20013"Content length is limited" with no number). Prompts target 1600,MAX_LENGTHis 1850 as a small margin, since it's unclear whether Square counts raw characters or UTF-16 code units. gpt-oss-120bis a reasoning model: it spends part ofmax_tokenson internal reasoning before writing the answer, and can return empty or truncated text if the budget is too tight. Mitigated withreasoning_effort: "low", a largermax_tokens, and explicitfinish_reasonchecks ingenerate.mjs.- Em dash: the LLM ignores "never use em dash" in the prompt often
enough that a real run failed validation 3 attempts in a row and skipped
the day's post. Retrying alone isn't reliable, so
generate.mjsruns a deterministicsanitizeText()on every output before validation (em dash to comma or "to", curly quotes and non-breaking hyphens to ASCII). Once em dashes were banned the model switched to spaced en dashes (–), which are the same tell, so those are handled too.validate.mjsstill rejects both as a safety net. - Predictive language: even with "no predictions" in the prompt, drafts
said things like "something brewing" and "keep an eye on the squeeze" for
tokens whose range was only ~30% below normal. Themes 3-4 now tell the
model to scale its wording to the size of the gap and describe only what
happened, and
validate.mjsrejects words like "squeeze", "brewing" and "coiled". - Cashtags must match the tradable ticker, not the raw trading-pair
symbol: a real post wrote "$MARSCOINUSDT" and "$ZECUSDT" — Square
actually parsed these fine (only the base asset rendered as a live
cashtag, "USDT" sat after as plain text) but with no space it read as
one garbled ticker. bStocks have the same issue in reverse: they trade
as
NVDAB, notNVDA, so deriving a cashtag from the symbol guesses wrong either way. Everymarket.mjsandtokenized-stocks.mjsfunction now sends a ready-madecashtagfield (base asset only,$prefix included) so the model doesn't have to derive one, plus a rule inSTYLE_RULEStelling it to use that field verbatim. success_without_post_id:square-post's publish call can return a 504 and still have actually posted, withid/shareLinkasnull.publish.mjstreats this as success (only a thrown error counts as failure). Caveat: a 504 doesn't guarantee the post landed, so a null id is "probably posted", not "confirmed".- bStocks trade as regular spot pairs:
NVDABUSDT,TSLABUSDT, etc. are readable through the sameticker/24hrendpoint as BTC/ETH/BNB, so Theme 6 doesn't need the separate RWA API that thebinance-tokenized-securities-infoskill documents. Switching also removed that skill's extra headers and a second API surface. Trade-off: spot tickers carry no stock fundamentals (P/E, dividend yield), so the theme leans on the "trades 24/7" angle instead. - Rolling 24h vs calendar day:
ticker/24hris a rolling window ending now, not "since 00:00 UTC". Prompts say "past 24 hours" rather than "today" for that reason. Themes 3-4 also compare that rolling range against daily-candle averages, which is directionally useful but not a strict like-for-like comparison. data/posts.jsondoesn't exist on first run (or after a validation-only failure): the workflow's commit step checks the file exists before trying togit addit.
Deliberately left as-is for a one-post-a-day bot, but worth knowing:
- No timeout or retry/backoff on individual HTTP calls (Binance, Groq, Gemini, Square). A hung connection stalls the run until GitHub's job timeout.
BSTOCKSintokenized-stocks.mjsis a hand-maintained list of 7 tickers, while Binance keeps adding bStocks. It needs occasional manual updates (check Binance's announcements). Delisted symbols are skipped silently rather than breaking the theme.- Duplicate detection is a simple word-overlap ratio (threshold 0.6) against the last 30 posts. It can miss paraphrases and occasionally over-reject posts that share common words.
- If the publish call succeeds but writing
data/posts.jsonfails, that post isn't in history and a near-duplicate could be published later. - No automated tests and no failure alerting; a failed run is only visible in the Actions tab.
- DEX/on-chain skills (
trading-signal,crypto-market-rank,meme-rush) were removed from this repo entirely, not just unwired, after a real post (viacrypto-market-rank'ssmart-money-inflow) referenced a token not listed on Binance and got a compliance notice from Square. These skills surface whatever's active on BSC/Solana DEXs with no guarantee of a Binance listing: fine for manual research, not safe for unattended auto-posting. All themes now come from Binance's own listed pairs only. - No fallback beyond Groq → Gemini (OpenRouter, etc.). At a handful of posts a day a dual-provider outage is unlikely enough that it's not worth the added complexity yet.