Skip to content

Commit 9c99cbf

Browse files
iriditejackwener
andauthored
feat(xueqiu): add Danjuan fund account commands (#391)
* feat(xueqiu): add danjuan fund account commands * refactor(xueqiu): convert danjuan fund YAML adapters to TS - Replace 3 YAML files with 4 TS files (shared utils + 3 commands) - Extract shared helpers: fetchDanjuanApi, fetchAssetGain, collectHoldings - Fix double-navigation by using navigateBefore instead of pipeline navigate - Unify error messages to English with Hint pattern - Mask real account ID in docs example - Add explicit default for --account arg * refactor(xueqiu): optimize danjuan fund adapters - Single page.evaluate with Promise.all for parallel account fetching (1 browser round-trip instead of N+1) - Merge fund-accounts into fund-holdings (account info visible per row) - 3 files: danjuan-utils.ts (shared), fund-holdings.ts, fund-snapshot.ts - Strong TypeScript interfaces for all data shapes - Update docs to reflect 2-command design * fix(xueqiu): preserve danjuan pre-navigation metadata * fix(xueqiu): fail on incomplete danjuan snapshots --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 297fd15 commit 9c99cbf

7 files changed

Lines changed: 333 additions & 12 deletions

File tree

docs/adapters/browser/xueqiu.md

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
# Xueqiu (雪球)
22

3-
**Mode**: 🔐 Browser · **Domain**: `xueqiu.com`
3+
**Mode**: 🔐 Browser · **Domain**: `xueqiu.com` / `danjuanfunds.com`
44

55
## Commands
66

77
| Command | Description |
88
|---------|-------------|
9-
| `opencli xueqiu feed` | |
10-
| `opencli xueqiu earnings-date` | |
11-
| `opencli xueqiu hot-stock` | |
12-
| `opencli xueqiu hot` | |
13-
| `opencli xueqiu search` | |
14-
| `opencli xueqiu stock` | |
15-
| `opencli xueqiu watchlist` | |
9+
| `opencli xueqiu feed` | 获取雪球首页时间线 |
10+
| `opencli xueqiu earnings-date` | 获取股票预计财报发布日期 |
11+
| `opencli xueqiu hot-stock` | 获取雪球热门股票榜 |
12+
| `opencli xueqiu hot` | 获取雪球热门动态 |
13+
| `opencli xueqiu search` | 搜索雪球股票(代码或名称) |
14+
| `opencli xueqiu stock` | 获取雪球股票实时行情 |
15+
| `opencli xueqiu watchlist` | 获取雪球自选股列表 |
16+
| `opencli xueqiu fund-holdings` | 获取蛋卷基金持仓明细(可用 `--account` 按子账户过滤) |
17+
| `opencli xueqiu fund-snapshot` | 获取蛋卷基金快照(总资产、子账户、持仓,推荐 `-f json`|
1618

1719
## Usage Examples
1820

@@ -29,6 +31,15 @@ opencli xueqiu stock SH600519
2931
# Upcoming earnings dates
3032
opencli xueqiu earnings-date SH600519 --next
3133

34+
# Danjuan all holdings
35+
opencli xueqiu fund-holdings
36+
37+
# Filter one Danjuan sub-account
38+
opencli xueqiu fund-holdings --account 默认账户
39+
40+
# Full Danjuan snapshot as JSON
41+
opencli xueqiu fund-snapshot -f json
42+
3243
# JSON output
3344
opencli xueqiu feed -f json
3445

@@ -38,5 +49,12 @@ opencli xueqiu feed -v
3849

3950
## Prerequisites
4051

41-
- Chrome running and **logged into** xueqiu.com
52+
- Chrome running and **logged into** `xueqiu.com`
53+
- For fund commands, Chrome must also be logged into `danjuanfunds.com` and able to open `https://danjuanfunds.com/my-money`
4254
- [Browser Bridge extension](/guide/browser-bridge) installed
55+
56+
## Notes
57+
58+
- `fund-holdings` exposes both market value and share fields (`volume`, `usableRemainShare`)
59+
- `fund-snapshot -f json` is the easiest way to persist a full account snapshot for later analysis or diffing
60+
- If the commands return empty data, first confirm the logged-in browser can directly see the Danjuan asset page

src/build-manifest.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,18 @@ describe('manifest helper rules', () => {
129129

130130
expect(scanTs(file, 'demo')).toBeNull();
131131
});
132+
133+
it('keeps literal domain and navigateBefore for TS adapters', () => {
134+
const file = path.join(process.cwd(), 'src', 'clis', 'xueqiu', 'fund-holdings.ts');
135+
const entry = scanTs(file, 'xueqiu');
136+
137+
expect(entry).toMatchObject({
138+
site: 'xueqiu',
139+
name: 'fund-holdings',
140+
domain: 'danjuanfunds.com',
141+
navigateBefore: 'https://danjuanfunds.com/my-money',
142+
type: 'ts',
143+
modulePath: 'xueqiu/fund-holdings.js',
144+
});
145+
});
132146
});

