跳到文档正文

完整 WebSocket 示例

复制一个 TypeScript 或 Python 程序,用你的 API key 运行,就能看到它连接、重连、合并帖子更新、解析引用链,并为每条帖子计时。

获取 API 密钥
在聊天中打开
按产品代码校对:2026年9月29日

运行程序

把 YOUR_API_KEY 替换为控制台中的 key,试用 key 也可以。监控账号发帖时才会收到帖子,所以请先在控制台添加至少一个账号,或设置 TWEETSTREAM_ACCOUNTS。

需要 Node.js 22.18 或更高版本,它可以直接运行 TypeScript 文件。程序没有依赖。

  1. 复制程序

    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();
    }
  2. 保存为 tweetstream-example.ts

  3. 用你的 key 运行

    终端bash
    TWEETSTREAM_API_KEY=YOUR_API_KEY node tweetstream-example.ts

预期输出

text
connected 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 把帖子与它引用或回复的帖子连接起来。

  1. 你发送auth_ping

    连接打开后立即发送,用于检查 key。

    json
    {
      "op": "auth_ping"
    }
  2. 服务端发送control/auth_pong

    key 有效。程序输出一行 auth,然后等待事件。

    json
    {
      "v": 1,
      "t": "control",
      "op": "auth_pong",
      "ts": 1790690396000,
      "d": {}
    }
  3. 服务端发送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"
        }
      }
    }
  4. 服务端发送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"
            }
          }
        }
      }
    }
  5. 服务端发送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"
      }
    }
  6. 服务端发送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"
        }
      }
    }
  7. 服务端发送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
      }
    }
  8. 服务端发送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(", ")}`);
  },

富化数据

tweet/meta 可能在帖子之后到达。每条都是完整快照,所以程序会替换已保存的元数据,而不是合并。

meta 行会列出检测到的代币及价格、预测市场及价格,以及从图片中识别的 OCR 文本(如有)。

其他事件

关注、取消关注、资料变更、关联账号变更、喊单、FOMO 观点与代币认证、新闻文章和慈善项目通知都会各输出一行。喊单、新闻、慈善和 FOMO 事件需要先在控制台开启才会收到。

遇到未知的 t 和 op 组合时,程序只计数并跳过,连接保持打开。某个处理函数出错时,程序会输出一行 error 并继续读取。

测量延迟

消息处理函数在解析任何内容之前先读取时钟。对于 X 帖子,发布时间来自帖子 ID:将 ID 右移 22 位,再加上 Twitter 纪元 1288834974657。其他平台使用 createdAt。

两者之差就是帖子从发布到在你的机器上被接收的时间,其中包括到你这里的网络传输。重放的帖子不会再次计入样本。程序每 60 秒以及你按下 Ctrl+C 时,都会输出最近 10,000 条帖子的 p50 和 p95,以及每种事件的数量。

读取发布时间typescript
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;
}

可选设置

在运行命令中,把这些环境变量和 TWEETSTREAM_API_KEY 放在一起。

变量默认值用途
TWEETSTREAM_API_KEY必填你的 API key。程序不会输出它。
TWEETSTREAM_ACCOUNTS无连接前要添加的账号,用逗号分隔,例如 marketdesk,projectteam
TWEETSTREAM_WS_URLwss://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 或更高版本,并用它创建虚拟环境。

在此基础上构建

要把这个程序改成长期运行的消费者,可以先看类型化分发器和参考消费者。