Skip to content

Commit 0f903f5

Browse files
authored
chore(clis/eastmoney): mirror 13 adapters + _secid helper as Phase A oracle (#1091)
Mirror the remaining 13 read-oriented adapters and the shared _secid.js helper from the author's local workspace into the repo, so that clis/eastmoney/ becomes the full Phase A codegen regression oracle described in OpenCLI Improvement Spec v1.1 §B.10. Total repo oracle after this PR: 14 adapters under clis/eastmoney/ (hot-rank.js already exists; this PR adds the other 13) plus the _secid.js normalize helper. Covers the two schema-expressiveness gaps discovered during prep: - CSV row_format: kline.js decodes "YYYYMMDD,open,close,..." strings - :row_index source: convertible.js derives rank = i + 1 _secid.js is the canonical example of the v1.1 §B.7 helper contract (pure normalize/derive function, serializable I/O, no env/fs/net/session access, does not drive pagination/retry/fallback). This PR is oracle-only, carries no framework changes. Phase A framework PR depends on this merging first so the codegen diff target is stable. Refs: task #177 / spec v1.1 §B.10
1 parent 1639746 commit 0f903f5

14 files changed

Lines changed: 1062 additions & 0 deletions

clis/eastmoney/_secid.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Shared helpers for resolving eastmoney "secid" (市场.代码).
2+
//
3+
// Markets:
4+
// 1.XXXXXX → Shanghai A (SSE)
5+
// 0.XXXXXX → Shenzhen A (SZSE) or Beijing (BSE) — eastmoney groups both under 0
6+
// 116.XXXXX → Hong Kong
7+
// 105.SYMBOL → NASDAQ
8+
// 106.SYMBOL → NYSE
9+
// 107.SYMBOL → AMEX (US)
10+
11+
const A_PREFIX_TO_MARKET = /** @param {string} c */ (c) => {
12+
if (/^(60|68|90|113|900)/.test(c)) return '1'; // SH (A + STAR + old B)
13+
if (/^(00|30|20)/.test(c)) return '0'; // SZ (A + ChiNext + B)
14+
if (/^(4|8|920|83|87)/.test(c)) return '0'; // BJ (eastmoney uses 0.)
15+
return '0';
16+
};
17+
18+
/**
19+
* Resolve various user inputs to an eastmoney `secid`.
20+
* - "600000" → "1.600000"
21+
* - "sh600000" → "1.600000"
22+
* - "sz000001" → "0.000001"
23+
* - "bj430047" → "0.430047"
24+
* - "hk00700" / "00700.HK" → "116.00700"
25+
* - "us.AAPL" / "AAPL" → "105.AAPL"
26+
* - "1.600000" → passed through
27+
* @param {string} input
28+
* @returns {string}
29+
*/
30+
// Known eastmoney market numeric prefixes. Narrow whitelist so that inputs like
31+
// "00700.HK" are NOT mistakenly treated as secids just because they look like
32+
// "<digits>.<alphanumeric>".
33+
const KNOWN_MARKET_PREFIXES = new Set(['0', '1', '100', '105', '106', '107', '116', '140', '150', '151', '152', '155', '156']);
34+
35+
export function resolveSecid(input) {
36+
const raw = String(input || '').trim();
37+
if (!raw) throw new Error('empty symbol');
38+
const secidMatch = raw.match(/^(\d{1,3})\.([A-Za-z0-9]+)$/);
39+
if (secidMatch && KNOWN_MARKET_PREFIXES.has(secidMatch[1])) return raw; // already a secid
40+
const lower = raw.toLowerCase();
41+
42+
// market-prefixed Chinese code
43+
const pref = lower.match(/^(sh|sz|bj)(\d{6})$/);
44+
if (pref) {
45+
const [, mk, code] = pref;
46+
return (mk === 'sh' ? '1' : '0') + '.' + code;
47+
}
48+
49+
// hk prefix
50+
const hk = lower.match(/^hk(\d{4,5})$/) || lower.match(/^(\d{4,5})\.hk$/);
51+
if (hk) return '116.' + hk[1].padStart(5, '0');
52+
53+
// us.SYMBOL or SYMBOL.N/.O (treat all as NASDAQ by default; .N as NYSE)
54+
const usDot = lower.match(/^([a-z.\-]+)\.([no])$/);
55+
if (usDot) return (usDot[2] === 'n' ? '106' : '105') + '.' + usDot[1].toUpperCase();
56+
const usPref = lower.match(/^us\.([a-z.\-]+)$/);
57+
if (usPref) return '105.' + usPref[1].toUpperCase();
58+
59+
// bare 6-digit Chinese code
60+
if (/^\d{6}$/.test(raw)) return A_PREFIX_TO_MARKET(raw) + '.' + raw;
61+
62+
// bare US ticker — uppercase letters only
63+
if (/^[A-Z.\-]{1,8}$/.test(raw)) return '105.' + raw;
64+
65+
throw new Error(`Unrecognized symbol: ${input}`);
66+
}
67+
68+
/**
69+
* Normalize a list of user inputs separated by comma / space / Chinese comma.
70+
* @param {string} s
71+
* @returns {string[]}
72+
*/
73+
export function splitSymbols(s) {
74+
return String(s || '')
75+
.split(/[,\s]+/)
76+
.map((x) => x.trim())
77+
.filter(Boolean);
78+
}

clis/eastmoney/announcement.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// eastmoney announcement — listed company filings/announcements feed.
2+
//
3+
// opencli eastmoney announcement
4+
// opencli eastmoney announcement --market SHA --limit 30
5+
6+
import { cli, Strategy } from '@jackwener/opencli/registry';
7+
import { CliError } from '@jackwener/opencli/errors';
8+
9+
cli({
10+
site: 'eastmoney',
11+
name: 'announcement',
12+
description: '上市公司公告(按交易所筛选)',
13+
domain: 'np-anotice-stock.eastmoney.com',
14+
strategy: Strategy.PUBLIC,
15+
browser: false,
16+
args: [
17+
{ name: 'market', type: 'string', default: 'SHA,SZA,BJA', help: '交易所:SHA (沪) / SZA (深) / BJA (北) 可逗号分隔' },
18+
{ name: 'limit', type: 'int', default: 20, help: '返回数量 (max 100)' },
19+
],
20+
columns: ['time', 'code', 'name', 'title', 'category', 'url'],
21+
func: async (_page, args) => {
22+
const market = String(args.market ?? 'SHA,SZA,BJA').trim() || 'SHA,SZA,BJA';
23+
const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));
24+
25+
const url = new URL('https://np-anotice-stock.eastmoney.com/api/security/ann');
26+
url.searchParams.set('page_size', String(limit));
27+
url.searchParams.set('page_index', '1');
28+
url.searchParams.set('ann_type', market);
29+
url.searchParams.set('client_source', 'web');
30+
url.searchParams.set('f_node', '0');
31+
url.searchParams.set('s_node', '0');
32+
33+
const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
34+
if (!resp.ok) throw new CliError('HTTP_ERROR', `announcement failed: HTTP ${resp.status}`);
35+
const data = await resp.json();
36+
const list = Array.isArray(data?.data?.list) ? data.data.list : [];
37+
if (list.length === 0) throw new CliError('NO_DATA', 'eastmoney returned no announcement data');
38+
39+
return list.slice(0, limit).map((it) => {
40+
const primary = Array.isArray(it.codes) && it.codes.length > 0 ? it.codes[0] : {};
41+
const cat = Array.isArray(it.columns) && it.columns.length > 0 ? it.columns[0]?.column_name : '';
42+
return {
43+
time: String(it.notice_date || it.display_time || '').slice(0, 19),
44+
code: primary.stock_code || '',
45+
name: primary.short_name || '',
46+
title: it.title || it.title_ch || '',
47+
category: cat || '',
48+
url: `https://data.eastmoney.com/notices/detail/${primary.stock_code || ''}/${it.art_code || ''}.html`,
49+
};
50+
});
51+
},
52+
});

