完整 WebSocket 示例
复制一个 TypeScript 或 Python 程序,用你的 API key 运行,就能看到它连接、重连、合并帖子更新、解析引用链,并为每条帖子计时。
运行程序
把 YOUR_API_KEY 替换为控制台中的 key,试用 key 也可以。监控账号发帖时才会收到帖子,所以请先在控制台添加至少一个账号,或设置 TWEETSTREAM_ACCOUNTS。
需要 Node.js 22.18 或更高版本,它可以直接运行 TypeScript 文件。程序没有依赖。
复制程序
tweetstream-example.tstypescript// TweetStream complete example: connect, reconnect, handle every event, and measure latency. // Node.js 22.18 or later runs this TypeScript file directly. No dependencies. // // TWEETSTREAM_API_KEY=YOUR_API_KEY node tweetstream-example.ts // // Optional settings: // TWEETSTREAM_ACCOUNTS=handle1,handle2 add these accounts before connecting // TWEETSTREAM_WS_URL=wss://ws-iad.tweetstream.io/ws use the US endpoint // The messages this program reads, limited to the fields it uses. Every field of every event: // https://tweetstream.io/docs/asyncapi.json // Every server message is a v1 envelope. Route on t (channel) and op (operation); d is the payload. type Envelope = { v: 1; t: string; op: string; id?: string; ts: number; d: unknown }; // Posts name their author by handle. Some account events use username instead. type Account = { id?: string; handle?: string; username?: string; platform?: string }; type PostKind = "post" | "quote" | "reply" | "retweet"; // A post that this one quotes, replies to, or reposts. It can reference another through subtweet. type Reference = { type: Exclude<PostKind, "post">; tweetId: string; author?: Account; text?: string; subtweet?: Reference; }; // tweet/content: a new post. author.platform is "twitter" when it is missing. type PostContent = { tweetId: string; text: string; createdAt: number; // Unix milliseconds kind: PostKind; author: Account; ref?: Reference; }; // tweet/update: only the fields that changed. textUpdateType "completion" delivers the full text // of a post that arrived shortened; it is not an edit. type PostUpdate = Partial<Omit<PostContent, "createdAt">> & { tweetId: string; platform?: string; textUpdateType?: "completion"; }; // tweet/meta: a complete snapshot of the tokens, markets, and image text found in a post. type PostMeta = { tweetId: string; platform?: string; detected?: { tokens?: Array<{ symbol?: string; contract?: string; priceUsd?: number }>; prediction?: Array<{ title?: string; priceUsd?: number }>; }; ocr?: { text: string }; }; // tweet/delete, tweet/pin, and tweet/unpin. type PostEvent = { tweetId: string; platform?: string; author?: Account }; type Coin = { symbol?: string; address?: string }; // The payload of each t/op pair this program handles. type Payloads = { "control/auth_pong": Record<string, never>; "tweet/content": PostContent; "tweet/update": PostUpdate; "tweet/meta": PostMeta; "tweet/delete": PostEvent; "tweet/pin": PostEvent; "tweet/unpin": PostEvent; "account/follow": { actor: Account; target: Account }; "account/unfollow": { actor: Account; target: Account }; "account/profile_update": { actor: Account; changes: Record<string, unknown> }; "account/affiliate_update": { organization: Account; member: Account; action: string }; "account/callout": { caller: Account; token: Coin }; "account/callout_update": { updateOf: string; token?: Coin }; "account/thesis": { author: Account; coin: Coin; thesis: { text: string } }; "account/token_verified": { coin: Coin }; "news/article": { source?: { name?: string }; title?: string }; "charity/added": { name: string }; "control/twitter_handles_result": { results: Array<{ input: string; state: string }>; error: string | null; }; }; type Pair = keyof Payloads; type Handler<P> = (d: P, receivedAt: number) => void; // What this program keeps for each post. type StoredPost = { content: Partial<PostUpdate> | null; meta: PostMeta | null; deleted: boolean; pinned: boolean; hasContent: boolean; }; const API_KEY = process.env.TWEETSTREAM_API_KEY?.trim(); const DASHBOARD = "https://tweetstream.io/dashboard?tab=api"; if (!API_KEY) { console.error(`Set TWEETSTREAM_API_KEY. Copy your key from ${DASHBOARD}`); process.exit(1); } const WS_URL = process.env.TWEETSTREAM_WS_URL || "wss://ws-global.tweetstream.io/ws"; const API_URL = "https://api.tweetstream.io"; const ACCOUNTS = (process.env.TWEETSTREAM_ACCOUNTS ?? "") .split(",") .map((handle) => handle.trim().replace(/^@/, "")) .filter(Boolean); const TWITTER_EPOCH_MS = 1288834974657n; const posts = new Map<string, StoredPost>(); // "platform:tweetId" -> stored post const counts = new Map<string, number>(); // "t/op" -> number of events seen const latencies: number[] = []; // publish-to-receipt ms for the most recent 10,000 new posts let attempt = 0; let hinted = false; const log = (label: string, text: string) => console.log(`${label.padEnd(9)} ${text}`); const at = (account: Account | undefined) => `@${String(account?.handle ?? account?.username ?? "unknown").replace(/^@/, "")}`; const clip = (text: unknown, max = 80) => { const flat = String(text ?? "").replace(/\s+/g, " ").trim(); return JSON.stringify(flat.length > max ? `${flat.slice(0, max - 1)}…` : flat); }; // Posts are keyed by (platform, tweetId). Content carries the platform on its author; // later operations carry it on d.platform. const platformOf = (d: PostEvent) => d.platform ?? d.author?.platform ?? "twitter"; function postFor(d: PostEvent, platform = platformOf(d)): StoredPost { const key = `${platform}:${d.tweetId}`; let post = posts.get(key); if (!post) { post = { content: null, deleted: false, hasContent: false, meta: null, pinned: false }; posts.set(key, post); if (posts.size > 10_000) { const oldest = posts.keys().next().value; if (oldest !== undefined) posts.delete(oldest); // bound memory } } return post; } // Updates carry only the fields that changed, so merge them over what you stored. const merge = (base: Partial<PostUpdate> | null, patch: Partial<PostUpdate> | null) => ({ ...base, ...patch, author: { ...base?.author, ...patch?.author }, }); function publishedAtMs(platform: string, d: PostContent): number | null { // X post IDs encode their publish time: (id >> 22) + the Twitter epoch. if (platform === "twitter" && /^\d+$/.test(d.tweetId)) { return Number((BigInt(d.tweetId) >> 22n) + TWITTER_EPOCH_MS); } return typeof d.createdAt === "number" ? d.createdAt : null; } function referenceChain(ref: Reference | undefined): string { const links: string[] = []; // A referenced post can reference another one through ref.subtweet. for (let r = ref, depth = 0; r && depth < 8; r = r.subtweet, depth++) { links.push(`${r.type ?? "ref"} ${at(r.author)} ${r.tweetId} ${clip(r.text, 40)}`); } return links.join(" -> "); } function describeMeta(meta: PostMeta): string { const price = (value: number | undefined, prefix = "") => (value == null ? "" : ` ${prefix}${value}`); const tokens = meta.detected?.tokens ?? []; const markets = meta.detected?.prediction ?? []; const parts: string[] = []; if (tokens.length) { parts.push(`tokens ${tokens.map((t) => `${t.symbol ?? t.contract}${price(t.priceUsd, "$")}`).join(", ")}`); } if (markets.length) { parts.push(`markets ${markets.map((m) => `${clip(m.title, 40)}${price(m.priceUsd)}`).join(", ")}`); } if (meta.ocr?.text) parts.push(`ocr ${clip(meta.ocr.text, 40)}`); return parts.join(" | ") || "no detections"; } // Delete, pin, and unpin change the stored post's state and keep its content. function lifecycle(d: PostEvent, label: string, change: Partial<StoredPost>) { Object.assign(postFor(d), change); log(label, `${at(d.author)} ${d.tweetId}`); } const handlers: { [P in Pair]: Handler<Payloads[P]> } = { "control/auth_pong": () => log("auth", "ok, waiting for events"), "tweet/content": (d, receivedAt) => { const platform = d.author?.platform ?? "twitter"; const post = postFor(d, platform); post.content = merge(d, post.content); // an update that arrived first still wins let latency = ""; const publishedAt = publishedAtMs(platform, d); if (!post.hasContent && publishedAt !== null) { latencies.push(receivedAt - publishedAt); if (latencies.length > 10_000) latencies.shift(); latency = `${receivedAt - publishedAt} ms `; } post.hasContent = true; const where = platform === "twitter" ? "" : ` (${platform})`; log("post", `${at(d.author)}${where} ${d.tweetId} ${latency}${clip(post.content.text)}`); if (d.ref) log("", `chain: ${referenceChain(d.ref)}`); }, "tweet/update": (d) => { const post = postFor(d); post.content = merge(post.content, d); // a ref in the update replaces the stored one const who = `${at(post.content.author)} ${d.tweetId}`; const changed = Object.keys(d).filter((k) => !["platform", "receivedAt", "tweetId"].includes(k)); if (d.ref) log("update", `${who} chain: ${referenceChain(d.ref)}`); else if (d.textUpdateType === "completion") log("update", `${who} full text ${clip(d.text)}`); else log("update", `${who} changed ${changed.join(", ")}`); }, "tweet/meta": (d) => { const post = postFor(d); post.meta = d; // each meta is a complete snapshot: replace it, never merge log("meta", `${at(post.content?.author)} ${d.tweetId} ${describeMeta(d)}`); }, "tweet/delete": (d) => lifecycle(d, "delete", { deleted: true }), "tweet/pin": (d) => lifecycle(d, "pin", { pinned: true }), "tweet/unpin": (d) => lifecycle(d, "unpin", { pinned: false }), "account/follow": (d) => log("follow", `${at(d.actor)} followed ${at(d.target)}`), "account/unfollow": (d) => log("unfollow", `${at(d.actor)} unfollowed ${at(d.target)}`), "account/profile_update": (d) => log("profile", `${at(d.actor)} changed ${Object.keys(d.changes ?? {}).join(", ")}`), "account/affiliate_update": (d) => log("affiliate", `${at(d.organization)} ${d.action} ${at(d.member)}`), "account/callout": (d) => log("callout", `${at(d.caller)} called ${d.token?.symbol} ${d.token?.address}`), "account/callout_update": (d) => log("callout", `update to ${d.updateOf}: ${d.token?.symbol}`), "account/thesis": (d) => log("thesis", `${at(d.author)} on ${d.coin?.symbol}: ${clip(d.thesis?.text, 60)}`), "account/token_verified": (d) => log("verified", `${d.coin?.symbol ?? d.coin?.address}`), "news/article": (d) => log("news", `${d.source?.name}: ${clip(d.title)}`), "charity/added": (d) => log("charity", d.name), "control/twitter_handles_result": (d) => log("handles", (d.results ?? []).map((r) => `${r.input} ${r.state}`).join(", ") || String(d.error)), }; const isPair = (pair: string): pair is Pair => Object.hasOwn(handlers, pair); function onMessage(data: unknown, receivedAt: number) { let event: Partial<Envelope> | null; try { event = JSON.parse(String(data)); } catch { log("skip", "frame is not JSON"); return; } if (event?.v !== 1 || typeof event.t !== "string" || typeof event.op !== "string") return; const pair = `${event.t}/${event.op}`; counts.set(pair, (counts.get(pair) ?? 0) + 1); if (!isPair(pair)) return; // unknown pairs are counted, then ignored // The payload is JSON from the server; the types above describe what each pair carries. const handle = handlers[pair] as Handler<unknown>; try { handle(event.d ?? {}, receivedAt); } catch (error) { log("error", `${pair}: ${error}`); // one malformed event must not stop the program } } function printSummary() { const sorted = [...latencies].sort((a, b) => a - b); const pct = (p: number) => sorted[Math.max(0, Math.ceil(p * sorted.length) - 1)]; const timing = sorted.length ? `p50 ${pct(0.5)} ms p95 ${pct(0.95)} ms (n=${sorted.length})` : "no posts yet"; const seen = [...counts].map(([pair, n]) => `${pair} ${n}`).join(", ") || "none"; log("summary", `latency ${timing} | events ${seen}`); } // POST /api/add-account answers with one row per handle. type AddAccountResponse = { results?: Array<{ input: string; state: string; message?: string }>; error?: string; }; async function addAccounts() { const response = await fetch(`${API_URL}/api/add-account`, { body: JSON.stringify({ accounts: ACCOUNTS }), headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" }, method: "POST", signal: AbortSignal.timeout(15_000), }); const body = (await response.json().catch(() => ({}))) as AddAccountResponse; // One row per handle, even on 207 (some failed) or 400 (all failed). Each row says why. const rows = Array.isArray(body.results) ? body.results : []; for (const row of rows) { log("accounts", `${row.input} ${row.state}${row.message ? `: ${row.message}` : ""}`); } if (!rows.length && !response.ok) { log("accounts", `add failed (${response.status}): ${body.error ?? "unknown error"}`); } } // GET /api/me reports which pool the key connects to and how many connections each has open. type ConnectionPool = { count: number; limit: number }; type MeResponse = { credentialScope?: "standard" | "ultra_speed"; websocket?: ConnectionPool; // standard keys ultraSpeed?: { websocket?: ConnectionPool } | null; // Ultra Speed keys }; async function explainRefusedConnection() { // Node's built-in WebSocket hides the HTTP status of a refused upgrade, so ask the REST API. const headers = { Authorization: `Bearer ${API_KEY}` }; const response = await fetch(`${API_URL}/api/me`, { headers, signal: AbortSignal.timeout(10_000), }).catch(() => null); if (response?.status === 401) { console.error(`API key rejected (401). Copy a current key from ${DASHBOARD}`); process.exit(1); } if (response?.status === 403) { console.error("Streaming is not active for this key (403). Start a trial or renew your plan."); process.exit(1); } const me = (response?.ok ? await response.json().catch(() => null) : null) as MeResponse | null; const pool = me?.credentialScope === "ultra_speed" ? me.ultraSpeed?.websocket : me?.websocket; if (pool && pool.count >= pool.limit) { log("limit", `all ${pool.limit} connections are in use; close another client`); } } function connect() { const socket = new WebSocket(WS_URL, ["tweetstream.v1", `tweetstream.auth.token.${API_KEY}`]); let openedAt: number | null = null; // set while this socket is open socket.addEventListener("open", () => { openedAt = Date.now(); log("connected", WS_URL); socket.send(JSON.stringify({ op: "auth_ping" })); // answered by control/auth_pong setTimeout(() => { if (openedAt !== null && !latencies.length && !hinted) { hinted = true; log("hint", "posts appear when a monitored account posts. Add accounts in the"); log("hint", "dashboard, or run again with TWEETSTREAM_ACCOUNTS=handle1,handle2"); } }, 120_000); }); // Take the receipt time before any parsing so latency excludes your own processing. socket.addEventListener("message", (event) => onMessage(event.data, Date.now())); let settled = false; // some runtimes fire both error and close; reconnect only once const reconnect = async (reason: string) => { if (settled) return; settled = true; if (openedAt === null) await explainRefusedConnection(); else if (Date.now() - openedAt >= 30_000) attempt = 0; // healthy session: reset backoff openedAt = null; const delay = Math.min(30_000, 1_000 * 2 ** attempt) + Math.floor(Math.random() * 250); attempt = Math.min(attempt + 1, 5); log("closed", `${reason}; reconnecting in ${(delay / 1000).toFixed(1)} s`); setTimeout(connect, delay); }; // Node's built-in WebSocket reports a refused or failed handshake with error and no close. socket.addEventListener("error", () => openedAt === null && reconnect("connection failed")); socket.addEventListener("close", (event) => reconnect(`code ${event.code}`)); } process.on("SIGINT", () => { printSummary(); process.exit(0); }); setInterval(printSummary, 60_000); if (ACCOUNTS.length) { addAccounts() .catch((error: Error) => log("accounts", `add failed: ${error.message}`)) .finally(connect); } else { connect(); }保存为
tweetstream-example.ts用你的 key 运行
终端bashTWEETSTREAM_API_KEY=YOUR_API_KEY node tweetstream-example.ts
需要 Python 3.10 或更高版本。运行命令会创建虚拟环境,并在其中安装 websockets。
复制程序
tweetstream_example.pypython"""TweetStream complete example: connect, reconnect, handle every event, and measure latency. Python 3.10 or later. One dependency, installed in a virtual environment: python3 -m venv tweetstream-venv tweetstream-venv/bin/pip install 'websockets>=14' TWEETSTREAM_API_KEY=YOUR_API_KEY tweetstream-venv/bin/python tweetstream_example.py Optional settings: TWEETSTREAM_ACCOUNTS=handle1,handle2 add these accounts before connecting TWEETSTREAM_WS_URL=wss://ws-iad.tweetstream.io/ws use the US endpoint """ import asyncio import collections import json import math import os import random import sys import time import urllib.error import urllib.request if sys.version_info < (3, 10): sys.exit(f"This example needs Python 3.10 or later; this is {sys.version.split()[0]}.") import websockets API_KEY = os.environ.get("TWEETSTREAM_API_KEY", "").strip() DASHBOARD = "https://tweetstream.io/dashboard?tab=api" if not API_KEY: sys.exit(f"Set TWEETSTREAM_API_KEY. Copy your key from {DASHBOARD}") WS_URL = os.environ.get("TWEETSTREAM_WS_URL") or "wss://ws-global.tweetstream.io/ws" API_URL = "https://api.tweetstream.io" ACCOUNTS = [ handle.strip().lstrip("@") for handle in os.environ.get("TWEETSTREAM_ACCOUNTS", "").split(",") if handle.strip() ] TWITTER_EPOCH_MS = 1288834974657 posts = {} # (platform, tweetId) -> {"content", "meta", "deleted", "pinned", "has_content"} counts = {} # "t/op" -> number of events seen latencies = collections.deque(maxlen=10_000) # publish-to-receipt ms, most recent new posts hinted = False def log(label, text): print(f"{label:<9} {text}", flush=True) def at(account): account = account if isinstance(account, dict) else {} return "@" + str(account.get("handle") or account.get("username") or "unknown").lstrip("@") def clip(text, limit=80): flat = " ".join(str(text or "").split()) return json.dumps(flat if len(flat) <= limit else flat[: limit - 1] + "…", ensure_ascii=False) def field(d, name, key): return (d.get(name) or {}).get(key) # Posts are keyed by (platform, tweetId). Content carries the platform on its author; # later operations carry it on d.platform. def post_for(d, platform=None): platform = platform or d.get("platform") or field(d, "author", "platform") or "twitter" key = (platform, d["tweetId"]) if key not in posts: posts[key] = {"content": None, "meta": None, "deleted": False, "pinned": False} posts[key]["has_content"] = False if len(posts) > 10_000: posts.pop(next(iter(posts))) # bound memory return posts[key] def merge(base, patch): # Updates carry only the fields that changed, so merge them over what you stored. base, patch = base or {}, patch or {} return {**base, **patch, "author": {**(base.get("author") or {}), **(patch.get("author") or {})}} def published_at_ms(platform, d): tweet_id = str(d.get("tweetId", "")) if platform == "twitter" and tweet_id.isdigit(): return (int(tweet_id) >> 22) + TWITTER_EPOCH_MS # X post IDs encode their publish time created_at = d.get("createdAt") return created_at if isinstance(created_at, (int, float)) else None def reference_chain(ref): links = [] # A referenced post can reference another one through subtweet. while ref and len(links) < 8: text = clip(ref.get("text"), 40) links.append(f"{ref.get('type', 'ref')} {at(ref.get('author'))} {ref.get('tweetId')} {text}") ref = ref.get("subtweet") return " -> ".join(links) def describe_meta(meta): detected = meta.get("detected") or {} tokens = [t.get("symbol") or t.get("contract") for t in detected.get("tokens") or []] prices = [t.get("priceUsd") for t in detected.get("tokens") or []] markets = [clip(m.get("title"), 40) for m in detected.get("prediction") or []] odds = [m.get("priceUsd") for m in detected.get("prediction") or []] parts = [] if tokens: parts.append("tokens " + ", ".join(t if p is None else f"{t} ${p}" for t, p in zip(tokens, prices))) if markets: parts.append("markets " + ", ".join(m if p is None else f"{m} {p}" for m, p in zip(markets, odds))) if field(meta, "ocr", "text"): parts.append(f"ocr {clip(meta['ocr']['text'], 40)}") return " | ".join(parts) or "no detections" def on_content(d, received_at): platform = field(d, "author", "platform") or "twitter" post = post_for(d, platform) post["content"] = merge(d, post["content"]) # an update that arrived first still wins latency = "" published_at = published_at_ms(platform, d) if not post["has_content"] and published_at is not None: latencies.append(round(received_at - published_at)) latency = f"{latencies[-1]} ms " post["has_content"] = True where = "" if platform == "twitter" else f" ({platform})" text = clip(post["content"].get("text")) log("post", f"{at(d.get('author'))}{where} {d['tweetId']} {latency}{text}") if d.get("ref"): log("", f"chain: {reference_chain(d['ref'])}") def on_update(d, _): post = post_for(d) post["content"] = merge(post["content"], d) # a ref in the update replaces the stored one who = f"{at(post['content'].get('author'))} {d['tweetId']}" changed = ", ".join(k for k in d if k not in ("platform", "receivedAt", "tweetId")) if d.get("ref"): log("update", f"{who} chain: {reference_chain(d['ref'])}") elif d.get("textUpdateType") == "completion": log("update", f"{who} full text {clip(d.get('text'))}") else: log("update", f"{who} changed {changed}") def on_meta(d, _): post = post_for(d) post["meta"] = d # each meta is a complete snapshot: replace it, never merge log("meta", f"{at((post['content'] or {}).get('author'))} {d['tweetId']} {describe_meta(d)}") def lifecycle(label, **change): # Delete, pin, and unpin change the stored post's state and keep its content. def handle(d, _): post_for(d).update(change) log(label, f"{at(d.get('author'))} {d['tweetId']}") return handle HANDLERS = { "tweet/content": on_content, "tweet/update": on_update, "tweet/meta": on_meta, "tweet/delete": lifecycle("delete", deleted=True), "tweet/pin": lifecycle("pin", pinned=True), "tweet/unpin": lifecycle("unpin", pinned=False), } def event_line(pair, d): # One readable line for account, news, charity, and control events. actor, target = at(d.get("actor")), at(d.get("target")) token, coin = d.get("token") or {}, d.get("coin") or {} results = ", ".join(f"{r.get('input')} {r.get('state')}" for r in d.get("results") or []) return { "control/auth_pong": ("auth", "ok, waiting for events"), "control/twitter_handles_result": ("handles", results or str(d.get("error"))), "account/follow": ("follow", f"{actor} followed {target}"), "account/unfollow": ("unfollow", f"{actor} unfollowed {target}"), "account/profile_update": ("profile", f"{actor} changed {', '.join(d.get('changes') or {})}"), "account/affiliate_update": ( "affiliate", f"{at(d.get('organization'))} {d.get('action')} {at(d.get('member'))}", ), "account/callout": ( "callout", f"{at(d.get('caller'))} called {token.get('symbol')} {token.get('address')}", ), "account/callout_update": ("callout", f"update to {d.get('updateOf')}: {token.get('symbol')}"), "account/thesis": ( "thesis", f"{at(d.get('author'))} on {coin.get('symbol')}: {clip(field(d, 'thesis', 'text'), 60)}", ), "account/token_verified": ("verified", coin.get("symbol") or coin.get("address")), "news/article": ("news", f"{field(d, 'source', 'name')}: {clip(d.get('title'))}"), "charity/added": ("charity", d.get("name")), }.get(pair) def on_message(raw, received_at): try: event = json.loads(raw) except ValueError: return log("skip", "frame is not JSON") # Envelope: {v, t, op, id?, ts, d}. Route by t, then op. if not isinstance(event, dict) or event.get("v") != 1: return pair = f"{event.get('t')}/{event.get('op')}" counts[pair] = counts.get(pair, 0) + 1 d = event.get("d") or {} try: if pair in HANDLERS: HANDLERS[pair](d, received_at) elif event_line(pair, d): # unknown pairs are counted, then ignored log(*event_line(pair, d)) except Exception as error: # one malformed event must not close the connection log("error", f"{pair}: {error!r}") def print_summary(): ordered = sorted(latencies) pct = lambda p: ordered[max(0, math.ceil(p * len(ordered)) - 1)] timing = f"p50 {pct(0.5)} ms p95 {pct(0.95)} ms (n={len(ordered)})" if ordered else "no posts yet" seen = ", ".join(f"{pair} {n}" for pair, n in counts.items()) or "none" log("summary", f"latency {timing} | events {seen}") def add_accounts(): request = urllib.request.Request( f"{API_URL}/api/add-account", data=json.dumps({"accounts": ACCOUNTS}).encode(), headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=15) as response: status, raw = response.status, response.read() except urllib.error.HTTPError as error: # 400, 429, and 503 can still carry results status, raw = error.code, error.read() except OSError as error: return log("accounts", f"add failed: {error}") try: body = json.loads(raw) except ValueError: body = {} body = body if isinstance(body, dict) else {} # One row per handle, even on 207 (some failed) or 400 (all failed). Each row says why. rows = body.get("results") if isinstance(body.get("results"), list) else [] for row in rows: message = f": {row['message']}" if row.get("message") else "" log("accounts", f"{row.get('input')} {row.get('state')}{message}") if not rows and status >= 300: log("accounts", f"add failed ({status}): {body.get('error') or 'unknown error'}") async def report_every_minute(): while True: await asyncio.sleep(60) print_summary() async def hint_when_quiet(): global hinted await asyncio.sleep(120) if not latencies and not hinted: hinted = True log("hint", "posts appear when a monitored account posts. Add accounts in the") log("hint", "dashboard, or run again with TWEETSTREAM_ACCOUNTS=handle1,handle2") async def main(): asyncio.create_task(report_every_minute()) protocols = ["tweetstream.v1", f"tweetstream.auth.token.{API_KEY}"] attempt = 0 while True: opened_at, reason = None, "connection closed" try: async with websockets.connect(WS_URL, subprotocols=protocols) as ws: opened_at = time.monotonic() log("connected", WS_URL) await ws.send(json.dumps({"op": "auth_ping"})) # answered by control/auth_pong hint = asyncio.create_task(hint_when_quiet()) try: async for raw in ws: # Take the receipt time before parsing so latency excludes your own work. on_message(raw, time.time() * 1000) finally: hint.cancel() except Exception as error: # A refused upgrade carries its HTTP status on error.response. 429 means the pool # this key connects to (standard or Ultra Speed) has no free connection. status = getattr(getattr(error, "response", None), "status_code", None) if status == 401: sys.exit(f"API key rejected (401). Copy a current key from {DASHBOARD}") if status == 403: sys.exit("Streaming is not active for this key (403). Start a trial or renew your plan.") reason = "all connections are in use" if status == 429 else f"{type(error).__name__}: {error}" if opened_at is not None and time.monotonic() - opened_at >= 30: attempt = 0 # a healthy session resets backoff delay = min(30, 2**attempt) + random.random() / 4 attempt = min(attempt + 1, 5) log("closed", f"{reason}; reconnecting in {delay:.1f} s") await asyncio.sleep(delay) if __name__ == "__main__": if ACCOUNTS: add_accounts() try: asyncio.run(main()) except KeyboardInterrupt: print_summary()保存为
tweetstream_example.py用你的 key 运行
终端bashpython3 -m venv tweetstream-venv && \ tweetstream-venv/bin/pip install 'websockets>=14' && \ TWEETSTREAM_API_KEY=YOUR_API_KEY tweetstream-venv/bin/python tweetstream_example.py
预期输出
textconnected wss://ws-global.tweetstream.io/ws
auth ok, waiting for events
post @marketdesk 2104934217937850514 183 ms "SOL breaks out of the 4h range. Watching $98.5 as…"
update @marketdesk 2104934217937850514 chain: quote @projectteam 2104932246614966349 "Mainnet is live." -> reply @devteam 2104929205744566281 "Ship date?"
update @marketdesk 2104934217937850514 full text "SOL breaks out of the 4h range. Watching $98.5 as support. Chart below."
meta @marketdesk 2104934217937850514 tokens SOL $98.5 | ocr "SOL/USD 4h"
delete @marketdesk 2104934217937850514
follow @marketdesk followed @projectteam
summary latency p50 183 ms p95 183 ms (n=1) | events control/auth_pong 1, tweet/content 1, tweet/update 2, tweet/meta 1, tweet/delete 1, account/follow 1事件顺序
上面的预期输出就来自这些消息,其中的时间戳仅作示意。每条帖子都从 tweet/content 开始。同一 (platform, tweetId) 的 tweet/update 会为它补充字段,其中的 ref 把帖子与它引用或回复的帖子连接起来。
你发送
auth_ping连接打开后立即发送,用于检查 key。
json{ "op": "auth_ping" }服务端发送
control/auth_pongkey 有效。程序输出一行
auth,然后等待事件。json{ "v": 1, "t": "control", "op": "auth_pong", "ts": 1790690396000, "d": {} }服务端发送
tweet/content一条新帖子,这里的正文是缩短版。程序按
(platform, tweetId)保存它,并输出正文和延迟。json{ "v": 1, "t": "tweet", "op": "content", "id": "2104934217937850514", "ts": 1790690400160, "d": { "tweetId": "2104934217937850514", "text": "SOL breaks out of the 4h range. Watching $98.5 as…", "createdAt": 1790690400000, "receivedAt": 1790690400150, "kind": "quote", "author": { "id": "1450012345678901248", "handle": "@marketdesk", "name": "Market Desk", "platform": "twitter" } } }服务端发送
tweet/update为同一条帖子补充引用。程序把字段合并到已保存的帖子上,替换其
ref,并沿ref.subtweet输出引用链。json{ "v": 1, "t": "tweet", "op": "update", "id": "2104934217937850514", "ts": 1790690402000, "d": { "tweetId": "2104934217937850514", "platform": "twitter", "receivedAt": 1790690401990, "kind": "quote", "ref": { "type": "quote", "tweetId": "2104932246614966349", "text": "Mainnet is live.", "author": { "id": "1380123456789012480", "handle": "@projectteam", "name": "Project Team" }, "subtweet": { "type": "reply", "tweetId": "2104929205744566281", "text": "Ship date?", "author": { "id": "1290123456789012481", "handle": "@devteam", "name": "Dev Team" } } } } }服务端发送
tweet/update提供完整正文,
textUpdateType为completion。程序把它合并到已保存的帖子上,已有的ref保持不变。json{ "v": 1, "t": "tweet", "op": "update", "id": "2104934217937850514", "ts": 1790690403000, "d": { "tweetId": "2104934217937850514", "platform": "twitter", "receivedAt": 1790690402990, "text": "SOL breaks out of the 4h range. Watching $98.5 as support. Chart below.", "textUpdateType": "completion" } }服务端发送
tweet/meta帖子的富化数据。程序用这份快照替换已保存的
meta,并输出代币、预测市场和 OCR 文本。json{ "v": 1, "t": "tweet", "op": "meta", "id": "2104934217937850514", "ts": 1790690404000, "d": { "tweetId": "2104934217937850514", "platform": "twitter", "receivedAt": 1790690403990, "detected": { "tokens": [ { "chain": "solana", "contract": "So11111111111111111111111111111111111111112", "name": "Wrapped SOL", "networkId": 1399811149, "priceUsd": 98.5, "sources": [ "text", "ocr" ], "symbol": "SOL" } ] }, "ocr": { "text": "SOL/USD 4h" } } }服务端发送
tweet/delete作者删除了帖子。程序把它标记为已删除,内容保留。
json{ "v": 1, "t": "tweet", "op": "delete", "id": "2104934217937850514", "ts": 1790690430000, "d": { "eventId": "delete.264da58762fac7204da8", "tweetId": "2104934217937850514", "platform": "twitter", "author": { "id": "1450012345678901248", "handle": "@marketdesk" }, "deletedAt": 1790690429990, "receivedAt": 1790690429990 } }服务端发送
account/follow一个监控账号关注了另一个账号。程序输出一行,不改动已保存的帖子。
json{ "v": 1, "t": "account", "op": "follow", "id": "follow-1450012345678901248-1380123456789012480", "ts": 1790690438000, "d": { "actor": { "id": "1450012345678901248", "handle": "@marketdesk" }, "target": { "id": "1380123456789012480", "handle": "@projectteam" }, "kind": "FOLLOW", "eventId": "follow-1450012345678901248-1380123456789012480", "observedAt": 1790690437990, "receivedAt": 1790690437990 } }
连接与重连
connect() 使用两个子协议 tweetstream.v1 和 tweetstream.auth.token.<API_KEY> 打开连接,然后发送 auth_ping。收到 control/auth_pong 即表示 key 有效。
断线后,程序依次等待 1、2、4、8、16 秒,之后每次等待 30 秒,并加入少量随机抖动。连接保持 30 秒后,等待时间会重置。
遇到 401 或 403 时,程序会输出说明并退出,因为重试无法修复 key 或套餐问题。Node.js 不提供被拒连接的 HTTP 状态码,所以 TypeScript 程序会调用 GET /api/me 查询原因。Python 程序直接从 websockets 的错误中读取状态码。
添加账号
TWEETSTREAM_ACCOUNTS 中有账号时,addAccounts() 会在连接前把它们发送到 POST https://api.tweetstream.io/api/add-account。207 响应表示部分账号添加成功、部分失败,因此程序为每个账号输出一行,并显示其 state。
程序退出后,已添加的账号仍保留在监控列表中。
帖子与更新链
程序按 (platform, tweetId) 保存每条帖子。tweet/content 创建帖子,tweet/update 只合并它携带的字段,已有的正文和作者信息保持不变。
更新包含 ref 时,程序会替换已保存的引用,并沿 ref.subtweet 逐层输出引用链。更新可能先于内容到达,合并时会保留更新中的字段。tweet/delete、tweet/pin 和 tweet/unpin 只改变已保存帖子的状态,不改动内容。
// Updates carry only the fields that changed, so merge them over what you stored.
const merge = (base: Partial<PostUpdate> | null, patch: Partial<PostUpdate> | null) => ({
...base,
...patch,
author: { ...base?.author, ...patch?.author },
});
function referenceChain(ref: Reference | undefined): string {
const links: string[] = [];
// A referenced post can reference another one through ref.subtweet.
for (let r = ref, depth = 0; r && depth < 8; r = r.subtweet, depth++) {
links.push(`${r.type ?? "ref"} ${at(r.author)} ${r.tweetId} ${clip(r.text, 40)}`);
}
return links.join(" -> ");
}
"tweet/update": (d) => {
const post = postFor(d);
post.content = merge(post.content, d); // a ref in the update replaces the stored one
const who = `${at(post.content.author)} ${d.tweetId}`;
const changed = Object.keys(d).filter((k) => !["platform", "receivedAt", "tweetId"].includes(k));
if (d.ref) log("update", `${who} chain: ${referenceChain(d.ref)}`);
else if (d.textUpdateType === "completion") log("update", `${who} full text ${clip(d.text)}`);
else log("update", `${who} changed ${changed.join(", ")}`);
},def merge(base, patch):
# Updates carry only the fields that changed, so merge them over what you stored.
base, patch = base or {}, patch or {}
return {**base, **patch, "author": {**(base.get("author") or {}), **(patch.get("author") or {})}}
def reference_chain(ref):
links = []
# A referenced post can reference another one through subtweet.
while ref and len(links) < 8:
text = clip(ref.get("text"), 40)
links.append(f"{ref.get('type', 'ref')} {at(ref.get('author'))} {ref.get('tweetId')} {text}")
ref = ref.get("subtweet")
return " -> ".join(links)
def on_update(d, _):
post = post_for(d)
post["content"] = merge(post["content"], d) # a ref in the update replaces the stored one
who = f"{at(post['content'].get('author'))} {d['tweetId']}"
changed = ", ".join(k for k in d if k not in ("platform", "receivedAt", "tweetId"))
if d.get("ref"):
log("update", f"{who} chain: {reference_chain(d['ref'])}")
elif d.get("textUpdateType") == "completion":
log("update", f"{who} full text {clip(d.get('text'))}")
else:
log("update", f"{who} changed {changed}")富化数据
tweet/meta 可能在帖子之后到达。每条都是完整快照,所以程序会替换已保存的元数据,而不是合并。
meta 行会列出检测到的代币及价格、预测市场及价格,以及从图片中识别的 OCR 文本(如有)。
其他事件
关注、取消关注、资料变更、关联账号变更、喊单、FOMO 观点与代币认证、新闻文章和慈善项目通知都会各输出一行。喊单、新闻、慈善和 FOMO 事件需要先在控制台开启才会收到。
遇到未知的 t 和 op 组合时,程序只计数并跳过,连接保持打开。某个处理函数出错时,程序会输出一行 error 并继续读取。
测量延迟
消息处理函数在解析任何内容之前先读取时钟。对于 X 帖子,发布时间来自帖子 ID:将 ID 右移 22 位,再加上 Twitter 纪元 1288834974657。其他平台使用 createdAt。
两者之差就是帖子从发布到在你的机器上被接收的时间,其中包括到你这里的网络传输。重放的帖子不会再次计入样本。程序每 60 秒以及你按下 Ctrl+C 时,都会输出最近 10,000 条帖子的 p50 和 p95,以及每种事件的数量。
function publishedAtMs(platform: string, d: PostContent): number | null {
// X post IDs encode their publish time: (id >> 22) + the Twitter epoch.
if (platform === "twitter" && /^\d+$/.test(d.tweetId)) {
return Number((BigInt(d.tweetId) >> 22n) + TWITTER_EPOCH_MS);
}
return typeof d.createdAt === "number" ? d.createdAt : null;
}def published_at_ms(platform, d):
tweet_id = str(d.get("tweetId", ""))
if platform == "twitter" and tweet_id.isdigit():
return (int(tweet_id) >> 22) + TWITTER_EPOCH_MS # X post IDs encode their publish time
created_at = d.get("createdAt")
return created_at if isinstance(created_at, (int, float)) else None可选设置
在运行命令中,把这些环境变量和 TWEETSTREAM_API_KEY 放在一起。
| 变量 | 默认值 | 用途 |
|---|---|---|
TWEETSTREAM_API_KEY | 必填 | 你的 API key。程序不会输出它。 |
TWEETSTREAM_ACCOUNTS | 无 | 连接前要添加的账号,用逗号分隔,例如 marketdesk,projectteam |
TWEETSTREAM_WS_URL | wss://ws-global.tweetstream.io/ws | 在美国运行时使用 wss://ws-iad.tweetstream.io/ws |
常见错误
- 把
tweet/update当作新帖子。它属于你按同一(platform, tweetId)保存的帖子。 - 用更新替换已保存的帖子。更新只携带发生变化的字段,应当合并。
- 丢弃先于内容到达的更新。先保留它,内容到达后再合并进来,不覆盖更新中的字段。
- 把
tweet/meta合并到旧的元数据中。每条都是完整快照,应替换已保存的那份。 - 用信封中的
ts或id给帖子排序或去重。帖子身份请使用(platform, tweetId)。 - 遇到未知的
t和op组合就关闭连接。跳过这条消息并继续读取。 - 对
401或403反复重试。新连接无法修复 key 或套餐问题。
故障排查
- 只看到
auth和summary行。监控账号发帖时才会收到帖子。请添加一个活跃账号,或用GET /api/me检查监控列表。连续两分钟没有帖子时,程序会输出提示。 - 程序因
API key rejected (401)退出。请从控制台重新复制 key。轮换后的旧 key 会失效。 - 程序因
Streaming is not active for this key (403)退出。请开始试用或续订套餐,然后重新运行。 - 程序提示所有连接都在使用中。你的套餐限制了同时打开的 WebSocket 连接数。请关闭另一个客户端,或等待旧连接关闭。
- 延迟为负数或远高于预期。这个数值比较的是你的时钟和帖子的发布时间,所以请用 NTP 同步时钟,并在运行交易系统的机器上测量。
- Node.js 报错
Unknown file extension ".ts"。说明你的 Node.js 低于 22.18。请升级 Node.js 后重新运行命令。 - Python 报错
No module named 'websockets'。请按照运行命令,用tweetstream-venv/bin/python运行程序,这个环境里已经安装了websockets。 - 创建虚拟环境时提示
ensurepip is not available。在 Debian 或 Ubuntu 上,请先用sudo apt install python3-venv安装 venv 模块,然后重新运行 Python 命令。 - pip 提示
No matching distribution found for websockets>=14。说明你的python3低于 3.10。请安装 Python 3.10 或更高版本,并用它创建虚拟环境。
在此基础上构建
要把这个程序改成长期运行的消费者,可以先看类型化分发器和参考消费者。