feat: 调整前10重仓股票和业绩走势数据获取方式
This commit is contained in:
418
app/api/fund.js
418
app/api/fund.js
@@ -305,117 +305,97 @@ export const fetchFundData = async (c) => {
|
|||||||
.catch(() => resolveT(null));
|
.catch(() => resolveT(null));
|
||||||
});
|
});
|
||||||
const holdingsPromise = new Promise((resolveH) => {
|
const holdingsPromise = new Promise((resolveH) => {
|
||||||
const holdingsUrl = `https://fundf10.eastmoney.com/FundArchivesDatas.aspx?type=jjcc&code=${c}&topline=10&year=&month=&_=${Date.now()}`;
|
(async () => {
|
||||||
loadScript(holdingsUrl).then(async (apidata) => {
|
try {
|
||||||
let holdings = [];
|
const pz = await fetchFundPingzhongdata(c, { cacheTime: 10 * 60 * 1000 });
|
||||||
const html = apidata?.content || '';
|
const rawCodes = Array.isArray(pz?.stockCodes) ? pz.stockCodes : [];
|
||||||
const holdingsReportDate = extractHoldingsReportDate(html);
|
const codes = rawCodes
|
||||||
const holdingsIsLastQuarter = isLastQuarterReport(holdingsReportDate);
|
.map((code) => String(code).slice(0, 6))
|
||||||
|
.filter((code) => /^\d{6}$/.test(code))
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
// 如果不是上一季度末的披露数据,则不展示重仓(并避免继续解析/请求行情)
|
if (!codes.length) {
|
||||||
if (!holdingsIsLastQuarter) {
|
resolveH({ holdings: [], holdingsReportDate: null, holdingsIsLastQuarter: false });
|
||||||
resolveH({ holdings: [], holdingsReportDate, holdingsIsLastQuarter: false });
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const headerRow = (html.match(/<thead[\s\S]*?<tr[\s\S]*?<\/tr>[\s\S]*?<\/thead>/i) || [])[0] || '';
|
let holdings = codes.map((code) => ({
|
||||||
const headerCells = (headerRow.match(/<th[\s\S]*?>([\s\S]*?)<\/th>/gi) || []).map(th => th.replace(/<[^>]*>/g, '').trim());
|
code,
|
||||||
let idxCode = -1, idxName = -1, idxWeight = -1;
|
name: '',
|
||||||
headerCells.forEach((h, i) => {
|
weight: '',
|
||||||
const t = h.replace(/\s+/g, '');
|
change: null
|
||||||
if (idxCode < 0 && (t.includes('股票代码') || t.includes('证券代码'))) idxCode = i;
|
}));
|
||||||
if (idxName < 0 && (t.includes('股票名称') || t.includes('证券名称'))) idxName = i;
|
|
||||||
if (idxWeight < 0 && (t.includes('占净值比例') || t.includes('占比'))) idxWeight = i;
|
const needQuotes = holdings.filter(h => /^\d{6}$/.test(h.code) || /^\d{5}$/.test(h.code));
|
||||||
});
|
if (needQuotes.length) {
|
||||||
const rows = html.match(/<tbody[\s\S]*?<\/tbody>/i) || [];
|
try {
|
||||||
const dataRows = rows.length ? rows[0].match(/<tr[\s\S]*?<\/tr>/gi) || [] : html.match(/<tr[\s\S]*?<\/tr>/gi) || [];
|
const tencentCodes = needQuotes.map(h => {
|
||||||
for (const r of dataRows) {
|
const cd = String(h.code || '');
|
||||||
const tds = (r.match(/<td[\s\S]*?>([\s\S]*?)<\/td>/gi) || []).map(td => td.replace(/<[^>]*>/g, '').trim());
|
if (/^\d{6}$/.test(cd)) {
|
||||||
if (!tds.length) continue;
|
const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
|
||||||
let code = '';
|
return `s_${pfx}${cd}`;
|
||||||
let name = '';
|
}
|
||||||
let weight = '';
|
if (/^\d{5}$/.test(cd)) {
|
||||||
if (idxCode >= 0 && tds[idxCode]) {
|
return `s_hk${cd}`;
|
||||||
const m = tds[idxCode].match(/(\d{6})/);
|
}
|
||||||
code = m ? m[1] : tds[idxCode];
|
return null;
|
||||||
} else {
|
}).filter(Boolean).join(',');
|
||||||
const codeIdx = tds.findIndex(txt => /^\d{6}$/.test(txt));
|
if (tencentCodes) {
|
||||||
if (codeIdx >= 0) code = tds[codeIdx];
|
const quoteUrl = `https://qt.gtimg.cn/q=${tencentCodes}`;
|
||||||
}
|
await new Promise((resQuote) => {
|
||||||
if (idxName >= 0 && tds[idxName]) {
|
const scriptQuote = document.createElement('script');
|
||||||
name = tds[idxName];
|
scriptQuote.src = quoteUrl;
|
||||||
} else if (code) {
|
scriptQuote.onload = () => {
|
||||||
const i = tds.findIndex(txt => txt && txt !== code && !/%$/.test(txt));
|
needQuotes.forEach(h => {
|
||||||
name = i >= 0 ? tds[i] : '';
|
const cd = String(h.code || '');
|
||||||
}
|
let varName = '';
|
||||||
if (idxWeight >= 0 && tds[idxWeight]) {
|
if (/^\d{6}$/.test(cd)) {
|
||||||
const wm = tds[idxWeight].match(/([\d.]+)\s*%/);
|
const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
|
||||||
weight = wm ? `${wm[1]}%` : tds[idxWeight];
|
varName = `v_s_${pfx}${cd}`;
|
||||||
} else {
|
} else if (/^\d{5}$/.test(cd)) {
|
||||||
const wIdx = tds.findIndex(txt => /\d+(?:\.\d+)?\s*%/.test(txt));
|
varName = `v_s_hk${cd}`;
|
||||||
weight = wIdx >= 0 ? tds[wIdx].match(/([\d.]+)\s*%/)?.[1] + '%' : '';
|
} else {
|
||||||
}
|
return;
|
||||||
if (code || name || weight) {
|
}
|
||||||
holdings.push({ code, name, weight, change: null });
|
const dataStr = window[varName];
|
||||||
}
|
if (dataStr) {
|
||||||
}
|
const parts = dataStr.split('~');
|
||||||
holdings = holdings.slice(0, 10);
|
if (parts.length > 5) {
|
||||||
const needQuotes = holdings.filter(h => /^\d{6}$/.test(h.code) || /^\d{5}$/.test(h.code));
|
// parts[1] 是名称,parts[5] 是涨跌幅
|
||||||
if (needQuotes.length) {
|
if (!h.name && parts[1]) {
|
||||||
try {
|
h.name = parts[1];
|
||||||
const tencentCodes = needQuotes.map(h => {
|
}
|
||||||
const cd = String(h.code || '');
|
const chg = parseFloat(parts[5]);
|
||||||
if (/^\d{6}$/.test(cd)) {
|
if (!Number.isNaN(chg)) {
|
||||||
const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
|
h.change = chg;
|
||||||
return `s_${pfx}${cd}`;
|
}
|
||||||
}
|
}
|
||||||
if (/^\d{5}$/.test(cd)) {
|
}
|
||||||
return `s_hk${cd}`;
|
});
|
||||||
}
|
if (document.body.contains(scriptQuote)) document.body.removeChild(scriptQuote);
|
||||||
return null;
|
resQuote();
|
||||||
}).filter(Boolean).join(',');
|
};
|
||||||
if (!tencentCodes) {
|
scriptQuote.onerror = () => {
|
||||||
resolveH(holdings);
|
if (document.body.contains(scriptQuote)) document.body.removeChild(scriptQuote);
|
||||||
return;
|
resQuote();
|
||||||
}
|
};
|
||||||
const quoteUrl = `https://qt.gtimg.cn/q=${tencentCodes}`;
|
document.body.appendChild(scriptQuote);
|
||||||
await new Promise((resQuote) => {
|
|
||||||
const scriptQuote = document.createElement('script');
|
|
||||||
scriptQuote.src = quoteUrl;
|
|
||||||
scriptQuote.onload = () => {
|
|
||||||
needQuotes.forEach(h => {
|
|
||||||
const cd = String(h.code || '');
|
|
||||||
let varName = '';
|
|
||||||
if (/^\d{6}$/.test(cd)) {
|
|
||||||
const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
|
|
||||||
varName = `v_s_${pfx}${cd}`;
|
|
||||||
} else if (/^\d{5}$/.test(cd)) {
|
|
||||||
varName = `v_s_hk${cd}`;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dataStr = window[varName];
|
|
||||||
if (dataStr) {
|
|
||||||
const parts = dataStr.split('~');
|
|
||||||
if (parts.length > 5) {
|
|
||||||
h.change = parseFloat(parts[5]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (document.body.contains(scriptQuote)) document.body.removeChild(scriptQuote);
|
}
|
||||||
resQuote();
|
} catch (e) {
|
||||||
};
|
}
|
||||||
scriptQuote.onerror = () => {
|
|
||||||
if (document.body.contains(scriptQuote)) document.body.removeChild(scriptQuote);
|
|
||||||
resQuote();
|
|
||||||
};
|
|
||||||
document.body.appendChild(scriptQuote);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用 pingzhongdata 的结果作为展现依据:有前 10 代码即视为可展示
|
||||||
|
resolveH({
|
||||||
|
holdings,
|
||||||
|
holdingsReportDate: null,
|
||||||
|
holdingsIsLastQuarter: holdings.length > 0
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
resolveH({ holdings: [], holdingsReportDate: null, holdingsIsLastQuarter: false });
|
||||||
}
|
}
|
||||||
resolveH({ holdings, holdingsReportDate, holdingsIsLastQuarter });
|
})();
|
||||||
}).catch(() => resolveH({ holdings: [], holdingsReportDate: null, holdingsIsLastQuarter: false }));
|
|
||||||
});
|
});
|
||||||
Promise.all([lsjzPromise, holdingsPromise]).then(([tData, holdingsResult]) => {
|
Promise.all([lsjzPromise, holdingsPromise]).then(([tData, holdingsResult]) => {
|
||||||
const {
|
const {
|
||||||
@@ -529,6 +509,141 @@ export const submitFeedback = async (formData) => {
|
|||||||
return response.json();
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PINGZHONGDATA_GLOBAL_KEYS = [
|
||||||
|
'ishb',
|
||||||
|
'fS_name',
|
||||||
|
'fS_code',
|
||||||
|
'fund_sourceRate',
|
||||||
|
'fund_Rate',
|
||||||
|
'fund_minsg',
|
||||||
|
'stockCodes',
|
||||||
|
'zqCodes',
|
||||||
|
'stockCodesNew',
|
||||||
|
'zqCodesNew',
|
||||||
|
'syl_1n',
|
||||||
|
'syl_6y',
|
||||||
|
'syl_3y',
|
||||||
|
'syl_1y',
|
||||||
|
'Data_fundSharesPositions',
|
||||||
|
'Data_netWorthTrend',
|
||||||
|
'Data_ACWorthTrend',
|
||||||
|
'Data_grandTotal',
|
||||||
|
'Data_rateInSimilarType',
|
||||||
|
'Data_rateInSimilarPersent',
|
||||||
|
'Data_fluctuationScale',
|
||||||
|
'Data_holderStructure',
|
||||||
|
'Data_assetAllocation',
|
||||||
|
'Data_performanceEvaluation',
|
||||||
|
'Data_currentFundManager',
|
||||||
|
'Data_buySedemption',
|
||||||
|
'swithSameType',
|
||||||
|
];
|
||||||
|
|
||||||
|
let pingzhongdataQueue = Promise.resolve();
|
||||||
|
|
||||||
|
const enqueuePingzhongdataLoad = (fn) => {
|
||||||
|
const p = pingzhongdataQueue.then(fn, fn);
|
||||||
|
// 避免队列被 reject 永久阻塞
|
||||||
|
pingzhongdataQueue = p.catch(() => undefined);
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshotPingzhongdataGlobals = (fundCode) => {
|
||||||
|
const out = {};
|
||||||
|
for (const k of PINGZHONGDATA_GLOBAL_KEYS) {
|
||||||
|
if (typeof window?.[k] === 'undefined') continue;
|
||||||
|
try {
|
||||||
|
out[k] = JSON.parse(JSON.stringify(window[k]));
|
||||||
|
} catch (e) {
|
||||||
|
out[k] = window[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fundCode: out.fS_code || fundCode,
|
||||||
|
fundName: out.fS_name || '',
|
||||||
|
...out,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 10000) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (typeof document === 'undefined' || !document.body) {
|
||||||
|
reject(new Error('无浏览器环境'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://fund.eastmoney.com/pingzhongdata/${fundCode}.js?v=${Date.now()}`;
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = url;
|
||||||
|
script.async = true;
|
||||||
|
|
||||||
|
let done = false;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
script.onload = null;
|
||||||
|
script.onerror = null;
|
||||||
|
if (document.body.contains(script)) document.body.removeChild(script);
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('pingzhongdata 请求超时'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
script.onload = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
const data = snapshotPingzhongdataGlobals(fundCode);
|
||||||
|
cleanup();
|
||||||
|
resolve(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
script.onerror = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('pingzhongdata 加载失败'));
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAndParsePingzhongdata = async (fundCode) => {
|
||||||
|
// 使用 JSONP(script 注入) 方式获取并解析 pingzhongdata
|
||||||
|
return enqueuePingzhongdataLoad(() => jsonpLoadPingzhongdata(fundCode));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取并解析「基金走势图/资产等」数据(pingzhongdata)
|
||||||
|
* 来源:https://fund.eastmoney.com/pingzhongdata/${fundCode}.js
|
||||||
|
*/
|
||||||
|
export const fetchFundPingzhongdata = async (fundCode, { cacheTime = 10 * 60 * 1000 } = {}) => {
|
||||||
|
if (!fundCode) throw new Error('fundCode 不能为空');
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
|
throw new Error('无浏览器环境');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `pingzhongdata_${fundCode}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await cachedRequest(
|
||||||
|
() => fetchAndParsePingzhongdata(fundCode),
|
||||||
|
cacheKey,
|
||||||
|
{ cacheTime }
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
clearCachedRequest(cacheKey);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 使用智谱 GLM 从 OCR 文本中抽取基金名称
|
// 使用智谱 GLM 从 OCR 文本中抽取基金名称
|
||||||
export const extractFundNamesWithLLM = async (ocrText) => {
|
export const extractFundNamesWithLLM = async (ocrText) => {
|
||||||
const apiKey = '8df8ccf74a174722847c83b7e222f2af.4A39rJvUeBVDmef1';
|
const apiKey = '8df8ccf74a174722847c83b7e222f2af.4A39rJvUeBVDmef1';
|
||||||
@@ -595,8 +710,6 @@ export const extractFundNamesWithLLM = async (ocrText) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let historyQueue = Promise.resolve();
|
|
||||||
|
|
||||||
export const fetchFundHistory = async (code, range = '1m') => {
|
export const fetchFundHistory = async (code, range = '1m') => {
|
||||||
if (typeof window === 'undefined') return [];
|
if (typeof window === 'undefined') return [];
|
||||||
|
|
||||||
@@ -609,73 +722,32 @@ export const fetchFundHistory = async (code, range = '1m') => {
|
|||||||
case '6m': start = start.subtract(6, 'month'); break;
|
case '6m': start = start.subtract(6, 'month'); break;
|
||||||
case '1y': start = start.subtract(1, 'year'); break;
|
case '1y': start = start.subtract(1, 'year'); break;
|
||||||
case '3y': start = start.subtract(3, 'year'); break;
|
case '3y': start = start.subtract(3, 'year'); break;
|
||||||
|
case 'all': start = dayjs(0).tz(TZ); break;
|
||||||
default: start = start.subtract(1, 'month');
|
default: start = start.subtract(1, 'month');
|
||||||
}
|
}
|
||||||
|
|
||||||
const sdate = start.format('YYYY-MM-DD');
|
// 业绩走势统一走 pingzhongdata.Data_netWorthTrend
|
||||||
const edate = end.format('YYYY-MM-DD');
|
try {
|
||||||
const per = 49;
|
const pz = await fetchFundPingzhongdata(code, { cacheTime: 10 * 60 * 1000 });
|
||||||
|
const trend = pz?.Data_netWorthTrend;
|
||||||
|
if (Array.isArray(trend) && trend.length) {
|
||||||
|
const startMs = start.startOf('day').valueOf();
|
||||||
|
// end 可能是当日任意时刻,这里用 end-of-day 包含最后一天
|
||||||
|
const endMs = end.endOf('day').valueOf();
|
||||||
|
const out = trend
|
||||||
|
.filter((d) => d && typeof d.x === 'number' && d.x >= startMs && d.x <= endMs)
|
||||||
|
.map((d) => {
|
||||||
|
const value = Number(d.y);
|
||||||
|
if (!Number.isFinite(value)) return null;
|
||||||
|
const date = dayjs(d.x).tz(TZ).format('YYYY-MM-DD');
|
||||||
|
return { date, value };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
if (out.length) return out;
|
||||||
historyQueue = historyQueue.then(async () => {
|
}
|
||||||
let allData = [];
|
} catch (e) {
|
||||||
let page = 1;
|
return [];
|
||||||
let totalPages = 1;
|
}
|
||||||
|
return [];
|
||||||
try {
|
|
||||||
const parseContent = (content) => {
|
|
||||||
if (!content) return [];
|
|
||||||
const rows = content.split('<tr>');
|
|
||||||
const data = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
const cells = row.match(/<td[^>]*>(.*?)<\/td>/g);
|
|
||||||
if (cells && cells.length >= 2) {
|
|
||||||
const dateStr = cells[0].replace(/<[^>]+>/g, '').trim();
|
|
||||||
const valStr = cells[1].replace(/<[^>]+>/g, '').trim();
|
|
||||||
const val = parseFloat(valStr);
|
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr) && !isNaN(val)) {
|
|
||||||
data.push({ date: dateStr, value: val });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch first page to get metadata
|
|
||||||
const firstUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`;
|
|
||||||
const firstApidata = await loadScript(firstUrl);
|
|
||||||
|
|
||||||
if (!firstApidata || !firstApidata.content || firstApidata.content.includes('暂无数据')) {
|
|
||||||
resolve([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse total pages
|
|
||||||
if (firstApidata.pages) {
|
|
||||||
totalPages = parseInt(firstApidata.pages, 10) || 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
allData = allData.concat(parseContent(firstApidata.content));
|
|
||||||
|
|
||||||
// Fetch remaining pages
|
|
||||||
for (page = 2; page <= totalPages; page++) {
|
|
||||||
const nextUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`;
|
|
||||||
const nextApidata = await loadScript(nextUrl);
|
|
||||||
if (nextApidata && nextApidata.content) {
|
|
||||||
allData = allData.concat(parseContent(nextApidata.content));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The data comes in reverse chronological order (newest first), so we need to reverse it for the chart (oldest first)
|
|
||||||
resolve(allData.reverse());
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Fetch history error:', e);
|
|
||||||
resolve([]);
|
|
||||||
}
|
|
||||||
}).catch((e) => {
|
|
||||||
console.error('Queue error:', e);
|
|
||||||
resolve([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
{ label: '近3月', value: '3m' },
|
{ label: '近3月', value: '3m' },
|
||||||
{ label: '近6月', value: '6m' },
|
{ label: '近6月', value: '6m' },
|
||||||
{ label: '近1年', value: '1y' },
|
{ label: '近1年', value: '1y' },
|
||||||
{ label: '近3年', value: '3y'}
|
{ label: '近3年', value: '3y' },
|
||||||
|
{ label: '成立来', value: 'all' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const change = useMemo(() => {
|
const change = useMemo(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user