Files
real-time-fund/app/hooks/useFundFuzzyMatcher.js

205 lines
6.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useRef } from 'react';
import { cachedRequest, clearCachedRequest } from '../lib/cacheRequest';
const FUND_CODE_SEARCH_URL = 'https://fund.eastmoney.com/js/fundcode_search.js';
const FUND_LIST_CACHE_KEY = 'eastmoney_fundcode_search_list';
const FUND_LIST_CACHE_TIME = 24 * 60 * 60 * 1000;
const formatEastMoneyFundList = (rawList) => {
if (!Array.isArray(rawList)) return [];
return rawList
.map((item) => {
if (!Array.isArray(item)) return null;
const code = String(item[0] ?? '').trim();
const name = String(item[2] ?? '').trim();
if (!code || !name) return null;
return { code, name };
})
.filter(Boolean);
};
export const useFundFuzzyMatcher = () => {
const allFundFuseRef = useRef(null);
const allFundLoadPromiseRef = useRef(null);
const getAllFundFuse = useCallback(async () => {
if (allFundFuseRef.current) return allFundFuseRef.current;
if (allFundLoadPromiseRef.current) return allFundLoadPromiseRef.current;
allFundLoadPromiseRef.current = (async () => {
const [fuseModule, allFundList] = await Promise.all([
import('fuse.js'),
cachedRequest(
() =>
new Promise((resolve, reject) => {
if (typeof window === 'undefined' || typeof document === 'undefined' || !document.body) {
reject(new Error('NO_BROWSER_ENV'));
return;
}
const prevR = window.r;
const script = document.createElement('script');
script.src = `${FUND_CODE_SEARCH_URL}?_=${Date.now()}`;
script.async = true;
const cleanup = () => {
if (document.body.contains(script)) {
document.body.removeChild(script);
}
if (prevR === undefined) {
try {
delete window.r;
} catch (e) {
window.r = undefined;
}
} else {
window.r = prevR;
}
};
script.onload = () => {
const snapshot = Array.isArray(window.r) ? JSON.parse(JSON.stringify(window.r)) : [];
cleanup();
const parsed = formatEastMoneyFundList(snapshot);
if (!parsed.length) {
reject(new Error('PARSE_ALL_FUND_FAILED'));
return;
}
resolve(parsed);
};
script.onerror = () => {
cleanup();
reject(new Error('LOAD_ALL_FUND_FAILED'));
};
document.body.appendChild(script);
}),
FUND_LIST_CACHE_KEY,
{ cacheTime: FUND_LIST_CACHE_TIME }
),
]);
const Fuse = fuseModule.default;
const fuse = new Fuse(Array.isArray(allFundList) ? allFundList : [], {
keys: ['name', 'code'],
includeScore: true,
threshold: 0.5,
ignoreLocation: true,
minMatchCharLength: 2,
});
allFundFuseRef.current = fuse;
return fuse;
})();
try {
return await allFundLoadPromiseRef.current;
} catch (e) {
allFundLoadPromiseRef.current = null;
clearCachedRequest(FUND_LIST_CACHE_KEY);
throw e;
}
}, []);
const normalizeFundText = useCallback((value) => {
if (typeof value !== 'string') return '';
return value
.toUpperCase()
.replace(/[(]/g, '(')
.replace(/[)]/g, ')')
.replace(/[·•]/g, '')
.replace(/\s+/g, '')
.replace(/[^\u4e00-\u9fa5A-Z0-9()]/g, '');
}, []);
const parseFundQuerySignals = useCallback((rawName) => {
const normalized = normalizeFundText(rawName);
const hasETF = normalized.includes('ETF');
const hasLOF = normalized.includes('LOF');
const hasLink = normalized.includes('联接');
const shareMatch = normalized.match(/([A-Z])(?:类)?$/i);
const shareClass = shareMatch ? shareMatch[1].toUpperCase() : null;
const core = normalized
.replace(/基金/g, '')
.replace(/ETF联接/g, '')
.replace(/联接[A-Z]?/g, '')
.replace(/ETF/g, '')
.replace(/LOF/g, '')
.replace(/[A-Z](?:类)?$/g, '');
return {
normalized,
core,
hasETF,
hasLOF,
hasLink,
shareClass,
};
}, [normalizeFundText]);
const resolveFundCodeByFuzzy = useCallback(async (name) => {
const querySignals = parseFundQuerySignals(name);
if (!querySignals.normalized) return null;
const len = querySignals.normalized.length;
const strictThreshold = len <= 4 ? 0.16 : len <= 8 ? 0.22 : 0.28;
const relaxedThreshold = Math.min(0.45, strictThreshold + 0.16);
const scoreGapThreshold = len <= 5 ? 0.08 : 0.06;
const fuse = await getAllFundFuse();
const recalled = fuse.search(name, { limit: 50 });
if (!recalled.length) return null;
const stage1 = recalled.filter((item) => (item.score ?? 1) <= relaxedThreshold);
if (!stage1.length) return null;
const ranked = stage1
.map((item) => {
const candidateSignals = parseFundQuerySignals(item?.item?.name || '');
let finalScore = item.score ?? 1;
if (querySignals.hasETF) {
finalScore += candidateSignals.hasETF ? -0.04 : 0.2;
}
if (querySignals.hasLOF) {
finalScore += candidateSignals.hasLOF ? -0.04 : 0.2;
}
if (querySignals.hasLink) {
finalScore += candidateSignals.hasLink ? -0.03 : 0.18;
}
if (querySignals.shareClass) {
finalScore += candidateSignals.shareClass === querySignals.shareClass ? -0.03 : 0.18;
}
if (querySignals.core && candidateSignals.core) {
if (candidateSignals.core.includes(querySignals.core)) {
finalScore -= 0.06;
} else if (!querySignals.core.includes(candidateSignals.core)) {
finalScore += 0.06;
}
}
return { ...item, finalScore };
})
.sort((a, b) => a.finalScore - b.finalScore);
const top1 = ranked[0];
if (!top1 || top1.finalScore > strictThreshold) return null;
const top2 = ranked[1];
if (top2 && (top2.finalScore - top1.finalScore) < scoreGapThreshold) {
return null;
}
return top1?.item?.code || null;
}, [getAllFundFuse, parseFundQuerySignals]);
return {
resolveFundCodeByFuzzy,
};
};
export default useFundFuzzyMatcher;