// 股票历史K线数据代理 Edge Function // 主数据源:腾讯财经 fqkline 接口(前复权日K) // 降级数据源:新浪财经 getKLineData 接口(腾讯返回数据过少时启用,如北交所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', }; } // 根据代码推断市场前缀 function getMarketPrefix(code: string): string { if (code.startsWith('688') || code.startsWith('60')) return 'sh'; if (code.startsWith('920') || code.startsWith('8') || code.startsWith('4')) return 'bj'; return 'sz'; } // 从腾讯返回数据中提取K线原始数组 function extractTencentKlines(result: any, stockCode: string): any[] { const stockData = result?.data?.[stockCode]; if (!stockData) return []; const raw = stockData.qfqday || stockData.day || []; return Array.isArray(raw) ? raw : []; } // 转换腾讯K线格式:[日期, 开, 收, 高, 低, 量, 额] function parseTencentKlines(rawKlines: any[]) { return rawKlines.map((record: any[]) => ({ date: record[0], open: parseFloat(record[1]) || 0, close: parseFloat(record[2]) || 0, high: parseFloat(record[3]) || 0, low: parseFloat(record[4]) || 0, volume: parseInt(record[5]) || 0, })); } // 新浪财经日K线接口(降级数据源) // 返回格式:[{ day, open, high, low, close, volume }, ...] async function fetchSinaKlines(code: string, days: number): Promise { const market = getMarketPrefix(code); const sinaSymbol = `${market}${code}`; const url = `https://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData?symbol=${sinaSymbol}&scale=240&ma=no&datalen=${days}`; console.log(`[stock-history] 降级新浪接口: ${url}`); try { const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' }, }); if (!resp.ok) { console.error(`[stock-history] 新浪接口响应异常: ${resp.status}`); return []; } const text = await resp.text(); if (!text || text.trim() === '' || text.trim() === 'null') return []; const arr = JSON.parse(text); if (!Array.isArray(arr)) return []; return arr.map((item: any) => ({ date: item.day, open: parseFloat(item.open) || 0, close: parseFloat(item.close) || 0, high: parseFloat(item.high) || 0, low: parseFloat(item.low) || 0, volume: parseInt(item.volume) || 0, })); } catch (err) { console.error('[stock-history] 新浪接口失败:', err); return []; } } 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') || ''; const days = parseInt(url.searchParams.get('days') || '90'); if (!/^\d{6}$/.test(code)) { return new Response(JSON.stringify({ error: '股票代码格式错误,需为6位数字' }), { status: 400, headers: corsHeaders(), }); } const market = getMarketPrefix(code); const stockCode = `${market}${code}`; try { console.log(`[stock-history] 请求股票: ${stockCode}, 天数: ${days}`); // 1. 主数据源:腾讯财经日K线接口(前复权) const apiUrl = `https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${stockCode},day,,,${days},qfq`; console.log(`[stock-history] 腾讯请求URL: ${apiUrl}`); const resp = await fetch(apiUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': 'https://stockapp.finance.qq.com/', }, }); console.log(`[stock-history] 腾讯响应状态: ${resp.status}`); let tencentKlines: any[] = []; if (resp.ok) { const result = await resp.json(); console.log(`[stock-history] 腾讯返回数据结构:`, JSON.stringify(result).substring(0, 500)); const rawKlines = extractTencentKlines(result, stockCode); console.log(`[stock-history] 腾讯K线数据条数: ${rawKlines.length}`); if (rawKlines.length > 0) { console.log(`[stock-history] 腾讯首条:`, JSON.stringify(rawKlines[0])); console.log(`[stock-history] 腾讯末条:`, JSON.stringify(rawKlines[rawKlines.length - 1])); tencentKlines = parseTencentKlines(rawKlines); } } // 2. 腾讯数据充足则直接返回 if (tencentKlines.length >= 2) { console.log(`[stock-history] 腾讯数据充足,返回 ${tencentKlines.length} 条`); return new Response(JSON.stringify({ data: tencentKlines, count: tencentKlines.length, source: 'tencent', }), { headers: corsHeaders() }); } // 3. 腾讯数据过少(新股、北交所920开头等),降级到新浪 console.log(`[stock-history] 腾讯数据不足(${tencentKlines.length}条),降级新浪接口`); const sinaKlines = await fetchSinaKlines(code, days); console.log(`[stock-history] 新浪K线数据条数: ${sinaKlines.length}`); if (sinaKlines.length > 0) { return new Response(JSON.stringify({ data: sinaKlines, count: sinaKlines.length, source: 'sina', }), { headers: corsHeaders() }); } // 4. 新浪也无数据,若腾讯至少有1条则返回,否则报错 if (tencentKlines.length > 0) { console.log(`[stock-history] 新浪无数据,返回腾讯的 ${tencentKlines.length} 条`); return new Response(JSON.stringify({ data: tencentKlines, count: tencentKlines.length, source: 'tencent', }), { headers: corsHeaders() }); } return new Response(JSON.stringify({ error: '未获取到K线数据', data: [], count: 0, }), { status: 404, headers: corsHeaders(), }); } catch (error) { const msg = error instanceof Error ? error.message : String(error); console.error('[stock-history] 错误:', msg); return new Response(JSON.stringify({ error: `获取数据失败: ${msg}`, data: [], count: 0, }), { status: 500, headers: corsHeaders(), }); } });