up
This commit is contained in:
+65
@@ -0,0 +1,65 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.38.4";
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
|
||||
serve(async (req) => {
|
||||
try {
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
// 删除3个月未访问的集合
|
||||
const threeMonthsAgo = new Date();
|
||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||
|
||||
const { data: expiredCollections, error: fetchError } = await supabase
|
||||
.from("stock_collections")
|
||||
.select("id")
|
||||
.lt("last_accessed_at", threeMonthsAgo.toISOString());
|
||||
|
||||
if (fetchError) {
|
||||
throw new Error(`查询过期集合失败: ${fetchError.message}`);
|
||||
}
|
||||
|
||||
if (!expiredCollections || expiredCollections.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "没有过期的集合需要清理" }),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 删除过期的集合(级联删除关联的股票和分享链接)
|
||||
const collectionIds = expiredCollections.map((c) => c.id);
|
||||
const { error: deleteError } = await supabase
|
||||
.from("stock_collections")
|
||||
.delete()
|
||||
.in("id", collectionIds);
|
||||
|
||||
if (deleteError) {
|
||||
throw new Error(`删除过期集合失败: ${deleteError.message}`);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message: `成功清理 ${expiredCollections.length} 个过期集合`,
|
||||
deletedCount: expiredCollections.length,
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
}
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: errorMessage }),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 500,
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
Executable
+482
@@ -0,0 +1,482 @@
|
||||
// 股票资金流向数据代理 Edge Function
|
||||
// 数据源:东方财富妙想MX API (mkapi2.dfcfs.com)
|
||||
// 每日150次调用限制
|
||||
// 提供日K线资金流向、累计趋势、主力vs散户对比分析
|
||||
|
||||
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 getEastMoneyMarket(code: string): string {
|
||||
if (code.startsWith('688')) return '6';
|
||||
if (code.startsWith('60')) return '1';
|
||||
return '0';
|
||||
}
|
||||
|
||||
// 根据代码推断市场前缀(腾讯格式)
|
||||
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';
|
||||
}
|
||||
|
||||
interface FundFlowData {
|
||||
date: string;
|
||||
mainNetInflow: number; // 主力净流入
|
||||
superLargeInflow: number; // 超大单流入
|
||||
superLargeOutflow: number; // 超大单流出
|
||||
largeInflow: number; // 大单流入
|
||||
largeOutflow: number; // 大单流出
|
||||
mediumInflow: number; // 中单流入
|
||||
mediumOutflow: number; // 中单流出
|
||||
smallInflow: number; // 小单流入
|
||||
smallOutflow: number; // 小单流出
|
||||
mainNetInflowPercent: number; // 主力净流入占比
|
||||
superLargeInflowPercent: number; // 超大单流入占比
|
||||
superLargeOutflowPercent: number; // 超大单流出占比
|
||||
largeInflowPercent: number; // 大单流入占比
|
||||
largeOutflowPercent: number; // 大单流出占比
|
||||
mediumInflowPercent: number; // 中单流入占比
|
||||
mediumOutflowPercent: number; // 中单流出占比
|
||||
smallInflowPercent: number; // 小单流入占比
|
||||
smallOutflowPercent: number; // 小单流出占比
|
||||
turnover: number; // 成交额
|
||||
// 计算字段
|
||||
mainForceNet: number; // 主力净额(超大单+大单净流入)
|
||||
retailNet: number; // 散户净额(中单+小单净流入)
|
||||
cumulativeMainNet: number; // 累计主力净流入
|
||||
cumulativeRetailNet: number; // 累计散户净流入
|
||||
changePercent: number; // 当日涨幅%
|
||||
closePrice: number; // 收盘价
|
||||
}
|
||||
|
||||
// 解析金额文本(支持 "1.432亿元", "-9915万元", "0元" 等格式)
|
||||
function parseAmount(text: string | number): number {
|
||||
if (typeof text === 'number') return text;
|
||||
if (!text || typeof text !== 'string') return 0;
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return 0;
|
||||
|
||||
const sign = trimmed.startsWith('-') ? -1 : 1;
|
||||
const cleanText = trimmed.replace(/^[+-]/, '').trim();
|
||||
|
||||
const match = cleanText.match(/^([\d.]+)\s*(亿|万|元)?$/);
|
||||
if (!match) return 0;
|
||||
|
||||
const value = parseFloat(match[1]) || 0;
|
||||
const unit = match[2] || '元';
|
||||
|
||||
if (unit === '亿') return sign * value * 100000000;
|
||||
if (unit === '万') return sign * value * 10000;
|
||||
return sign * value;
|
||||
}
|
||||
|
||||
// 调用东方财富妙想MX API获取多日资金流向
|
||||
async function fetchMXApi(name: string, days: number): Promise<any[] | null> {
|
||||
const apiKey = Deno.env.get('MX_APIKEY');
|
||||
if (!apiKey) {
|
||||
console.warn('[stock-fund-flow] 未配置 MX_APIKEY 环境变量');
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = 'https://mkapi2.dfcfs.com/finskillshub/api/claw/query';
|
||||
const payload = { toolQuery: `${name}最近${days}天主力资金流向和成交额` };
|
||||
|
||||
console.log(`[stock-fund-flow] MX API 请求: ${name}, ${days}天`);
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': apiKey,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
console.error(`[stock-fund-flow] MX API 返回错误: ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await resp.json();
|
||||
console.log(`[stock-fund-flow] MX API 返回状态: ${result?.status}`);
|
||||
|
||||
if (result?.status !== 0) {
|
||||
console.error(`[stock-fund-flow] MX API 业务错误: ${result?.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析数据
|
||||
const dtoList = result?.data?.data?.searchDataResultDTO?.dataTableDTOList || [];
|
||||
if (!dtoList || dtoList.length === 0) {
|
||||
console.warn('[stock-fund-flow] MX API 无数据');
|
||||
return null;
|
||||
}
|
||||
|
||||
const dto = dtoList[0];
|
||||
const nameMap = dto.nameMap || {};
|
||||
const table = dto.table || {};
|
||||
const rawTable = dto.rawTable || {};
|
||||
const dates = table.headName || [];
|
||||
|
||||
if (!dates || dates.length === 0) {
|
||||
console.warn('[stock-fund-flow] MX API 无日期数据');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 查找列ID
|
||||
const nameToId: Record<string, string> = {};
|
||||
for (const [kid, v] of Object.entries(nameMap)) {
|
||||
if (typeof v === 'string') {
|
||||
nameToId[v] = kid;
|
||||
}
|
||||
}
|
||||
|
||||
// 打印所有可用的列名,便于调试
|
||||
console.log('[stock-fund-flow] MX API 可用列名:', Object.values(nameToId));
|
||||
|
||||
const mainNetColId = nameToId['(区间)主力净流入资金'];
|
||||
const amountColId = nameToId['区间成交额'];
|
||||
|
||||
const dataList: any[] = [];
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
let dateStr = String(dates[i]);
|
||||
// 标准化日期格式为 YYYY-MM-DD
|
||||
// 处理 "2026-07-04"、"2026/07/04"、"07-04" 等格式
|
||||
dateStr = dateStr.trim();
|
||||
if (/^\d{4}[-\/]\d{1,2}[-\/]\d{1,2}/.test(dateStr)) {
|
||||
// 完整日期格式,统一分隔符
|
||||
dateStr = dateStr.replace(/\//g, '-');
|
||||
// 补全月份和日期
|
||||
const parts = dateStr.split('-');
|
||||
if (parts.length >= 3) {
|
||||
dateStr = `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`;
|
||||
}
|
||||
} else if (/^\d{1,2}[-\/]\d{1,2}/.test(dateStr)) {
|
||||
// 只有月-日,补当前年份
|
||||
const now = new Date();
|
||||
const parts = dateStr.split(/[-\/]/);
|
||||
dateStr = `${now.getFullYear()}-${parts[0].padStart(2, '0')}-${parts[1].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 主力净流入
|
||||
let mainNetInflow = 0;
|
||||
if (mainNetColId && rawTable[mainNetColId] && i < rawTable[mainNetColId].length) {
|
||||
const rawVal = rawTable[mainNetColId][i];
|
||||
if (typeof rawVal === 'number') {
|
||||
mainNetInflow = rawVal;
|
||||
} else if (typeof rawVal === 'string') {
|
||||
mainNetInflow = parseAmount(rawVal);
|
||||
}
|
||||
}
|
||||
|
||||
// 成交额
|
||||
let amount = 0;
|
||||
if (amountColId && rawTable[amountColId] && i < rawTable[amountColId].length) {
|
||||
const rawVal = rawTable[amountColId][i];
|
||||
if (typeof rawVal === 'number') {
|
||||
amount = rawVal;
|
||||
} else if (typeof rawVal === 'string') {
|
||||
amount = parseAmount(rawVal);
|
||||
}
|
||||
}
|
||||
|
||||
dataList.push({
|
||||
date: dateStr,
|
||||
mainNetInflow,
|
||||
amount,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[stock-fund-flow] MX API 解析完成,共 ${dataList.length} 条数据`);
|
||||
if (dataList.length > 0) {
|
||||
console.log(`[stock-fund-flow] 首条数据:`, JSON.stringify(dataList[0]));
|
||||
console.log(`[stock-fund-flow] 末条数据:`, JSON.stringify(dataList[dataList.length - 1]));
|
||||
}
|
||||
return dataList;
|
||||
} catch (err) {
|
||||
console.error('[stock-fund-flow] MX API 请求失败:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 name = url.searchParams.get('name') || '';
|
||||
const days = parseInt(url.searchParams.get('days') || '30');
|
||||
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
return new Response(JSON.stringify({ error: '股票代码格式错误,需为6位数字' }), {
|
||||
status: 400,
|
||||
headers: corsHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
const market = getEastMoneyMarket(code);
|
||||
const secid = `${market}.${code}`;
|
||||
|
||||
try {
|
||||
console.log(`[stock-fund-flow] 请求股票: ${secid}, 天数: ${days}`);
|
||||
|
||||
// 首先尝试获取股票名称(如果未提供)
|
||||
let stockName = name;
|
||||
if (!stockName) {
|
||||
try {
|
||||
const quoteUrl = `https://qt.gtimg.cn/q=${getMarketPrefix(code)}${code}`;
|
||||
const quoteResp = await fetch(quoteUrl, { headers: { 'User-Agent': 'Mozilla/5.0' } });
|
||||
if (quoteResp.ok) {
|
||||
const quoteText = await quoteResp.text();
|
||||
const eqIdx = quoteText.indexOf('="');
|
||||
if (eqIdx !== -1) {
|
||||
const content = quoteText.substring(eqIdx + 2, quoteText.lastIndexOf('"'));
|
||||
const parts = content.split('~');
|
||||
stockName = parts[1] || '';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[stock-fund-flow] 获取股票名称失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 数据源1:东方财富妙想MX API
|
||||
let mxData: any[] | null = null;
|
||||
if (stockName) {
|
||||
mxData = await fetchMXApi(stockName, days);
|
||||
}
|
||||
|
||||
// 如果MX API失败,回退到原有东方财富API
|
||||
let fflowKlines: string[] = [];
|
||||
if (!mxData || mxData.length === 0) {
|
||||
console.log('[stock-fund-flow] MX API 失败,回退到原有东方财富API');
|
||||
|
||||
const fflowUrl = `https://push2his.eastmoney.com/api/qt/stock/fflow/daykline/get?lmt=${days}&fields1=f1,f2,f3,f7&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61,f62,f63,f64,f65,f66,f67,f68,f69&ut=b2884a393a59ad64002292a3e90d46a5&secid=${secid}`;
|
||||
|
||||
const fflowResp = await fetch(fflowUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Referer': 'https://quote.eastmoney.com/',
|
||||
},
|
||||
});
|
||||
|
||||
if (fflowResp.ok) {
|
||||
const fflowResult = await fflowResp.json();
|
||||
fflowKlines = fflowResult?.data?.klines || [];
|
||||
}
|
||||
}
|
||||
|
||||
// 数据源2:腾讯K线API(提供收盘价、涨跌幅、成交额)
|
||||
const marketPrefix = getMarketPrefix(code);
|
||||
const stockCode = `${marketPrefix}${code}`;
|
||||
const klineUrl = `https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${stockCode},day,,,${days + 5},qfq`;
|
||||
|
||||
const klineMap: Map<string, { close: number; changePercent: number; turnover: number }> = new Map();
|
||||
|
||||
try {
|
||||
const klineResp = await fetch(klineUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Referer': 'https://stockapp.finance.qq.com/',
|
||||
},
|
||||
});
|
||||
if (klineResp.ok) {
|
||||
const klineResult = await klineResp.json();
|
||||
const stockData = klineResult?.data?.[stockCode];
|
||||
const rawKlines = stockData?.qfqday || stockData?.day || [];
|
||||
console.log(`[stock-fund-flow] 腾讯K线数据条数: ${rawKlines.length}`);
|
||||
if (Array.isArray(rawKlines)) {
|
||||
let prevClose = 0;
|
||||
rawKlines.forEach((record: any[]) => {
|
||||
if (record && record.length >= 7) {
|
||||
const date = record[0];
|
||||
const close = parseFloat(record[2]) || 0;
|
||||
const turnover = parseFloat(record[6]) || 0;
|
||||
let changePercent = 0;
|
||||
if (prevClose > 0) {
|
||||
changePercent = ((close - prevClose) / prevClose) * 100;
|
||||
}
|
||||
klineMap.set(date, { close, changePercent, turnover });
|
||||
prevClose = close;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[stock-fund-flow] 获取腾讯K线数据失败:', err);
|
||||
}
|
||||
|
||||
// 解析数据
|
||||
let parsedData: FundFlowData[] = [];
|
||||
|
||||
if (mxData && mxData.length > 0) {
|
||||
// MX API 返回数据按日期降序(最新在前),需反转为正序以正确计算累计值
|
||||
const sortedMxData = [...mxData].sort((a, b) => {
|
||||
return String(a.date).localeCompare(String(b.date));
|
||||
});
|
||||
|
||||
let cumulativeMain = 0;
|
||||
let cumulativeRetail = 0;
|
||||
|
||||
parsedData = sortedMxData.map((item) => {
|
||||
const mainNetInflow = item.mainNetInflow || 0;
|
||||
const turnover = item.amount || 0;
|
||||
|
||||
// MX API 只返回主力净流入,没有散户数据
|
||||
// 主力 = 主力净流入
|
||||
// 散户 = 数据不可用(显示为0)
|
||||
const mainForceNet = mainNetInflow;
|
||||
const retailNet = 0; // MX API 不提供散户数据
|
||||
|
||||
// 按比例估算各档位(仅用于展示分布)
|
||||
const superLargeNet = mainNetInflow * 0.4;
|
||||
const largeNet = mainNetInflow * 0.6;
|
||||
const mediumNet = 0;
|
||||
const smallNet = 0;
|
||||
|
||||
cumulativeMain += mainForceNet;
|
||||
cumulativeRetail += 0; // 散户累计不计算
|
||||
|
||||
// 从K线数据获取收盘价、涨跌幅
|
||||
const klineInfo = klineMap.get(item.date);
|
||||
|
||||
return {
|
||||
date: item.date,
|
||||
mainNetInflow,
|
||||
superLargeInflow: Math.max(0, superLargeNet),
|
||||
superLargeOutflow: Math.abs(Math.min(0, superLargeNet)),
|
||||
largeInflow: Math.max(0, largeNet),
|
||||
largeOutflow: Math.abs(Math.min(0, largeNet)),
|
||||
mediumInflow: Math.max(0, mediumNet),
|
||||
mediumOutflow: Math.abs(Math.min(0, mediumNet)),
|
||||
smallInflow: Math.max(0, smallNet),
|
||||
smallOutflow: Math.abs(Math.min(0, smallNet)),
|
||||
mainNetInflowPercent: 0,
|
||||
superLargeInflowPercent: 0,
|
||||
superLargeOutflowPercent: 0,
|
||||
largeInflowPercent: 0,
|
||||
largeOutflowPercent: 0,
|
||||
mediumInflowPercent: 0,
|
||||
mediumOutflowPercent: 0,
|
||||
smallInflowPercent: 0,
|
||||
smallOutflowPercent: 0,
|
||||
turnover,
|
||||
mainForceNet,
|
||||
retailNet,
|
||||
cumulativeMainNet: cumulativeMain,
|
||||
cumulativeRetailNet: cumulativeRetail,
|
||||
changePercent: klineInfo?.changePercent || 0,
|
||||
closePrice: klineInfo?.close || 0,
|
||||
};
|
||||
});
|
||||
} else if (fflowKlines.length > 0) {
|
||||
// 使用原有东方财富API数据
|
||||
let cumulativeMain = 0;
|
||||
let cumulativeRetail = 0;
|
||||
|
||||
parsedData = fflowKlines.map((line: string) => {
|
||||
const fields = line.split(',');
|
||||
const date = fields[0];
|
||||
|
||||
const superLargeNet = (parseFloat(fields[2]) || 0) - (parseFloat(fields[3]) || 0);
|
||||
const largeNet = (parseFloat(fields[4]) || 0) - (parseFloat(fields[5]) || 0);
|
||||
const mediumNet = (parseFloat(fields[6]) || 0) - (parseFloat(fields[7]) || 0);
|
||||
const smallNet = (parseFloat(fields[8]) || 0) - (parseFloat(fields[9]) || 0);
|
||||
|
||||
const mainForceNet = superLargeNet + largeNet;
|
||||
const retailNet = mediumNet + smallNet;
|
||||
|
||||
cumulativeMain += mainForceNet;
|
||||
cumulativeRetail += retailNet;
|
||||
|
||||
const klineInfo = klineMap.get(date);
|
||||
|
||||
return {
|
||||
date,
|
||||
mainNetInflow: parseFloat(fields[1]) || 0,
|
||||
superLargeInflow: parseFloat(fields[2]) || 0,
|
||||
superLargeOutflow: parseFloat(fields[3]) || 0,
|
||||
largeInflow: parseFloat(fields[4]) || 0,
|
||||
largeOutflow: parseFloat(fields[5]) || 0,
|
||||
mediumInflow: parseFloat(fields[6]) || 0,
|
||||
mediumOutflow: parseFloat(fields[7]) || 0,
|
||||
smallInflow: parseFloat(fields[8]) || 0,
|
||||
smallOutflow: parseFloat(fields[9]) || 0,
|
||||
mainNetInflowPercent: parseFloat(fields[10]) || 0,
|
||||
superLargeInflowPercent: parseFloat(fields[11]) || 0,
|
||||
superLargeOutflowPercent: parseFloat(fields[12]) || 0,
|
||||
largeInflowPercent: parseFloat(fields[13]) || 0,
|
||||
largeOutflowPercent: parseFloat(fields[14]) || 0,
|
||||
mediumInflowPercent: parseFloat(fields[15]) || 0,
|
||||
mediumOutflowPercent: parseFloat(fields[16]) || 0,
|
||||
smallInflowPercent: parseFloat(fields[17]) || 0,
|
||||
smallOutflowPercent: parseFloat(fields[18]) || 0,
|
||||
turnover: klineInfo?.turnover || 0,
|
||||
mainForceNet,
|
||||
retailNet,
|
||||
cumulativeMainNet: cumulativeMain,
|
||||
cumulativeRetailNet: cumulativeRetail,
|
||||
changePercent: klineInfo?.changePercent || 0,
|
||||
closePrice: klineInfo?.close || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (parsedData.length === 0) {
|
||||
return new Response(JSON.stringify({
|
||||
error: '未获取到资金流向数据',
|
||||
data: [],
|
||||
count: 0,
|
||||
}), {
|
||||
status: 404,
|
||||
headers: corsHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[stock-fund-flow] 解析完成,共 ${parsedData.length} 条数据`);
|
||||
console.log(`[stock-fund-flow] 首条数据 turnover:`, parsedData[0]?.turnover, 'mainForceNet:', parsedData[0]?.mainForceNet);
|
||||
|
||||
// 计算总体统计
|
||||
const totalMainNet = parsedData.reduce((sum, d) => sum + d.mainForceNet, 0);
|
||||
const totalTurnover = parsedData.reduce((sum, d) => sum + d.turnover, 0);
|
||||
// 大单流入资金 ≈ 主买资金(机构主动买入的近似指标)
|
||||
const totalLargeInflow = parsedData.reduce((sum, d) => sum + d.largeInflow, 0);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
data: parsedData,
|
||||
count: parsedData.length,
|
||||
stockCode: code,
|
||||
summary: {
|
||||
totalMainNet,
|
||||
totalTurnover,
|
||||
totalLargeInflow, // 大单流入资金(≈主买资金)
|
||||
avgDailyMainNet: totalMainNet / parsedData.length,
|
||||
positiveDays: parsedData.filter(d => d.mainForceNet > 0).length,
|
||||
negativeDays: parsedData.filter(d => d.mainForceNet < 0).length,
|
||||
},
|
||||
}), { headers: corsHeaders() });
|
||||
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[stock-fund-flow] 错误:', msg);
|
||||
return new Response(JSON.stringify({
|
||||
error: `获取资金流向数据失败: ${msg}`,
|
||||
data: [],
|
||||
count: 0,
|
||||
}), {
|
||||
status: 500,
|
||||
headers: corsHeaders(),
|
||||
});
|
||||
}
|
||||
});
|
||||
Executable
+175
@@ -0,0 +1,175 @@
|
||||
// 股票历史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<any[]> {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
});
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
使用mootdx获取A股历史K线数据
|
||||
mootdx通过TCP协议直连通达信服务器,不依赖HTTP API,稳定性极高
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
def get_kline_data(code: str, days: int = 90):
|
||||
"""
|
||||
获取股票历史K线数据
|
||||
|
||||
Args:
|
||||
code: 6位股票代码
|
||||
days: 需要获取的天数
|
||||
|
||||
Returns:
|
||||
K线数据列表
|
||||
"""
|
||||
try:
|
||||
from mootdx.quotes import Quotes
|
||||
|
||||
# 判断市场
|
||||
market = 'sh' if code.startswith('6') else 'sz'
|
||||
|
||||
# 创建客户端
|
||||
client = Quotes.factory(market='std', multithread=True, heartbeat=True)
|
||||
|
||||
# 获取K线数据(日K)
|
||||
# category: 0=5分钟, 1=15分钟, 2=30分钟, 3=1小时, 4=日K, 5=周K, 6=月K
|
||||
klines = client.bars(symbol=code, market=market, category=4, count=days)
|
||||
|
||||
if not klines or len(klines) == 0:
|
||||
return {
|
||||
'error': '未获取到K线数据',
|
||||
'data': [],
|
||||
'count': 0
|
||||
}
|
||||
|
||||
# 转换为标准格式
|
||||
result = []
|
||||
for bar in klines:
|
||||
# mootdx返回的是namedtuple,包含: datetime, open, high, low, close, amount, vol
|
||||
result.append({
|
||||
'date': bar.datetime.strftime('%Y-%m-%d') if hasattr(bar.datetime, 'strftime') else str(bar.datetime)[:10],
|
||||
'open': float(bar.open),
|
||||
'close': float(bar.close),
|
||||
'high': float(bar.high),
|
||||
'low': float(bar.low),
|
||||
'volume': int(bar.vol) if hasattr(bar, 'vol') else 0,
|
||||
})
|
||||
|
||||
# 按日期排序(从旧到新)
|
||||
result.sort(key=lambda x: x['date'])
|
||||
|
||||
return {
|
||||
'data': result,
|
||||
'count': len(result),
|
||||
'source': 'mootdx'
|
||||
}
|
||||
|
||||
except ImportError:
|
||||
return {
|
||||
'error': 'mootdx库未安装',
|
||||
'data': [],
|
||||
'count': 0
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'error': f'获取失败: {str(e)}',
|
||||
'data': [],
|
||||
'count': 0
|
||||
}
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 从命令行参数获取股票代码和天数
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({'error': '请提供股票代码'}))
|
||||
sys.exit(1)
|
||||
|
||||
code = sys.argv[1]
|
||||
days = int(sys.argv[2]) if len(sys.argv) > 2 else 90
|
||||
|
||||
result = get_kline_data(code, days)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
// 股票数据代理 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(),
|
||||
});
|
||||
}
|
||||
});
|
||||
Executable
+217
@@ -0,0 +1,217 @@
|
||||
// 股票搜索 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() });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user