import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import { isString } from 'lodash';
import { cachedRequest, clearCachedRequest } from '../lib/cacheRequest';
dayjs.extend(utc);
dayjs.extend(timezone);
const DEFAULT_TZ = 'Asia/Shanghai';
const getBrowserTimeZone = () => {
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz || DEFAULT_TZ;
}
return DEFAULT_TZ;
};
const TZ = getBrowserTimeZone();
dayjs.tz.setDefault(TZ);
const nowInTz = () => dayjs().tz(TZ);
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
export const loadScript = (url) => {
if (typeof document === 'undefined' || !document.body) return Promise.resolve(null);
let cacheKey = url;
try {
const parsed = new URL(url);
parsed.searchParams.delete('_');
parsed.searchParams.delete('_t');
cacheKey = parsed.toString();
} catch (e) {
}
const cacheTime = 10 * 60 * 1000;
return cachedRequest(
() =>
new Promise((resolve) => {
const script = document.createElement('script');
script.src = url;
script.async = true;
const cleanup = () => {
if (document.body.contains(script)) document.body.removeChild(script);
};
script.onload = () => {
cleanup();
let apidata;
try {
apidata = window?.apidata ? JSON.parse(JSON.stringify(window.apidata)) : undefined;
} catch (e) {
apidata = window?.apidata;
}
resolve({ ok: true, apidata });
};
script.onerror = () => {
cleanup();
resolve({ ok: false, error: '数据加载失败' });
};
document.body.appendChild(script);
}),
cacheKey,
{ cacheTime }
).then((result) => {
if (!result?.ok) {
clearCachedRequest(cacheKey);
throw new Error(result?.error || '数据加载失败');
}
return result.apidata;
});
};
export const fetchFundNetValue = async (code, date) => {
if (typeof window === 'undefined') return null;
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=1&per=1&sdate=${date}&edate=${date}`;
try {
const apidata = await loadScript(url);
if (apidata && apidata.content) {
const content = apidata.content;
if (content.includes('暂无数据')) return null;
const rows = content.split('
/gi) || [];
for (const row of rowMatches) {
const cells = row.match(/| ]*>(.*?)<\/td>/gi) || [];
if (!cells.length) continue;
const getText = (td) => td.replace(/<[^>]+>/g, '').trim();
const dateStr = getText(cells[0] || '');
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) continue;
const navStr = getText(cells[1] || '');
const nav = parseFloat(navStr);
if (!Number.isFinite(nav)) continue;
let growth = null;
for (const c of cells) {
const txt = getText(c);
const m = txt.match(/([-+]?\d+(?:\.\d+)?)\s*%/);
if (m) {
growth = parseFloat(m[1]);
break;
}
}
return { date: dateStr, nav, growth };
}
return null;
};
const extractHoldingsReportDate = (html) => {
if (!html) return null;
// 优先匹配带有“报告期 / 截止日期”等关键字附近的日期
const m1 = html.match(/(报告期|截止日期)[^0-9]{0,20}(\d{4}-\d{2}-\d{2})/);
if (m1) return m1[2];
// 兜底:取文中出现的第一个 yyyy-MM-dd 格式日期
const m2 = html.match(/(\d{4}-\d{2}-\d{2})/);
return m2 ? m2[1] : null;
};
const isLastQuarterReport = (reportDateStr) => {
if (!reportDateStr) return false;
const report = dayjs(reportDateStr, 'YYYY-MM-DD');
if (!report.isValid()) return false;
const now = nowInTz();
const m = now.month(); // 0-11
const q = Math.floor(m / 3); // 当前季度 0-3 => Q1-Q4
let lastQ;
let year;
if (q === 0) {
// 当前为 Q1,则上一季度是上一年的 Q4
lastQ = 3;
year = now.year() - 1;
} else {
lastQ = q - 1;
year = now.year();
}
const quarterEnds = [
{ month: 2, day: 31 }, // Q1 -> 03-31
{ month: 5, day: 30 }, // Q2 -> 06-30
{ month: 8, day: 30 }, // Q3 -> 09-30
{ month: 11, day: 31 } // Q4 -> 12-31
];
const { month: endMonth, day: endDay } = quarterEnds[lastQ];
const lastQuarterEnd = dayjs(
`${year}-${String(endMonth + 1).padStart(2, '0')}-${endDay}`,
'YYYY-MM-DD'
);
return report.isSame(lastQuarterEnd, 'day');
};
export const fetchSmartFundNetValue = async (code, startDate) => {
const today = nowInTz().startOf('day');
let current = toTz(startDate).startOf('day');
for (let i = 0; i < 30; i++) {
if (current.isAfter(today)) break;
const dateStr = current.format('YYYY-MM-DD');
const val = await fetchFundNetValue(code, dateStr);
if (val !== null) {
return { date: dateStr, value: val };
}
current = current.add(1, 'day');
}
return null;
};
export const fetchFundDataFallback = async (c) => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
throw new Error('无浏览器环境');
}
return new Promise(async (resolve, reject) => {
const searchCallbackName = `SuggestData_fallback_${Date.now()}`;
const searchUrl = `https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx?m=1&key=${encodeURIComponent(c)}&callback=${searchCallbackName}&_=${Date.now()}`;
let fundName = '';
try {
await new Promise((resSearch, rejSearch) => {
window[searchCallbackName] = (data) => {
if (data && data.Datas && data.Datas.length > 0) {
const found = data.Datas.find(d => d.CODE === c);
if (found) {
fundName = found.NAME || found.SHORTNAME || '';
}
}
delete window[searchCallbackName];
resSearch();
};
const script = document.createElement('script');
script.src = searchUrl;
script.async = true;
script.onload = () => {
if (document.body.contains(script)) document.body.removeChild(script);
};
script.onerror = () => {
if (document.body.contains(script)) document.body.removeChild(script);
delete window[searchCallbackName];
rejSearch(new Error('搜索接口失败'));
};
document.body.appendChild(script);
setTimeout(() => {
if (window[searchCallbackName]) {
delete window[searchCallbackName];
resSearch();
}
}, 3000);
});
} catch (e) {
}
try {
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${c}&page=1&per=1&sdate=&edate=`;
const apidata = await loadScript(url);
const content = apidata?.content || '';
const latest = parseLatestNetValueFromLsjzContent(content);
if (latest && latest.nav) {
const name = fundName || `未知基金(${c})`;
resolve({
code: c,
name,
dwjz: String(latest.nav),
gsz: null,
gztime: null,
jzrq: latest.date,
gszzl: null,
zzl: Number.isFinite(latest.growth) ? latest.growth : null,
noValuation: true,
holdings: [],
holdingsReportDate: null,
holdingsIsLastQuarter: false
});
} else {
reject(new Error('未能获取到基金数据'));
}
} catch (e) {
reject(new Error('基金数据加载失败'));
}
});
};
export const fetchFundData = async (c) => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
throw new Error('无浏览器环境');
}
return new Promise(async (resolve, reject) => {
const gzUrl = `https://fundgz.1234567.com.cn/js/${c}.js?rt=${Date.now()}`;
const scriptGz = document.createElement('script');
scriptGz.src = gzUrl;
const originalJsonpgz = window.jsonpgz;
window.jsonpgz = (json) => {
window.jsonpgz = originalJsonpgz;
if (!json || typeof json !== 'object') {
fetchFundDataFallback(c).then(resolve).catch(reject);
return;
}
const gszzlNum = Number(json.gszzl);
const gzData = {
code: json.fundcode,
name: json.name,
dwjz: json.dwjz,
gsz: json.gsz,
gztime: json.gztime,
jzrq: json.jzrq,
gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl
};
const lsjzPromise = new Promise((resolveT) => {
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${c}&page=1&per=1&sdate=&edate=`;
loadScript(url)
.then((apidata) => {
const content = apidata?.content || '';
const latest = parseLatestNetValueFromLsjzContent(content);
if (latest && latest.nav) {
resolveT({
dwjz: String(latest.nav),
zzl: Number.isFinite(latest.growth) ? latest.growth : null,
jzrq: latest.date
});
} else {
resolveT(null);
}
})
.catch(() => resolveT(null));
});
const holdingsPromise = new Promise((resolveH) => {
(async () => {
try {
const pz = await fetchFundPingzhongdata(c);
const rawCodes = Array.isArray(pz?.stockCodes) ? pz.stockCodes : [];
const codes = rawCodes
.map((code) => String(code).slice(0, 6))
.filter((code) => /^\d{6}$/.test(code))
.slice(0, 10);
if (!codes.length) {
resolveH({ holdings: [], holdingsReportDate: null, holdingsIsLastQuarter: false });
return;
}
let holdings = codes.map((code) => ({
code,
name: '',
weight: '',
change: null
}));
const needQuotes = holdings.filter(h => /^\d{6}$/.test(h.code) || /^\d{5}$/.test(h.code));
if (needQuotes.length) {
try {
const tencentCodes = needQuotes.map(h => {
const cd = String(h.code || '');
if (/^\d{6}$/.test(cd)) {
const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
return `s_${pfx}${cd}`;
}
if (/^\d{5}$/.test(cd)) {
return `s_hk${cd}`;
}
return null;
}).filter(Boolean).join(',');
if (tencentCodes) {
const quoteUrl = `https://qt.gtimg.cn/q=${tencentCodes}`;
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) {
// parts[1] 是名称,parts[5] 是涨跌幅
if (!h.name && parts[1]) {
h.name = parts[1];
}
const chg = parseFloat(parts[5]);
if (!Number.isNaN(chg)) {
h.change = chg;
}
}
}
});
if (document.body.contains(scriptQuote)) document.body.removeChild(scriptQuote);
resQuote();
};
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 });
}
})();
});
Promise.all([lsjzPromise, holdingsPromise]).then(([tData, holdingsResult]) => {
const {
holdings,
holdingsReportDate,
holdingsIsLastQuarter
} = holdingsResult || {};
if (tData) {
if (tData.jzrq && (!gzData.jzrq || tData.jzrq >= gzData.jzrq)) {
gzData.dwjz = tData.dwjz;
gzData.jzrq = tData.jzrq;
gzData.zzl = tData.zzl;
}
}
resolve({
...gzData,
holdings,
holdingsReportDate,
holdingsIsLastQuarter
});
});
};
scriptGz.onerror = () => {
window.jsonpgz = originalJsonpgz;
if (document.body.contains(scriptGz)) document.body.removeChild(scriptGz);
reject(new Error('基金数据加载失败'));
};
document.body.appendChild(scriptGz);
setTimeout(() => {
if (document.body.contains(scriptGz)) document.body.removeChild(scriptGz);
}, 5000);
});
};
export const searchFunds = async (val) => {
if (!val.trim()) return [];
if (typeof window === 'undefined' || typeof document === 'undefined') return [];
const callbackName = `SuggestData_${Date.now()}`;
const url = `https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx?m=1&key=${encodeURIComponent(val)}&callback=${callbackName}&_=${Date.now()}`;
return new Promise((resolve, reject) => {
window[callbackName] = (data) => {
let results = [];
if (data && data.Datas) {
results = data.Datas.filter(d =>
d.CATEGORY === 700 ||
d.CATEGORY === '700' ||
d.CATEGORYDESC === '基金'
);
}
delete window[callbackName];
resolve(results);
};
const script = document.createElement('script');
script.src = url;
script.async = true;
script.onload = () => {
if (document.body.contains(script)) document.body.removeChild(script);
};
script.onerror = () => {
if (document.body.contains(script)) document.body.removeChild(script);
delete window[callbackName];
reject(new Error('搜索请求失败'));
};
document.body.appendChild(script);
});
};
export const fetchShanghaiIndexDate = async () => {
if (typeof window === 'undefined' || typeof document === 'undefined') return null;
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `https://qt.gtimg.cn/q=sh000001&_t=${Date.now()}`;
script.onload = () => {
const data = window.v_sh000001;
let dateStr = null;
if (data) {
const parts = data.split('~');
if (parts.length > 30) {
dateStr = parts[30].slice(0, 8);
}
}
if (document.body.contains(script)) document.body.removeChild(script);
resolve(dateStr);
};
script.onerror = () => {
if (document.body.contains(script)) document.body.removeChild(script);
reject(new Error('指数数据加载失败'));
};
document.body.appendChild(script);
});
};
export const fetchLatestRelease = async () => {
const url = process.env.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL;
if (!url) return null;
const res = await fetch(url);
if (!res.ok) return null;
const data = await res.json();
return {
tagName: data.tag_name,
body: data.body || ''
};
};
export const submitFeedback = async (formData) => {
const response = await fetch('https://api.web3forms.com/submit', {
method: 'POST',
body: formData
});
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 = 60 * 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 文本中抽取基金名称
export const extractFundNamesWithLLM = async (ocrText) => {
const apiKey = '8df8ccf74a174722847c83b7e222f2af.4A39rJvUeBVDmef1';
if (!apiKey || !ocrText) return [];
try {
const models = ['glm-4.5-flash', 'glm-4.7-flash'];
const model = models[Math.floor(Math.random() * models.length)];
const resp = await fetch('https://open.bigmodel.cn/api/paas/v4/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: [
{
role: 'user',
content:
'你是一个基金 OCR 文本解析助手。' +
'从下面的 OCR 文本中抽取其中出现的「基金名称列表」。' +
'要求:1)基金名称一般为中文,中间不能有空字符串,可包含部分英文或括号' +
'2)名称后面通常会跟着金额或持有金额(数字,可能带千分位逗号和小数);' +
'3)忽略无关信息,只返回你判断为基金名称的字符串;' +
'4)去重后输出。输出格式:严格返回 JSON,如 {"fund_names": ["基金名称1","基金名称2"]},不要输出任何多余说明',
},
{
role: 'user',
content: String(ocrText),
},
],
temperature: 0.2,
max_tokens: 1024,
thinking: {
type: 'disabled',
},
}),
});
if (!resp.ok) {
return [];
}
const data = await resp.json();
let content = data?.choices?.[0]?.message?.content?.match(/\{[\s\S]*?\}/)?.[0];
if (!isString(content)) return [];
let parsed;
try {
parsed = JSON.parse(content);
} catch {
return [];
}
const names = parsed?.fund_names;
if (!Array.isArray(names)) return [];
return names
.map((n) => (isString(n) ? n.trim().replaceAll(' ','') : ''))
.filter(Boolean);
} catch (e) {
return [];
}
};
export const fetchFundHistory = async (code, range = '1m') => {
if (typeof window === 'undefined') return [];
const end = nowInTz();
let start = end.clone();
switch (range) {
case '1m': start = start.subtract(1, 'month'); break;
case '3m': start = start.subtract(3, 'month'); break;
case '6m': start = start.subtract(6, 'month'); break;
case '1y': start = start.subtract(1, 'year'); break;
case '3y': start = start.subtract(3, 'year'); break;
case 'all': start = dayjs(0).tz(TZ); break;
default: start = start.subtract(1, 'month');
}
// 业绩走势统一走 pingzhongdata.Data_netWorthTrend
try {
const pz = await fetchFundPingzhongdata(code);
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);
if (out.length) return out;
}
} catch (e) {
return [];
}
return [];
};
|