Files
auv/functions/stock-search/index.ts
T
2026-07-05 21:29:17 +08:00

218 lines
7.3 KiB
TypeScript
Executable File
Raw 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.
// 股票搜索 Edge Function
// 通过腾讯智能搜索APIsmartbox.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() });
}
});