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:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: '示例基金' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
}];
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user