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

155 lines
4.6 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
// 通过腾讯财经API获取A股实时行情
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',
};
}
// 从腾讯返回的文本中提取股票数据
function parseTencentData(text: string): string[] | null {
// 腾讯格式:v_sh600519="1~贵州茅台~...";
// 找到第一个 = 后的 " 到最后一个 " 之间的内容
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);
if (!content) return null;
return content.split('~');
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders() });
}
const url = new URL(req.url);
const code = url.searchParams.get('code') || '';
if (!/^\d{6}$/.test(code)) {
return new Response(JSON.stringify({ error: '股票代码格式错误,需为6位数字' }), {
status: 400,
headers: corsHeaders(),
});
}
// 市场标识:
// - 688/60开头:上海(sh,含科创板)
// - 920/8/4开头:北交所(bj
// - 其他:深圳(sz
let market: string;
if (code.startsWith('688') || code.startsWith('60')) {
market = 'sh';
} else if (code.startsWith('920') || code.startsWith('8') || code.startsWith('4')) {
market = 'bj';
} else {
market = 'sz';
}
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 new Response(JSON.stringify({ error: `上游响应异常: ${resp.status}` }), {
status: 502,
headers: corsHeaders(),
});
}
const buffer = await resp.arrayBuffer();
let text: string;
try {
text = new TextDecoder('gbk').decode(buffer);
} catch {
text = new TextDecoder().decode(buffer);
}
const parts = parseTencentData(text);
if (!parts || parts.length < 30) {
return new Response(JSON.stringify({
error: '数据解析失败',
preview: text.substring(0, 200),
}), {
status: 500,
headers: corsHeaders(),
});
}
// 腾讯行情字段说明(基于实际数据验证)
// 1:名称 2:代码 3:当前价 4:昨收 5:今开 6:成交量(手)
// 31:涨跌额 32:涨跌幅 33:最高 34:最低 37:成交额(万)
const name = parts[1] || '';
const currentPrice = parseFloat(parts[3]) || 0;
const yesterdayClose = parseFloat(parts[4]) || 0;
const todayOpen = parseFloat(parts[5]) || 0;
const volume = parseFloat(parts[6]) || 0;
const high = parseFloat(parts[33]) || 0;
const low = parseFloat(parts[34]) || 0;
const amount = parseFloat(parts[37]) || 0;
const changeFromData = parseFloat(parts[31]) || 0;
const changePercentFromData = parseFloat(parts[32]) || 0;
// 外盘(索引7)和内盘(索引8)数据,用于计算净主动买入额
const outerDisk = parseFloat(parts[7]) || 0;
const innerDisk = parseFloat(parts[8]) || 0;
if (!name || currentPrice === 0) {
return new Response(JSON.stringify({
error: '股票不存在或已停牌',
name,
currentPrice,
}), {
status: 404,
headers: corsHeaders(),
});
}
// 优先使用腾讯返回的涨跌额/涨跌幅
const change = changeFromData !== 0 ? changeFromData : (currentPrice - yesterdayClose);
const changePercent = changePercentFromData !== 0
? changePercentFromData
: (yesterdayClose > 0 ? ((currentPrice - yesterdayClose) / yesterdayClose) * 100 : 0);
const result = {
code,
market: market.toUpperCase(),
name,
todayOpen,
yesterdayClose,
currentPrice,
high,
low,
volume: volume * 100,
amount,
outerDisk,
innerDisk,
date: new Date().toISOString().split('T')[0],
time: new Date().toTimeString().split(' ')[0],
change: parseFloat(change.toFixed(2)),
changePercent: parseFloat(changePercent.toFixed(2)),
};
return new Response(JSON.stringify({ data: result }), {
headers: corsHeaders(),
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: `获取数据失败: ${msg}` }), {
status: 500,
headers: corsHeaders(),
});
}
});