clis/eastmoney/convertible.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// eastmoney convertible — on-market convertible bond listing.
2+
//
3+
// opencli eastmoney convertible
4+
// opencli eastmoney convertible --sort premium --limit 30
5+
6+
import { cli, Strategy } from '@jackwener/opencli/registry';
7+
import { CliError } from '@jackwener/opencli/errors';
8+
9+
const SORTS = {
10+
change: { fid: 'f3', order: 'desc' },
11+
drop: { fid: 'f3', order: 'asc' },
12+
turnover: { fid: 'f6', order: 'desc' },
13+
price: { fid: 'f2', order: 'desc' },
14+
premium: { fid: 'f237', order: 'desc' }, // 转股溢价率
15+
value: { fid: 'f236', order: 'desc' }, // 转股价值
16+
ytm: { fid: 'f239', order: 'desc' }, // 到期收益率
17+
};
18+
19+
cli({
20+
site: 'eastmoney',
21+
name: 'convertible',
22+
description: '可转债行情列表(默认按成交额排序)',
23+
domain: 'push2.eastmoney.com',
24+
strategy: Strategy.PUBLIC,
25+
browser: false,
26+
args: [
27+
{ name: 'sort', type: 'string', default: 'turnover', help: '排序:turnover / change / drop / price / premium' },
28+
{ name: 'limit', type: 'int', default: 20, help: '返回数量 (max 100)' },
29+
],
30+
columns: ['rank', 'bondCode', 'bondName', 'bondPrice', 'bondChangePct', 'stockCode', 'stockName', 'stockPrice', 'stockChangePct', 'convPrice', 'convValue', 'convPremiumPct', 'remainingYears', 'ytm', 'listDate'],
31+
func: async (_page, args) => {
32+
const sortKey = String(args.sort ?? 'turnover').toLowerCase();
33+
const sort = SORTS[sortKey];
34+
if (!sort) throw new CliError('INVALID_ARGUMENT', `Unknown sort "${sortKey}". Valid: ${Object.keys(SORTS).join(', ')}`);
35+
const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));
36+
37+
const url = new URL('https://push2.eastmoney.com/api/qt/clist/get');
38+
url.searchParams.set('pn', '1');
39+
url.searchParams.set('pz', String(limit));
40+
url.searchParams.set('po', sort.order === 'desc' ? '1' : '0');
41+
url.searchParams.set('np', '1');
42+
url.searchParams.set('fltt', '2');
43+
url.searchParams.set('invt', '2');
44+
url.searchParams.set('fid', sort.fid);
45+
url.searchParams.set('fs', 'b:MK0354');
46+
url.searchParams.set('fields', 'f12,f14,f2,f3,f6,f229,f230,f232,f234,f235,f236,f237,f238,f239,f243');
47+
url.searchParams.set('ut', 'bd1d9ddb04089700cf9c27f6f7426281');
48+
49+
const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
50+
if (!resp.ok) throw new CliError('HTTP_ERROR', `convertible failed: HTTP ${resp.status}`);
51+
const data = await resp.json();
52+
const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];
53+
if (diff.length === 0) throw new CliError('NO_DATA', 'eastmoney returned no convertible data');
54+
55+
return diff.slice(0, limit).map((it, i) => ({
56+
rank: i + 1,
57+
bondCode: it.f12,
58+
bondName: it.f14,
59+
bondPrice: it.f2,
60+
bondChangePct: it.f3,
61+
stockCode: it.f232,
62+
stockName: it.f234,
63+
stockPrice: it.f229,
64+
stockChangePct: it.f230,
65+
convPrice: it.f235,
66+
convValue: it.f236,
67+
convPremiumPct: it.f237,
68+
remainingYears: it.f238,
69+
ytm: it.f239,
70+
listDate: String(it.f243 ?? ''),
71+
}));
72+
},
73+
});

