diff --git a/cli-manifest.json b/cli-manifest.json index 48b6769de..37543769b 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -1607,6 +1607,342 @@ "modulePath": "bilibili/user-videos.js", "sourceFile": "bilibili/user-videos.js" }, + { + "site": "binance", + "name": "asks", + "description": "Order book ask prices for a trading pair", + "domain": "data-api.binance.vision", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "symbol", + "type": "str", + "required": true, + "positional": true, + "help": "Trading pair symbol (e.g. BTCUSDT, ETHUSDT)" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of price levels (5, 10, 20, 50, 100)" + } + ], + "columns": [ + "rank", + "ask_price", + "ask_qty" + ], + "type": "js", + "modulePath": "binance/asks.js", + "sourceFile": "binance/asks.js" + }, + { + "site": "binance", + "name": "depth", + "description": "Order book bid and ask prices for a trading pair", + "domain": "data-api.binance.vision", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "symbol", + "type": "str", + "required": true, + "positional": true, + "help": "Trading pair symbol (e.g. BTCUSDT, ETHUSDT)" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of price levels (5, 10, 20, 50, 100)" + } + ], + "columns": [ + "rank", + "bid_price", + "bid_qty", + "ask_price", + "ask_qty" + ], + "type": "js", + "modulePath": "binance/depth.js", + "sourceFile": "binance/depth.js" + }, + { + "site": "binance", + "name": "gainers", + "description": "Top gaining trading pairs by 24h price change", + "domain": "data-api.binance.vision", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of trading pairs" + } + ], + "columns": [ + "rank", + "symbol", + "price", + "change_24h", + "volume" + ], + "type": "js", + "modulePath": "binance/gainers.js", + "sourceFile": "binance/gainers.js" + }, + { + "site": "binance", + "name": "klines", + "description": "Candlestick/kline data for a trading pair", + "domain": "data-api.binance.vision", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "symbol", + "type": "str", + "required": true, + "positional": true, + "help": "Trading pair symbol (e.g. BTCUSDT, ETHUSDT)" + }, + { + "name": "interval", + "type": "str", + "default": "1d", + "required": false, + "help": "Kline interval (1m, 5m, 15m, 1h, 4h, 1d, 1w, 1M)" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of klines (max 1000)" + } + ], + "columns": [ + "open", + "high", + "low", + "close", + "volume" + ], + "type": "js", + "modulePath": "binance/klines.js", + "sourceFile": "binance/klines.js" + }, + { + "site": "binance", + "name": "losers", + "description": "Top losing trading pairs by 24h price change", + "domain": "data-api.binance.vision", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of trading pairs" + } + ], + "columns": [ + "rank", + "symbol", + "price", + "change_24h", + "volume" + ], + "type": "js", + "modulePath": "binance/losers.js", + "sourceFile": "binance/losers.js" + }, + { + "site": "binance", + "name": "pairs", + "description": "List active trading pairs on Binance", + "domain": "data-api.binance.vision", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of trading pairs" + } + ], + "columns": [ + "symbol", + "base", + "quote", + "status" + ], + "type": "js", + "modulePath": "binance/pairs.js", + "sourceFile": "binance/pairs.js" + }, + { + "site": "binance", + "name": "price", + "description": "Quick price check for a trading pair", + "domain": "data-api.binance.vision", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "symbol", + "type": "str", + "required": true, + "positional": true, + "help": "Trading pair symbol (e.g. BTCUSDT, ETHUSDT)" + } + ], + "columns": [ + "symbol", + "price", + "change", + "change_pct", + "high", + "low", + "volume", + "quote_volume", + "trades" + ], + "type": "js", + "modulePath": "binance/price.js", + "sourceFile": "binance/price.js" + }, + { + "site": "binance", + "name": "prices", + "description": "Latest prices for all trading pairs", + "domain": "data-api.binance.vision", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of prices" + } + ], + "columns": [ + "rank", + "symbol", + "price" + ], + "type": "js", + "modulePath": "binance/prices.js", + "sourceFile": "binance/prices.js" + }, + { + "site": "binance", + "name": "ticker", + "description": "24h ticker statistics for top trading pairs by volume", + "domain": "data-api.binance.vision", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of tickers" + } + ], + "columns": [ + "symbol", + "price", + "change_pct", + "high", + "low", + "volume", + "quote_vol", + "trades" + ], + "type": "js", + "modulePath": "binance/ticker.js", + "sourceFile": "binance/ticker.js" + }, + { + "site": "binance", + "name": "top", + "description": "Top trading pairs by 24h volume on Binance", + "domain": "data-api.binance.vision", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of trading pairs" + } + ], + "columns": [ + "rank", + "symbol", + "price", + "change_24h", + "high", + "low", + "volume" + ], + "type": "js", + "modulePath": "binance/top.js", + "sourceFile": "binance/top.js" + }, + { + "site": "binance", + "name": "trades", + "description": "Recent trades for a trading pair", + "domain": "data-api.binance.vision", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "symbol", + "type": "str", + "required": true, + "positional": true, + "help": "Trading pair symbol (e.g. BTCUSDT, ETHUSDT)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of trades (max 1000)" + } + ], + "columns": [ + "id", + "price", + "qty", + "quote_qty", + "buyer_maker" + ], + "type": "js", + "modulePath": "binance/trades.js", + "sourceFile": "binance/trades.js" + }, { "site": "bloomberg", "name": "businessweek", @@ -13193,14 +13529,14 @@ "help": "" } ], - "columns": [ - "author", - "text", - "likes", - "retweets", - "bookmarks", - "url" - ], + "columns": [ + "author", + "text", + "likes", + "retweets", + "bookmarks", + "url" + ], "type": "js", "modulePath": "twitter/bookmarks.js", "sourceFile": "twitter/bookmarks.js" diff --git a/clis/binance/depth.js b/clis/binance/depth.js index e9d6fe457..f9377be09 100644 --- a/clis/binance/depth.js +++ b/clis/binance/depth.js @@ -3,7 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; cli({ site: 'binance', name: 'depth', - description: 'Order book bid prices for a trading pair', + description: 'Order book bid and ask prices for a trading pair', domain: 'data-api.binance.vision', strategy: Strategy.PUBLIC, browser: false, @@ -11,11 +11,10 @@ cli({ { name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' }, { name: 'limit', type: 'int', default: 10, help: 'Number of price levels (5, 10, 20, 50, 100)' }, ], - columns: ['rank', 'bid_price', 'bid_qty'], + columns: ['rank', 'bid_price', 'bid_qty', 'ask_price', 'ask_qty'], pipeline: [ { fetch: { url: 'https://data-api.binance.vision/api/v3/depth?symbol=${{ args.symbol }}&limit=${{ args.limit }}' } }, - { select: 'bids' }, - { map: { rank: '${{ index + 1 }}', bid_price: '${{ item.0 }}', bid_qty: '${{ item.1 }}' } }, + { map: { select: 'bids', rank: '${{ index + 1 }}', bid_price: '${{ item[0] }}', bid_qty: '${{ item[1] }}', ask_price: '${{ root.asks[index]?.[0] ?? "" }}', ask_qty: '${{ root.asks[index]?.[1] ?? "" }}' } }, { limit: '${{ args.limit }}' }, ], }); diff --git a/src/pipeline/steps/transform.ts b/src/pipeline/steps/transform.ts index d070cc65e..788297af8 100644 --- a/src/pipeline/steps/transform.ts +++ b/src/pipeline/steps/transform.ts @@ -41,7 +41,7 @@ export async function stepMap(_page: IPage | null, params: unknown, data: unknow const row: Record = {}; for (const [key, template] of Object.entries(templateParams)) { if (key === 'select') continue; - row[key] = render(template, { args, data: source, item, index: i }); + row[key] = render(template, { args, data: source, root: data, item, index: i }); } result.push(row); } diff --git a/src/pipeline/template.test.ts b/src/pipeline/template.test.ts index deaf43b14..6253994c8 100644 --- a/src/pipeline/template.test.ts +++ b/src/pipeline/template.test.ts @@ -24,6 +24,9 @@ describe('resolvePath', () => { it('resolves data path', () => { expect(resolvePath('data.items', { data: { items: [1, 2, 3] } })).toEqual([1, 2, 3]); }); + it('resolves root path', () => { + expect(resolvePath('root.items', { root: { items: [1, 2, 3] } })).toEqual([1, 2, 3]); + }); it('returns null for missing path', () => { expect(resolvePath('args.missing', { args: {} })).toBeUndefined(); }); diff --git a/src/pipeline/template.ts b/src/pipeline/template.ts index f01596734..fb7ed66c5 100644 --- a/src/pipeline/template.ts +++ b/src/pipeline/template.ts @@ -7,6 +7,7 @@ import vm from 'node:vm'; export interface RenderContext { args?: Record; data?: unknown; + root?: unknown; item?: unknown; index?: number; } @@ -34,6 +35,7 @@ export function evalExpr(expr: string, ctx: RenderContext): unknown { const args = ctx.args ?? {}; const item = ctx.item ?? {}; const data = ctx.data; + const root = ctx.root; const index = ctx.index ?? 0; // ── Pipe filters: expr | filter1(arg) | filter2 ── @@ -55,12 +57,12 @@ export function evalExpr(expr: string, ctx: RenderContext): unknown { if (/^\d+(\.\d+)?$/.test(expr)) return Number(expr); // Try resolving as a simple dotted path (item.foo.bar, args.limit, index) - const resolved = resolvePath(expr, { args, item, data, index }); + const resolved = resolvePath(expr, { args, item, data, root, index }); if (resolved !== null && resolved !== undefined) return resolved; // Fallback: evaluate as JS in a sandboxed VM. // Handles ||, ??, arithmetic, ternary, method calls, etc. natively. - return evalJsExpr(expr, { args, item, data, index }); + return evalJsExpr(expr, { args, item, data, root, index }); } /** @@ -151,6 +153,7 @@ export function resolvePath(pathStr: string, ctx: RenderContext): unknown { const args = ctx.args ?? {}; const item = ctx.item ?? {}; const data = ctx.data; + const root = ctx.root; const index = ctx.index ?? 0; const parts = pathStr.split('.'); const rootName = parts[0]; @@ -159,6 +162,7 @@ export function resolvePath(pathStr: string, ctx: RenderContext): unknown { if (rootName === 'args') { obj = args; rest = parts.slice(1); } else if (rootName === 'item') { obj = item; rest = parts.slice(1); } else if (rootName === 'data') { obj = data; rest = parts.slice(1); } + else if (rootName === 'root') { obj = root; rest = parts.slice(1); } else if (rootName === 'index') return index; else { obj = item; rest = parts; } for (const part of rest) { @@ -256,6 +260,7 @@ function getReusableContext(): { sandbox: Record; context: vm.C args: {}, item: {}, data: null, + root: null, index: 0, encodeURIComponent, decodeURIComponent, @@ -275,7 +280,7 @@ function getReusableContext(): { sandbox: Record; context: vm.C /** Properties that are part of the sandbox's initial shape and safe to keep. */ const SANDBOX_WHITELIST = new Set([ - 'args', 'item', 'data', 'index', + 'args', 'item', 'data', 'root', 'index', 'encodeURIComponent', 'decodeURIComponent', 'JSON', 'Math', 'Number', 'String', 'Boolean', 'Array', 'Date', ]); @@ -303,6 +308,7 @@ function evalJsExpr(expr: string, ctx: RenderContext): unknown { sandbox.args = sanitizeContext(ctx.args ?? {}); sandbox.item = sanitizeContext(ctx.item ?? {}); sandbox.data = sanitizeContext(ctx.data); + sandbox.root = sanitizeContext(ctx.root); sandbox.index = ctx.index ?? 0; return script.runInContext(context, { timeout: 50 }); } catch { diff --git a/src/pipeline/transform.test.ts b/src/pipeline/transform.test.ts index e676b5dd0..0be4a2c6e 100644 --- a/src/pipeline/transform.test.ts +++ b/src/pipeline/transform.test.ts @@ -71,6 +71,22 @@ describe('stepMap', () => { { title: 'Two', rank: 2 }, ]); }); + + it('keeps data bound to the selected source and exposes root separately', async () => { + const result = await stepMap(null, { + select: 'bids', + bid_price: '${{ data[index][0] }}', + ask_price: '${{ root.asks[index][0] }}', + }, { + bids: [['100', '2'], ['99', '3']], + asks: [['101', '1'], ['102', '4']], + }, {}); + + expect(result).toEqual([ + { bid_price: '100', ask_price: '101' }, + { bid_price: '99', ask_price: '102' }, + ]); + }); }); describe('stepFilter', () => {