218 lines
7.3 KiB
TypeScript
Executable File
218 lines
7.3 KiB
TypeScript
Executable File
// 股票搜索 Edge Function
|
||
// 通过腾讯智能搜索API(smartbox.gtimg.cn)支持股票名称/代码/拼音模糊搜索
|
||
// 返回格式:v_hint="市场~代码~名称~拼音~类型^市场~代码~名称~拼音~类型^..."
|
||
// 名称字段为 \u 转义的 unicode
|
||
// 降级:若搜索接口无结果但keyword是6位数字代码,直接调用行情接口验证(支持北交所920开头)
|
||
|
||
function corsHeaders() {
|
||
return {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||
'Access-Control-Allow-Headers': 'content-type',
|
||
};
|
||
}
|
||
|
||
interface SearchResult {
|
||
code: string;
|
||
name: string;
|
||
market: string;
|
||
type: string;
|
||
}
|
||
|
||
// 解析 \u 转义的 unicode 字符串
|
||
function decodeUnicode(str: string): string {
|
||
try {
|
||
return JSON.parse(`"${str}"`);
|
||
} catch {
|
||
return str;
|
||
}
|
||
}
|
||
|
||
// 根据代码推断市场前缀(用于行情接口)
|
||
function getMarketPrefix(code: string): string {
|
||
if (code.startsWith('688') || code.startsWith('60')) return 'sh';
|
||
if (code.startsWith('8') || code.startsWith('4') || code.startsWith('920')) return 'bj';
|
||
return 'sz';
|
||
}
|
||
|
||
// 根据代码推断板块类型
|
||
function getBoardType(code: string): string {
|
||
if (code.startsWith('688')) return 'GP-A-KCB';
|
||
if (code.startsWith('300') || code.startsWith('301')) return 'GP-A-CYB';
|
||
if (code.startsWith('8') || code.startsWith('4') || code.startsWith('920')) return 'GP-A-BJB';
|
||
return 'GP-A';
|
||
}
|
||
|
||
// 通过行情接口直接验证股票是否存在(用于搜索接口不支持的股票,如北交所920开头)
|
||
async function lookupByQuoteApi(code: string): Promise<SearchResult | null> {
|
||
const market = getMarketPrefix(code);
|
||
const stockCode = `${market}${code}`;
|
||
try {
|
||
const resp = await fetch(`https://qt.gtimg.cn/q=${stockCode}`, {
|
||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||
});
|
||
if (!resp.ok) return null;
|
||
|
||
const buffer = await resp.arrayBuffer();
|
||
let text: string;
|
||
try {
|
||
text = new TextDecoder('gbk').decode(buffer);
|
||
} catch {
|
||
text = new TextDecoder().decode(buffer);
|
||
}
|
||
|
||
// 格式:v_bj920510="62~丰光精密~920510~..."
|
||
const eqIdx = text.indexOf('="');
|
||
if (eqIdx === -1) return null;
|
||
const startIdx = eqIdx + 2;
|
||
const endIdx = text.lastIndexOf('"');
|
||
if (endIdx <= startIdx) return null;
|
||
const content = text.substring(startIdx, endIdx);
|
||
const parts = content.split('~');
|
||
if (parts.length < 3) return null;
|
||
|
||
const name = parts[1];
|
||
const currentPrice = parseFloat(parts[3]) || 0;
|
||
// 名称非空且价格大于0才算有效股票
|
||
if (!name || currentPrice === 0) return null;
|
||
|
||
return {
|
||
code,
|
||
name,
|
||
market: market.toUpperCase(),
|
||
type: getBoardType(code),
|
||
};
|
||
} catch (err) {
|
||
console.error(`[stock-search] 行情接口查询失败 ${stockCode}:`, err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Deno.serve(async (req) => {
|
||
if (req.method === 'OPTIONS') {
|
||
return new Response('ok', { headers: corsHeaders() });
|
||
}
|
||
|
||
const url = new URL(req.url);
|
||
const keyword = (url.searchParams.get('keyword') || '').trim();
|
||
|
||
if (!keyword) {
|
||
return new Response(JSON.stringify({ data: [], count: 0 }), {
|
||
headers: corsHeaders(),
|
||
});
|
||
}
|
||
|
||
try {
|
||
// 腾讯智能搜索接口:支持名称、代码、拼音首字母模糊匹配
|
||
const searchUrl = `https://smartbox.gtimg.cn/s3/?t=all&q=${encodeURIComponent(keyword)}&v=2`;
|
||
console.log(`[stock-search] 请求: ${searchUrl}`);
|
||
|
||
const resp = await fetch(searchUrl, {
|
||
headers: {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||
'Referer': 'https://stockapp.finance.qq.com/',
|
||
},
|
||
});
|
||
|
||
const results: SearchResult[] = [];
|
||
|
||
if (resp.ok) {
|
||
// 腾讯返回的是 UTF-8 文本(含 \u 转义),不是 GBK
|
||
const text = await resp.text();
|
||
console.log(`[stock-search] 原始响应: ${text.substring(0, 500)}`);
|
||
|
||
// 提取引号内内容:v_hint="..."
|
||
const eqIdx = text.indexOf('="');
|
||
let content = '';
|
||
if (eqIdx !== -1) {
|
||
const startIdx = eqIdx + 2;
|
||
const endIdx = text.lastIndexOf('"');
|
||
if (endIdx > startIdx) {
|
||
content = text.substring(startIdx, endIdx);
|
||
}
|
||
}
|
||
|
||
if (content && content !== 'N') {
|
||
// 多条记录用 ^ 分隔
|
||
const records = content.split('^');
|
||
console.log(`[stock-search] 解析到 ${records.length} 条原始记录`);
|
||
|
||
for (const record of records) {
|
||
if (!record) continue;
|
||
// 字段顺序:市场~代码~名称~拼音~类型
|
||
const parts = record.split('~');
|
||
if (parts.length >= 5) {
|
||
const market = parts[0]; // sh / sz / hk / jj / bj
|
||
const code = parts[1];
|
||
const nameRaw = parts[2];
|
||
const type = parts[4]; // GP-A / GP-A-KCB(科创板) / GP-A-BJB(北交所) / GP / KJ-HB
|
||
|
||
// 保留沪深A股(含科创板、创业板、北交所):
|
||
// - sh + GP-A:上海主板
|
||
// - sh + GP-A-KCB:科创板
|
||
// - sz + GP-A:深圳主板/创业板(含301注册制)
|
||
// - sz + GP-A-CYB:创业板(部分接口会标注)
|
||
// - bj + GP-A-BJB:北交所
|
||
const isShA = market === 'sh' && (type === 'GP-A' || type === 'GP-A-KCB');
|
||
const isSzA = market === 'sz' && (type === 'GP-A' || type === 'GP-A-CYB');
|
||
const isBjA = market === 'bj' && (type === 'GP-A' || type === 'GP-A-BJB');
|
||
|
||
if ((isShA || isSzA || isBjA) && /^\d{6}$/.test(code)) {
|
||
const name = decodeUnicode(nameRaw);
|
||
if (name) {
|
||
results.push({
|
||
code,
|
||
name,
|
||
market: market.toUpperCase(),
|
||
type,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 降级:如果搜索接口无结果但keyword是6位数字代码,直接调用行情接口验证
|
||
// 这是为了支持腾讯搜索接口不收录的股票(如北交所920开头)
|
||
if (results.length === 0 && /^\d{6}$/.test(keyword)) {
|
||
console.log(`[stock-search] 搜索无结果,尝试行情接口直查: ${keyword}`);
|
||
const directResult = await lookupByQuoteApi(keyword);
|
||
if (directResult) {
|
||
results.push(directResult);
|
||
console.log(`[stock-search] 行情接口直查成功: ${directResult.name}`);
|
||
}
|
||
}
|
||
|
||
// 去重(按code)
|
||
const seen = new Set<string>();
|
||
const uniqueResults = results.filter(r => {
|
||
if (seen.has(r.code)) return false;
|
||
seen.add(r.code);
|
||
return true;
|
||
});
|
||
|
||
// 限制最多20条
|
||
const limited = uniqueResults.slice(0, 20);
|
||
|
||
console.log(`[stock-search] 关键词"${keyword}" 找到 ${limited.length} 条A股结果`);
|
||
if (limited.length > 0) {
|
||
console.log(`[stock-search] 首条: ${limited[0].code} ${limited[0].name} ${limited[0].market} ${limited[0].type}`);
|
||
}
|
||
|
||
return new Response(JSON.stringify({
|
||
data: limited,
|
||
count: limited.length,
|
||
}), { headers: corsHeaders() });
|
||
} catch (error) {
|
||
const msg = error instanceof Error ? error.message : String(error);
|
||
console.error('[stock-search] 错误:', msg);
|
||
return new Response(JSON.stringify({
|
||
error: `搜索失败: ${msg}`,
|
||
data: [],
|
||
count: 0,
|
||
}), { status: 500, headers: corsHeaders() });
|
||
}
|
||
});
|