src/build-manifest.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,9 +260,14 @@ export function scanTs(filePath: string, site: string): ManifestEntry | null {
260260
entry.args = parseTsArgsBlock(argsBlock);
261261
}
262262

263-
// Extract navigateBefore: false
264-
const navMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
265-
if (navMatch) entry.navigateBefore = navMatch[1] === 'true' ? true : false;
263+
// Extract navigateBefore: false / true / 'https://...'
264+
const navBoolMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
265+
if (navBoolMatch) {
266+
entry.navigateBefore = navBoolMatch[1] === 'true';
267+
} else {
268+
const navStringMatch = src.match(/navigateBefore\s*:\s*['"`]([^'"`]+)['"`]/);
269+
if (navStringMatch) entry.navigateBefore = navStringMatch[1];
270+
}
266271

267272
return entry;
268273
} catch (err) {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { fetchDanjuanAll } from './danjuan-utils.js';
3+
4+
describe('fetchDanjuanAll', () => {
5+
it('throws when no Danjuan accounts are visible', async () => {
6+
const mockPage = {
7+
evaluate: vi.fn().mockResolvedValue({ _emptyAccounts: true }),
8+
} as any;
9+
10+
await expect(fetchDanjuanAll(mockPage)).rejects.toThrow('No fund accounts found');
11+
});
12+
13+
it('throws when any account detail request fails', async () => {
14+
const mockPage = {
15+
evaluate: vi.fn().mockResolvedValue({
16+
detailErrors: [
17+
{ accountName: '默认账户', accountId: 'acc-1', error: 403 },
18+
],
19+
}),
20+
} as any;
21+
22+
await expect(fetchDanjuanAll(mockPage)).rejects.toThrow(
23+
'Failed to fetch Danjuan account details: 默认账户 (acc-1): 403',
24+
);
25+
});
26+
27+
it('returns the combined snapshot when all account details succeed', async () => {
28+
const snapshot = {
29+
asOf: '2026-03-25',
30+
totalAssetAmount: 100,
31+
totalAssetDailyGain: 1,
32+
totalAssetHoldGain: 2,
33+
totalAssetTotalGain: 3,
34+
totalFundMarketValue: 80,
35+
accounts: [{ accountId: 'acc-1', accountName: '默认账户' }],
36+
holdings: [{ accountId: 'acc-1', fdCode: '000001', fdName: '示例基金' }],
37+
detailErrors: [],
38+
};
39+
const mockPage = {
40+
evaluate: vi.fn().mockResolvedValue(snapshot),
41+
} as any;
42+
43+
await expect(fetchDanjuanAll(mockPage)).resolves.toMatchObject({
44+
asOf: '2026-03-25',
45+
accounts: [{ accountId: 'acc-1', accountName: '默认账户' }],
46+
holdings: [{ accountId: 'acc-1', fdCode: '000001', fdName: '示例基金' }],
47+
});
48+
});
49+
});

src/clis/xueqiu/danjuan-utils.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* Shared helpers for Danjuan (蛋卷基金) adapters.
3+
*
4+
* Core design: a single page.evaluate call fetches the gain overview AND
5+
* all per-account holdings in parallel (Promise.all), minimising Node↔Browser
6+
* round-trips to exactly one.
7+
*/
8+
9+
import type { IPage } from '../../types.js';
10+
11+
export const DANJUAN_DOMAIN = 'danjuanfunds.com';
12+
export const DANJUAN_ASSET_PAGE = `https://${DANJUAN_DOMAIN}/my-money`;
13+
14+
const GAIN_URL = `https://${DANJUAN_DOMAIN}/djapi/fundx/profit/assets/gain?gains=%5B%22private%22%5D`;
15+
const SUMMARY_URL = `https://${DANJUAN_DOMAIN}/djapi/fundx/profit/assets/summary?invest_account_id=`;
16+
17+
// ---------------------------------------------------------------------------
18+
// Types — keep everything explicit so TS consumers get autocomplete.
19+
// ---------------------------------------------------------------------------
20+
21+
export interface DanjuanAccount {
22+
accountId: string;
23+
accountName: string;
24+
accountType: string;
25+
accountCode: string;
26+
marketValue: number | null;
27+
dailyGain: number | null;
28+
mainFlag: boolean;
29+
}
30+
31+
export interface DanjuanHolding {
32+
accountId: string;
33+
accountName: string;
34+
accountType: string;
35+
fdCode: string;
36+
fdName: string;
37+
category: string;
38+
marketValue: number | null;
39+
volume: number | null;
40+
usableRemainShare: number | null;
41+
dailyGain: number | null;
42+
holdGain: number | null;
43+
holdGainRate: number | null;
44+
totalGain: number | null;
45+
nav: number | null;
46+
marketPercent: number | null;
47+
}
48+
49+
export interface DanjuanSnapshot {
50+
asOf: string | null;
51+
totalAssetAmount: number | null;
52+
totalAssetDailyGain: number | null;
53+
totalAssetHoldGain: number | null;
54+
totalAssetTotalGain: number | null;
55+
totalFundMarketValue: number | null;
56+
accounts: DanjuanAccount[];
57+
holdings: DanjuanHolding[];
58+
}
59+
60+
// ---------------------------------------------------------------------------
61+
// Single-evaluate fetcher
62+
// ---------------------------------------------------------------------------
63+
64+
/**
65+
* Fetch the complete Danjuan fund picture in ONE browser round-trip.
66+
*
67+
* Inside the browser context we:
68+
* 1. Fetch the gain/assets overview (contains account list)
69+
* 2. Promise.all → fetch every account's holdings in parallel
70+
* 3. Return the combined result to Node
71+
*/
72+
export async function fetchDanjuanAll(page: IPage): Promise<DanjuanSnapshot> {
73+
const raw: any = await page.evaluate(`
74+
(async () => {
75+
const f = async (u) => {
76+
const r = await fetch(u, { credentials: 'include' });
77+
if (!r.ok) return { _err: r.status };
78+
try { return await r.json(); } catch { return { _err: 'parse' }; }
79+
};
80+
const n = (v) => { const x = Number(v); return Number.isFinite(x) ? x : null; };
81+
82+
const gain = await f(${JSON.stringify(GAIN_URL)});
83+
if (gain._err) return { _httpError: gain._err };
84+
85+
const root = gain.data || {};
86+
const fundSec = (root.items || []).find(i => i && i.summary_type === 'FUND');
87+
const rawAccs = fundSec && Array.isArray(fundSec.invest_account_list)
88+
? fundSec.invest_account_list : [];
89+
90+
const accounts = rawAccs.map(a => ({
91+
accountId: String(a.invest_account_id || ''),
92+
accountName: a.invest_account_name || '',
93+
accountType: a.invest_account_type || '',
94+
accountCode: a.invest_account_code || '',
95+
marketValue: n(a.market_value),
96+
dailyGain: n(a.daily_gain),
97+
mainFlag: !!a.main_flag,
98+
}));
99+
100+
if (!accounts.length) {
101+
return { _emptyAccounts: true };
102+
}
103+
104+
const details = await Promise.all(
105+
accounts.map(a => f(${JSON.stringify(SUMMARY_URL)} + encodeURIComponent(a.accountId)))
106+
);
107+
108+
const holdings = [];
109+
const detailErrors = [];
110+
for (let i = 0; i < accounts.length; i++) {
111+
const d = details[i];
112+
if (d._err) {
113+
detailErrors.push({
114+
accountId: accounts[i].accountId,
115+
accountName: accounts[i].accountName,
116+
error: d._err,
117+
});
118+
continue;
119+
}
120+
const data = d.data || {};
121+
const funds = Array.isArray(data.items) ? data.items : [];
122+
const acc = accounts[i];
123+
for (const fd of funds) {
124+
holdings.push({
125+
accountId: acc.accountId,
126+
accountName: data.invest_account_name || acc.accountName,
127+
accountType: data.invest_account_type || acc.accountType,
128+
fdCode: fd.fd_code || '',
129+
fdName: fd.fd_name || '',
130+
category: fd.category_text || fd.category || '',
131+
marketValue: n(fd.market_value),
132+
volume: n(fd.volume),
133+
usableRemainShare:n(fd.usable_remain_share),
134+
dailyGain: n(fd.daily_gain),
135+
holdGain: n(fd.hold_gain),
136+
holdGainRate: n(fd.hold_gain_rate),
137+
totalGain: n(fd.total_gain),
138+
nav: n(fd.nav),
139+
marketPercent: n(fd.market_percent),
140+
});
141+
}
142+
}
143+
144+
return {
145+
asOf: root.daily_gain_date || null,
146+
totalAssetAmount: n(root.amount),
147+
totalAssetDailyGain: n(root.daily_gain),
148+
totalAssetHoldGain: n(root.hold_gain),
149+
totalAssetTotalGain: n(root.total_gain),
150+
totalFundMarketValue:n(fundSec && fundSec.amount),
151+
accounts,
152+
holdings,
153+
detailErrors,
154+
};
155+
})()
156+
`);
157+
158+
if (raw?._httpError) {
159+
throw new Error(`HTTP ${raw._httpError} — Hint: not logged in to ${DANJUAN_DOMAIN}?`);
160+
}
161+
if (raw?._emptyAccounts) {
162+
throw new Error(`No fund accounts found — Hint: not logged in to ${DANJUAN_DOMAIN}?`);
163+
}
164+
if (Array.isArray(raw?.detailErrors) && raw.detailErrors.length > 0) {
165+
const failedAccounts = raw.detailErrors
166+
.map((item: { accountName?: string; accountId?: string; error?: string | number }) => {
167+
const label = item.accountName && item.accountId
168+
? `${item.accountName} (${item.accountId})`
169+
: item.accountName || item.accountId || 'unknown account';
170+
return `${label}: ${item.error}`;
171+
})
172+
.join(', ');
173+
throw new Error(`Failed to fetch Danjuan account details: ${failedAccounts}`);
174+
}
175+
return raw as DanjuanSnapshot;
176+
}

src/clis/xueqiu/fund-holdings.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { cli, Strategy } from '../../registry.js';
2+
import type { IPage } from '../../types.js';
3+
import { fetchDanjuanAll } from './danjuan-utils.js';
4+
5+
cli({
6+
site: 'xueqiu',
7+
name: 'fund-holdings',
8+
description: '获取蛋卷基金持仓明细(可用 --account 按子账户过滤)',
9+
domain: 'danjuanfunds.com',
10+
strategy: Strategy.COOKIE,
11+
navigateBefore: 'https://danjuanfunds.com/my-money',
12+
args: [
13+
{ name: 'account', type: 'str', default: '', help: '按子账户名称或 ID 过滤' },
14+
],
15+
columns: ['accountName', 'fdCode', 'fdName', 'marketValue', 'volume', 'dailyGain', 'holdGain', 'holdGainRate', 'marketPercent'],
16+
func: async (page: IPage, args) => {
17+
const snapshot = await fetchDanjuanAll(page);
18+
if (!snapshot.accounts.length) {
19+
throw new Error('No fund accounts found — Hint: not logged in to danjuanfunds.com?');
20+
}
21+
22+
const filter = String(args.account ?? '').trim();
23+
const rows = filter
24+
? snapshot.holdings.filter(h => h.accountId === filter || h.accountName.includes(filter))
25+
: snapshot.holdings;
26+
27+
if (!rows.length) {
28+
throw new Error(filter ? `No holdings matched account filter: ${filter}` : 'No holdings found.');
29+
}
30+
return rows;
31+
},
32+
});

src/clis/xueqiu/fund-snapshot.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { cli, Strategy } from '../../registry.js';
2+
import type { IPage } from '../../types.js';
3+
import { fetchDanjuanAll } from './danjuan-utils.js';
4+
5+
cli({
6+
site: 'xueqiu',
7+
name: 'fund-snapshot',
8+
description: '获取蛋卷基金快照(总资产、子账户、持仓,推荐 -f json 输出)',
9+
domain: 'danjuanfunds.com',
10+
strategy: Strategy.COOKIE,
11+
navigateBefore: 'https://danjuanfunds.com/my-money',
12+
args: [],
13+
columns: ['asOf', 'totalAssetAmount', 'totalFundMarketValue', 'accountCount', 'holdingCount'],
14+
func: async (page: IPage) => {
15+
const s = await fetchDanjuanAll(page);
16+
return [{
17+
asOf: s.asOf,
18+
totalAssetAmount: s.totalAssetAmount,
19+
totalAssetDailyGain: s.totalAssetDailyGain,
20+
totalFundMarketValue: s.totalFundMarketValue,
21+
accountCount: s.accounts.length,
22+
holdingCount: s.holdings.length,
23+
accounts: s.accounts,
24+
holdings: s.holdings,
25+
}];
26+
},
27+
});

0 commit comments

Comments
 (0)