clis/eastmoney/etf.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// eastmoney etf — ETF ranking by change / turnover.
2+
//
3+
// opencli eastmoney etf
4+
// opencli eastmoney etf --sort change --limit 30
5+
6+
import { cli, Strategy } from '@jackwener/opencli/registry';
7+
import { CliError } from '@jackwener/opencli/errors';
8+
9+
const SORTS = {
10+
turnover: { fid: 'f6', order: 'desc' },
11+
change: { fid: 'f3', order: 'desc' },
12+
drop: { fid: 'f3', order: 'asc' },
13+
volume: { fid: 'f5', order: 'desc' },
14+
rate: { fid: 'f8', order: 'desc' },
15+
};
16+
17+
cli({
18+
site: 'eastmoney',
19+
name: 'etf',
20+
description: 'ETF 列表按成交额/涨跌幅排行',
21+
domain: 'push2.eastmoney.com',
22+
strategy: Strategy.PUBLIC,
23+
browser: false,
24+
args: [
25+
{ name: 'sort', type: 'string', default: 'turnover', help: '排序:turnover / change / drop / volume / rate' },
26+
{ name: 'limit', type: 'int', default: 20, help: '返回数量 (max 100)' },
27+
],
28+
columns: ['rank', 'code', 'name', 'price', 'changePercent', 'change', 'turnover', 'volume', 'turnoverRate'],
29+
func: async (_page, args) => {
30+
const sortKey = String(args.sort ?? 'turnover').toLowerCase();
31+
const sort = SORTS[sortKey];
32+
if (!sort) throw new CliError('INVALID_ARGUMENT', `Unknown sort "${sortKey}". Valid: ${Object.keys(SORTS).join(', ')}`);
33+
const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));
34+
35+
const url = new URL('https://push2.eastmoney.com/api/qt/clist/get');
36+
url.searchParams.set('pn', '1');
37+
url.searchParams.set('pz', String(limit));
38+
url.searchParams.set('po', sort.order === 'desc' ? '1' : '0');
39+
url.searchParams.set('np', '1');
40+
url.searchParams.set('fltt', '2');
41+
url.searchParams.set('invt', '2');
42+
url.searchParams.set('fid', sort.fid);
43+
url.searchParams.set('fs', 'b:MK0021'); // 场内ETF
44+
url.searchParams.set('fields', 'f12,f14,f2,f3,f4,f5,f6,f8');
45+
url.searchParams.set('ut', 'bd1d9ddb04089700cf9c27f6f7426281');
46+
47+
const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
48+
if (!resp.ok) throw new CliError('HTTP_ERROR', `etf failed: HTTP ${resp.status}`);
49+
const data = await resp.json();
50+
const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];
51+
if (diff.length === 0) throw new CliError('NO_DATA', 'eastmoney returned no ETF data');
52+
53+
return diff.slice(0, limit).map((it, i) => ({
54+
rank: i + 1,
55+
code: it.f12,
56+
name: it.f14,
57+
price: it.f2,
58+
changePercent: it.f3,
59+
change: it.f4,
60+
turnover: it.f6,
61+
volume: it.f5,
62+
turnoverRate: it.f8,
63+
}));
64+
},
65+
});

