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>
This commit is contained in:
iridite
2026-03-25 13:32:41 +08:00
committed by GitHub
parent 297fd15f02
commit 9c99cbf8ab
7 changed files with 333 additions and 12 deletions
+27 -9
View File
@@ -1,18 +1,20 @@
# Xueqiu (雪球)
**Mode**: 🔐 Browser · **Domain**: `xueqiu.com`
**Mode**: 🔐 Browser · **Domain**: `xueqiu.com` / `danjuanfunds.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli xueqiu feed` | |
| `opencli xueqiu earnings-date` | |
| `opencli xueqiu hot-stock` | |
| `opencli xueqiu hot` | |
| `opencli xueqiu search` | |
| `opencli xueqiu stock` | |
| `opencli xueqiu watchlist` | |
| `opencli xueqiu feed` | 获取雪球首页时间线 |
| `opencli xueqiu earnings-date` | 获取股票预计财报发布日期 |
| `opencli xueqiu hot-stock` | 获取雪球热门股票榜 |
| `opencli xueqiu hot` | 获取雪球热门动态 |
| `opencli xueqiu search` | 搜索雪球股票(代码或名称) |
| `opencli xueqiu stock` | 获取雪球股票实时行情 |
| `opencli xueqiu watchlist` | 获取雪球自选股列表 |
| `opencli xueqiu fund-holdings` | 获取蛋卷基金持仓明细(可用 `--account` 按子账户过滤) |
| `opencli xueqiu fund-snapshot` | 获取蛋卷基金快照(总资产、子账户、持仓,推荐 `-f json` |
## Usage Examples
@@ -29,6 +31,15 @@ opencli xueqiu stock SH600519
# Upcoming earnings dates
opencli xueqiu earnings-date SH600519 --next
# Danjuan all holdings
opencli xueqiu fund-holdings
# Filter one Danjuan sub-account
opencli xueqiu fund-holdings --account 默认账户
# Full Danjuan snapshot as JSON
opencli xueqiu fund-snapshot -f json
# JSON output
opencli xueqiu feed -f json
@@ -38,5 +49,12 @@ opencli xueqiu feed -v
## Prerequisites
- Chrome running and **logged into** xueqiu.com
- Chrome running and **logged into** `xueqiu.com`
- For fund commands, Chrome must also be logged into `danjuanfunds.com` and able to open `https://danjuanfunds.com/my-money`
- [Browser Bridge extension](/guide/browser-bridge) installed
## Notes
- `fund-holdings` exposes both market value and share fields (`volume`, `usableRemainShare`)
- `fund-snapshot -f json` is the easiest way to persist a full account snapshot for later analysis or diffing
- If the commands return empty data, first confirm the logged-in browser can directly see the Danjuan asset page
+14
View File
@@ -129,4 +129,18 @@ describe('manifest helper rules', () => {
expect(scanTs(file, 'demo')).toBeNull();
});
it('keeps literal domain and navigateBefore for TS adapters', () => {
const file = path.join(process.cwd(), 'src', 'clis', 'xueqiu', 'fund-holdings.ts');
const entry = scanTs(file, 'xueqiu');
expect(entry).toMatchObject({
site: 'xueqiu',
name: 'fund-holdings',
domain: 'danjuanfunds.com',
navigateBefore: 'https://danjuanfunds.com/my-money',
type: 'ts',
modulePath: 'xueqiu/fund-holdings.js',
});
});
});
+8 -3
View File
@@ -260,9 +260,14 @@ export function scanTs(filePath: string, site: string): ManifestEntry | null {
entry.args = parseTsArgsBlock(argsBlock);
}
// Extract navigateBefore: false
const navMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
if (navMatch) entry.navigateBefore = navMatch[1] === 'true' ? true : false;
// Extract navigateBefore: false / true / 'https://...'
const navBoolMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
if (navBoolMatch) {
entry.navigateBefore = navBoolMatch[1] === 'true';
} else {
const navStringMatch = src.match(/navigateBefore\s*:\s*['"`]([^'"`]+)['"`]/);
if (navStringMatch) entry.navigateBefore = navStringMatch[1];
}
return entry;
} catch (err) {
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest';
import { fetchDanjuanAll } from './danjuan-utils.js';
describe('fetchDanjuanAll', () => {
it('throws when no Danjuan accounts are visible', async () => {
const mockPage = {
evaluate: vi.fn().mockResolvedValue({ _emptyAccounts: true }),
} as any;
await expect(fetchDanjuanAll(mockPage)).rejects.toThrow('No fund accounts found');
});
it('throws when any account detail request fails', async () => {
const mockPage = {
evaluate: vi.fn().mockResolvedValue({
detailErrors: [
{ accountName: '默认账户', accountId: 'acc-1', error: 403 },
],
}),
} as any;
await expect(fetchDanjuanAll(mockPage)).rejects.toThrow(
'Failed to fetch Danjuan account details: 默认账户 (acc-1): 403',
);
});
it('returns the combined snapshot when all account details succeed', async () => {
const snapshot = {
asOf: '2026-03-25',
totalAssetAmount: 100,
totalAssetDailyGain: 1,
totalAssetHoldGain: 2,
totalAssetTotalGain: 3,
totalFundMarketValue: 80,
accounts: [{ accountId: 'acc-1', accountName: '默认账户' }],
holdings: [{ accountId: 'acc-1', fdCode: '000001', fdName: '示例基金' }],
detailErrors: [],
};
const mockPage = {
evaluate: vi.fn().mockResolvedValue(snapshot),
} as any;
await expect(fetchDanjuanAll(mockPage)).resolves.toMatchObject({
asOf: '2026-03-25',
accounts: [{ accountId: 'acc-1', accountName: '默认账户' }],
holdings: [{ accountId: 'acc-1', fdCode: '000001', fdName: '示例基金' }],
});
});
});
+176
View File
@@ -0,0 +1,176 @@
/**
* Shared helpers for Danjuan (蛋卷基金) adapters.
*
* Core design: a single page.evaluate call fetches the gain overview AND
* all per-account holdings in parallel (Promise.all), minimising Node↔Browser
* round-trips to exactly one.
*/
import type { IPage } from '../../types.js';
export const DANJUAN_DOMAIN = 'danjuanfunds.com';
export const DANJUAN_ASSET_PAGE = `https://${DANJUAN_DOMAIN}/my-money`;
const GAIN_URL = `https://${DANJUAN_DOMAIN}/djapi/fundx/profit/assets/gain?gains=%5B%22private%22%5D`;
const SUMMARY_URL = `https://${DANJUAN_DOMAIN}/djapi/fundx/profit/assets/summary?invest_account_id=`;
// ---------------------------------------------------------------------------
// Types — keep everything explicit so TS consumers get autocomplete.
// ---------------------------------------------------------------------------
export interface DanjuanAccount {
accountId: string;
accountName: string;
accountType: string;
accountCode: string;
marketValue: number | null;
dailyGain: number | null;
mainFlag: boolean;
}
export interface DanjuanHolding {
accountId: string;
accountName: string;
accountType: string;
fdCode: string;
fdName: string;
category: string;
marketValue: number | null;
volume: number | null;
usableRemainShare: number | null;
dailyGain: number | null;
holdGain: number | null;
holdGainRate: number | null;
totalGain: number | null;
nav: number | null;
marketPercent: number | null;
}
export interface DanjuanSnapshot {
asOf: string | null;
totalAssetAmount: number | null;
totalAssetDailyGain: number | null;
totalAssetHoldGain: number | null;
totalAssetTotalGain: number | null;
totalFundMarketValue: number | null;
accounts: DanjuanAccount[];
holdings: DanjuanHolding[];
}
// ---------------------------------------------------------------------------
// Single-evaluate fetcher
// ---------------------------------------------------------------------------
/**
* Fetch the complete Danjuan fund picture in ONE browser round-trip.
*
* Inside the browser context we:
* 1. Fetch the gain/assets overview (contains account list)
* 2. Promise.all → fetch every account's holdings in parallel
* 3. Return the combined result to Node
*/
export async function fetchDanjuanAll(page: IPage): Promise<DanjuanSnapshot> {
const raw: any = await page.evaluate(`
(async () => {
const f = async (u) => {
const r = await fetch(u, { credentials: 'include' });
if (!r.ok) return { _err: r.status };
try { return await r.json(); } catch { return { _err: 'parse' }; }
};
const n = (v) => { const x = Number(v); return Number.isFinite(x) ? x : null; };
const gain = await f(${JSON.stringify(GAIN_URL)});
if (gain._err) return { _httpError: gain._err };
const root = gain.data || {};
const fundSec = (root.items || []).find(i => i && i.summary_type === 'FUND');
const rawAccs = fundSec && Array.isArray(fundSec.invest_account_list)
? fundSec.invest_account_list : [];
const accounts = rawAccs.map(a => ({
accountId: String(a.invest_account_id || ''),
accountName: a.invest_account_name || '',
accountType: a.invest_account_type || '',
accountCode: a.invest_account_code || '',
marketValue: n(a.market_value),
dailyGain: n(a.daily_gain),
mainFlag: !!a.main_flag,
}));
if (!accounts.length) {
return { _emptyAccounts: true };
}
const details = await Promise.all(
accounts.map(a => f(${JSON.stringify(SUMMARY_URL)} + encodeURIComponent(a.accountId)))
);
const holdings = [];
const detailErrors = [];
for (let i = 0; i < accounts.length; i++) {
const d = details[i];
if (d._err) {
detailErrors.push({
accountId: accounts[i].accountId,
accountName: accounts[i].accountName,
error: d._err,
});
continue;
}
const data = d.data || {};
const funds = Array.isArray(data.items) ? data.items : [];
const acc = accounts[i];
for (const fd of funds) {
holdings.push({
accountId: acc.accountId,
accountName: data.invest_account_name || acc.accountName,
accountType: data.invest_account_type || acc.accountType,
fdCode: fd.fd_code || '',
fdName: fd.fd_name || '',
category: fd.category_text || fd.category || '',
marketValue: n(fd.market_value),
volume: n(fd.volume),
usableRemainShare:n(fd.usable_remain_share),
dailyGain: n(fd.daily_gain),
holdGain: n(fd.hold_gain),
holdGainRate: n(fd.hold_gain_rate),
totalGain: n(fd.total_gain),
nav: n(fd.nav),
marketPercent: n(fd.market_percent),
});
}
}
return {
asOf: root.daily_gain_date || null,
totalAssetAmount: n(root.amount),
totalAssetDailyGain: n(root.daily_gain),
totalAssetHoldGain: n(root.hold_gain),
totalAssetTotalGain: n(root.total_gain),
totalFundMarketValue:n(fundSec && fundSec.amount),
accounts,
holdings,
detailErrors,
};
})()
`);
if (raw?._httpError) {
throw new Error(`HTTP ${raw._httpError} — Hint: not logged in to ${DANJUAN_DOMAIN}?`);
}
if (raw?._emptyAccounts) {
throw new Error(`No fund accounts found — Hint: not logged in to ${DANJUAN_DOMAIN}?`);
}
if (Array.isArray(raw?.detailErrors) && raw.detailErrors.length > 0) {
const failedAccounts = raw.detailErrors
.map((item: { accountName?: string; accountId?: string; error?: string | number }) => {
const label = item.accountName && item.accountId
? `${item.accountName} (${item.accountId})`
: item.accountName || item.accountId || 'unknown account';
return `${label}: ${item.error}`;
})
.join(', ');
throw new Error(`Failed to fetch Danjuan account details: ${failedAccounts}`);
}
return raw as DanjuanSnapshot;
}
+32
View File
@@ -0,0 +1,32 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { fetchDanjuanAll } from './danjuan-utils.js';
cli({
site: 'xueqiu',
name: 'fund-holdings',
description: '获取蛋卷基金持仓明细(可用 --account 按子账户过滤)',
domain: 'danjuanfunds.com',
strategy: Strategy.COOKIE,
navigateBefore: 'https://danjuanfunds.com/my-money',
args: [
{ name: 'account', type: 'str', default: '', help: '按子账户名称或 ID 过滤' },
],
columns: ['accountName', 'fdCode', 'fdName', 'marketValue', 'volume', 'dailyGain', 'holdGain', 'holdGainRate', 'marketPercent'],
func: async (page: IPage, args) => {
const snapshot = await fetchDanjuanAll(page);
if (!snapshot.accounts.length) {
throw new Error('No fund accounts found — Hint: not logged in to danjuanfunds.com?');
}
const filter = String(args.account ?? '').trim();
const rows = filter
? snapshot.holdings.filter(h => h.accountId === filter || h.accountName.includes(filter))
: snapshot.holdings;
if (!rows.length) {
throw new Error(filter ? `No holdings matched account filter: ${filter}` : 'No holdings found.');
}
return rows;
},
});
+27
View File
@@ -0,0 +1,27 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { fetchDanjuanAll } from './danjuan-utils.js';
cli({
site: 'xueqiu',
name: 'fund-snapshot',
description: '获取蛋卷基金快照(总资产、子账户、持仓,推荐 -f json 输出)',
domain: 'danjuanfunds.com',
strategy: Strategy.COOKIE,
navigateBefore: 'https://danjuanfunds.com/my-money',
args: [],
columns: ['asOf', 'totalAssetAmount', 'totalFundMarketValue', 'accountCount', 'holdingCount'],
func: async (page: IPage) => {
const s = await fetchDanjuanAll(page);
return [{
asOf: s.asOf,
totalAssetAmount: s.totalAssetAmount,
totalAssetDailyGain: s.totalAssetDailyGain,
totalFundMarketValue: s.totalFundMarketValue,
accountCount: s.accounts.length,
holdingCount: s.holdings.length,
accounts: s.accounts,
holdings: s.holdings,
}];
},
});