clis/eastmoney/holders.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// eastmoney holders — top-10 float shareholders of an A-share (F10 data).
2+
//
3+
// opencli eastmoney holders 600519
4+
// opencli eastmoney holders sh600519 --limit 10
5+
6+
import { cli, Strategy } from '@jackwener/opencli/registry';
7+
import { CliError } from '@jackwener/opencli/errors';
8+
9+
/**
10+
* Convert a bare A-share symbol to eastmoney's SECUCODE form ("600519.SH").
11+
* Accepts "600519", "sh600519", "sz000001", "bj430047", or full "600519.SH".
12+
* @param {string} input
13+
* @returns {string}
14+
*/
15+
function toSecucode(input) {
16+
const raw = String(input || '').trim().toUpperCase();
17+
if (/^\d{6}\.(SH|SZ|BJ)$/.test(raw)) return raw;
18+
const pref = raw.match(/^(SH|SZ|BJ)(\d{6})$/);
19+
if (pref) return `${pref[2]}.${pref[1]}`;
20+
if (/^\d{6}$/.test(raw)) {
21+
if (/^(60|68|90|113|900)/.test(raw)) return `${raw}.SH`;
22+
if (/^(4|8|920|83|87)/.test(raw)) return `${raw}.BJ`;
23+
return `${raw}.SZ`;
24+
}
25+
throw new Error(`Unrecognized A-share symbol: ${input}`);
26+
}
27+
28+
cli({
29+
site: 'eastmoney',
30+
name: 'holders',
31+
description: '十大流通股东(A股 F10 数据)',
32+
domain: 'datacenter-web.eastmoney.com',
33+
strategy: Strategy.PUBLIC,
34+
browser: false,
35+
args: [
36+
{ name: 'symbol', required: true, positional: true, help: 'A股代码(600519 / sh600519 等)' },
37+
{ name: 'limit', type: 'int', default: 10, help: '返回股东数(默认十大流通股东)' },
38+
],
39+
columns: ['rank', 'reportDate', 'name', 'holdNum', 'floatRatio', 'change'],
40+
func: async (_page, args) => {
41+
/** @type {string} */
42+
let secucode;
43+
try { secucode = toSecucode(args.symbol); }
44+
catch (err) { throw new CliError('INVALID_ARGUMENT', `${err instanceof Error ? err.message : err}`); }
45+
const limit = Math.max(1, Math.min(Number(args.limit) || 10, 50));
46+
47+
const url = new URL('https://datacenter-web.eastmoney.com/api/data/v1/get');
48+
url.searchParams.set('sortColumns', 'END_DATE,HOLDER_RANK');
49+
url.searchParams.set('sortTypes', '-1,1');
50+
url.searchParams.set('pageSize', String(Math.max(limit, 10)));
51+
url.searchParams.set('pageNumber', '1');
52+
url.searchParams.set('reportName', 'RPT_F10_EH_FREEHOLDERS');
53+
url.searchParams.set('columns', 'SECUCODE,SECURITY_CODE,END_DATE,HOLDER_RANK,HOLDER_NAME,HOLD_NUM,FREE_HOLDNUM_RATIO,HOLD_NUM_CHANGE');
54+
url.searchParams.set('source', 'HSF10');
55+
url.searchParams.set('client', 'PC');
56+
url.searchParams.set('filter', `(SECUCODE="${secucode}")`);
57+
58+
const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
59+
if (!resp.ok) throw new CliError('HTTP_ERROR', `holders failed: HTTP ${resp.status}`);
60+
const data = await resp.json();
61+
const rows = Array.isArray(data?.result?.data) ? data.result.data : [];
62+
if (rows.length === 0) throw new CliError('NO_DATA', `No shareholder data for ${secucode}`);
63+
64+
// Only the most recent reporting period
65+
const latest = String(rows[0].END_DATE || '').slice(0, 10);
66+
return rows
67+
.filter((it) => String(it.END_DATE || '').slice(0, 10) === latest)
68+
.slice(0, limit)
69+
.map((it) => ({
70+
rank: it.HOLDER_RANK,
71+
reportDate: latest,
72+
name: it.HOLDER_NAME,
73+
holdNum: it.HOLD_NUM,
74+
floatRatio: it.FREE_HOLDNUM_RATIO,
75+
change: it.HOLD_NUM_CHANGE,
76+
}));
77+
},
78+
});

0 commit comments

Comments
 (0)