Flask应用, 通过前端获取数据,后端保存文件。
template/index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数据获取与保存工具</title>
<style>
body { font-family: sans-serif; margin: 20px; background-color: #f4f4f4; color: #333; }
.container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); max-width: 800px; margin: 0 auto; }
h1, h2 { color: #555; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 15px; }
label { display: block; margin-top: 10px; margin-bottom: 5px; font-weight: bold; }
input[type="text"], input[type="number"], input[type="date"], select {
width: calc(100% - 22px);
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-size: 1rem;
}
button {
background-color: #007bff;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 15px;
font-size: 1rem;
transition: background-color 0.2s ease;
}
button:hover { background-color: #0056b3; }
button:disabled { background-color: #cccccc; cursor: not-allowed; }
.file-list { list-style-type: none; padding: 0; margin-top: 10px; }
.file-list li { background-color: #e9e9e9; margin-bottom: 5px; padding: 8px; border-radius: 3px; word-break: break-all;}
.error { color: #a94442; background-color: #f2dede; border: 1px solid #ebccd1; padding: 10px; border-radius: 4px; margin-bottom:15px; }
.success { color: #3c763d; background-color: #dff0d8; border: 1px solid #d6e9c6; padding: 10px; border-radius: 4px; margin-bottom:15px; }
.info { color: #31708f; background-color: #d9edf7; border: 1px solid #bce8f1; padding: 10px; border-radius: 4px; margin-bottom:15px;}
.form-group { margin-bottom: 15px; }
.checkbox-group label { display: inline-block; margin-left: 5px; font-weight: normal;}
.error-details {
white-space: pre-wrap;
font-family: monospace;
margin-top: 10px;
padding: 10px;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
color: #721c24;
max-height: 300px;
overflow-y: auto;
word-break: break-all;
}
</style>
</head>
<body>
<div class="container">
<h1>加密货币K线数据获取与保存</h1>
<p class="info">
请选择币种、交易所、时间周期和起始日期,然后点击下方按钮获取数据并自动保存。<br>
<strong>注意:</strong> 获取历史恐惧贪婪指数功能仅支持日线 (1d) 和周线 (1w) 时间周期。
</p>
<div class="form-group">
<label for="coin_name">币种名称 (例如: BTC/USDT, ETH/BTC):</label>
<select id="coin_name">
{% for coin_symbol in coins_available %}
<option value="{{ coin_symbol }}/USDT">{{ coin_symbol }}/USDT</option>
{% endfor %}
<option value="OTHER">其他 (手动输入)</option>
</select>
<input type="text" id="coin_name_other" placeholder="若选择其他, 请在此输入币种全名 (如: BTC/USDT)" style="display:none; margin-top:5px;">
</div>
<div class="form-group">
<label for="exchange_name">交易所名称 (例如: binance, okx):</label>
<input type="text" id="exchange_name" value="binance">
</div>
<div class="form-group">
<label for="start_date">数据获取起始日期:</label>
<input type="date" id="start_date" value="{{ default_start_date }}">
</div>
<div class="form-group">
<label for="time_period">时间周期:</label>
<select id="time_period">
<option value="1d" selected>1天 (1d)</option>
<option value="1w">1周 (1w)</option>
<option value="4h">4小时 (4h)</option>
</select>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="fetch_historical_fng" checked>
<label for="fetch_historical_fng">自动获取恐惧贪婪指数 (仅适用于 1d, 1w 时间周期)</label>
</div>
<button id="fetchButton" onclick="fetchAndSaveData()">获取并保存数据</button>
<div id="message" style="margin-top:15px;"></div>
<div id="response_text" class="error-details" style="display:none;"></div>
<h2>已保存的文件</h2>
<button onclick="listSavedFiles()">刷新列表</button>
<p>数据保存路径: <code id="data_folder_path"></code></p>
<ul id="saved_files_list" class="file-list"></ul>
</div>
<script>
const coinNameSelect = document.getElementById('coin_name');
const coinNameOtherInput = document.getElementById('coin_name_other');
const messageDiv = document.getElementById('message');
const fetchButton = document.getElementById('fetchButton');
const fetchFngCheckbox = document.getElementById('fetch_historical_fng');
const timePeriodSelect = document.getElementById('time_period');
// Binance K-line interval mapping
const binanceIntervalMap = {
'1m': '1m', '15m': '15m', '1h': '1h', '4h': '4h', '1d': '1d', '1w': '1w'
};
// Show/hide manual coin input
if (coinNameSelect) {
coinNameSelect.addEventListener('change', function() {
if (coinNameOtherInput) {
if (this.value === 'OTHER') {
coinNameOtherInput.style.display = 'block';
coinNameOtherInput.value = '';
coinNameOtherInput.focus();
} else {
coinNameOtherInput.style.display = 'none';
}
}
});
}
// Validate FNG checkbox based on time period
if (timePeriodSelect && fetchFngCheckbox) {
timePeriodSelect.addEventListener('change', function() {
const selectedTimePeriod = this.value;
const isFngCompatible = ['1d', '1w'].includes(selectedTimePeriod);
fetchFngCheckbox.disabled = !isFngCompatible;
if (!isFngCompatible) {
fetchFngCheckbox.checked = false;
}
const fngLabel = document.querySelector('label[for="fetch_historical_fng"]');
if (fngLabel) {
if (isFngCompatible) {
fngLabel.textContent = '获取历史恐惧贪婪指数 (适用于 1d, 1w)';
} else {
fngLabel.textContent = '获取历史恐惧贪婪指数 (仅支持 1d, 1w)';
}
}
});
}
// Fetch Binance K-lines (supports batch requests for >1000 limit)
async function fetchBinanceKlines(symbol, interval, startTime, endTime) {
let klines = [];
let fetchStart = startTime;
const limit = 1000;
while (true) {
const url = `https://api.binance.com/api/v3/klines?symbol=${symbol}&interval=${interval}&startTime=${fetchStart}&endTime=${endTime}&limit=${limit}`;
try {
const resp = await fetch(url);
if (!resp.ok) {
const errorData = await resp.text();
throw new Error(`Binance API 请求失败: ${resp.status} ${resp.statusText}. Response: ${errorData}`);
}
const data = await resp.json();
if (!Array.isArray(data)) {
throw new Error('Binance API 返回数据格式错误,期望一个数组。');
}
if (data.length === 0) break; // No more data or initial empty response
klines = klines.concat(data);
if (data.length < limit) break; // Fetched less than limit, so it's the last batch
fetchStart = data[data.length - 1][0] + 1; // Next start time is after the last fetched kline's open time
} catch (error) {
console.error("Error during Binance kline fetch iteration:", error);
throw error; // Re-throw to be caught by the main function
}
}
return klines;
}
// Fetch Fear and Greed Index data
async function fetchFearAndGreedIndex(startDate, endDate) {
try {
const url = `https://api.alternative.me/fng/?limit=0&date_format=cn`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`获取恐惧贪婪指数失败: ${response.status}`);
}
const data = await response.json();
if (!data || !data.data || !Array.isArray(data.data)) {
throw new Error('恐惧贪婪指数数据格式无效');
}
// Parse the dates and set to start/end of day to include all data for those days
const startDateObj = new Date(startDate);
startDateObj.setHours(0, 0, 0, 0);
const startTimestampMs = startDateObj.getTime();
const endDateObj = new Date(endDate);
endDateObj.setHours(23, 59, 59, 999); // Include the entire end date
const endTimestampMs = endDateObj.getTime();
if (isNaN(startTimestampMs) || isNaN(endTimestampMs)) {
console.error("无效的FNG日期范围:", startDate, endDate);
return [];
}
console.log(`Filtering FNG data between ${startDateObj.toISOString()} (${startTimestampMs}) and ${endDateObj.toISOString()} (${endTimestampMs})`);
// Process all data points
const filteredData = data.data.map(item => {
// Parse the date string from the API (format: "YYYY-MM-DD")
const [year, month, day] = item.timestamp.split('-').map(Number);
const itemDate = new Date(year, month - 1, day);
const itemTimestampMs = itemDate.getTime();
return {
...item,
_parsedDate: itemDate,
_timestampMs: itemTimestampMs
};
}).filter(item => {
return item._timestampMs >= startTimestampMs && item._timestampMs <= endTimestampMs;
});
console.log(`Filtered ${data.data.length} FNG data points to ${filteredData.length} in range`);
return filteredData
.map(item => {
// Parse the date string to create a proper Date object
const [year, month, day] = item.timestamp.split('-').map(Number);
const itemDate = new Date(year, month - 1, day);
return {
timestamp: itemDate.getTime(), // Convert to milliseconds
value: parseInt(item.value, 10),
value_classification: item.value_classification,
date: item.timestamp // Keep the original date string in YYYY-MM-DD format
};
});
} catch (error) {
console.error('获取恐惧贪婪指数时出错:', error);
return [];
}
}
async function fetchAndSaveData() {
if (messageDiv) {
messageDiv.textContent = '正在初始化请求...';
messageDiv.className = 'info';
}
if (fetchButton) fetchButton.disabled = true;
let fngData = [];
const selectedTimePeriod = timePeriodSelect ? timePeriodSelect.value : '1d';
const shouldFetchFng = fetchFngCheckbox ? fetchFngCheckbox.checked : false;
const startDateInput = document.getElementById('start_date');
const startDateValue = startDateInput ? startDateInput.value : new Date().toISOString().split('T')[0]; // Default to today if input not found
const isFngCompatiblePeriod = ['1d', '1w'].includes(selectedTimePeriod);
if (isFngCompatiblePeriod && shouldFetchFng) {
if (messageDiv) messageDiv.textContent = '正在获取恐惧贪婪指数数据...';
try {
// FNG is daily, so fetch from start_date up to "today" or a reasonable recent end date.
// If K-lines go far into the past, FNG API might not have data for all of it.
const todayStr = new Date().toISOString().split('T')[0];
fngData = await fetchFearAndGreedIndex(startDateValue, todayStr);
console.log('Fetched FNG data:', fngData);
if (fngData.length === 0) {
console.warn("未获取到恐惧贪婪指数数据,或指定日期范围内无数据。");
}
} catch (errorCaughtByFetchAndSave) { // Catch errors if fetchFearAndGreedIndex re-throws
console.error('获取FNG数据处理中捕获到错误:', errorCaughtByFetchAndSave);
if (messageDiv) {
messageDiv.textContent = '获取恐惧贪婪指数失败: ' + errorCaughtByFetchAndSave.message + '。将尝试仅保存K线数据。';
messageDiv.className = 'info'; // Not a critical error for K-line saving
}
fngData = []; // Ensure it's an empty array to proceed
}
} else if (shouldFetchFng && !isFngCompatiblePeriod) {
console.warn(`FNG 获取已勾选,但时间周期 ${selectedTimePeriod} 不兼容 (仅支持 1d, 1w)。`);
if (messageDiv) {
messageDiv.textContent = `恐惧贪婪指数获取仅支持1d或1w周期。当前周期: ${selectedTimePeriod}。将仅保存K线数据。`;
messageDiv.className = 'info';
}
}
try {
let coinNameValue = coinNameSelect ? coinNameSelect.value : '';
if (coinNameValue === 'OTHER' && coinNameOtherInput) {
coinNameValue = coinNameOtherInput.value.trim();
}
if (!coinNameValue) {
throw new Error("币种名称不能为空。");
}
// Get exchange name from input or default to 'binance'
const exchangeInput = document.getElementById('exchange_name');
const exchangeNameValue = exchangeInput ? exchangeInput.value.trim().toLowerCase() : 'binance';
if (exchangeNameValue !== 'binance') {
console.warn("当前K线获取实现仅针对币安优化。其他交易所可能需要调整API调用。");
// For now, we'll proceed assuming it's 'binance' for the API call
}
const binanceSymbol = coinNameValue.replace('/', '').toUpperCase();
const klineInterval = binanceIntervalMap[selectedTimePeriod] || '1d'; // Default to '1d' if not in map
const klinesStartTimestamp = new Date(startDateValue).getTime();
const klinesEndTimestamp = Date.now(); // Fetch up to current time
if (messageDiv) messageDiv.textContent = '正在获取K线数据...';
const klinesRaw = await fetchBinanceKlines(binanceSymbol, klineInterval, klinesStartTimestamp, klinesEndTimestamp);
if (!klinesRaw || klinesRaw.length === 0) {
if (messageDiv) {
messageDiv.textContent = `未获取到 ${coinNameValue} (${klineInterval}) 的K线数据。`;
messageDiv.className = 'info'; // Not an error, but no data
}
if (fetchButton) fetchButton.disabled = false;
return; // Stop if no klines
}
console.log(`Fetched ${klinesRaw.length} raw klines for ${binanceSymbol}`);
// Create a map of date to FNG data for faster lookup
const fngDataMap = new Map();
if (isFngCompatiblePeriod && shouldFetchFng && fngData.length > 0) {
fngData.forEach(fng => {
fngDataMap.set(fng.date, fng);
});
console.log(`FNG data available for ${fngData.length} dates`);
}
const formattedKlines = klinesRaw.map(arr => {
const klineTimestamp = arr[0]; // Binance K-line open time (milliseconds)
const klineDate = new Date(klineTimestamp);
// Format date consistently as YYYY-MM-DD for matching
const klineDateStr = klineDate.toISOString().split('T')[0];
const klineObj = {
timestamp: klineTimestamp,
open: parseFloat(arr[1]),
high: parseFloat(arr[2]),
low: parseFloat(arr[3]),
close: parseFloat(arr[4]),
volume: parseFloat(arr[5]),
fng_value: null,
fng_classification: null
};
if (isFngCompatiblePeriod && shouldFetchFng && fngDataMap.size > 0) {
const fngMatch = fngDataMap.get(klineDateStr);
if (fngMatch) {
klineObj.fng_value = fngMatch.value;
klineObj.fng_classification = fngMatch.value_classification;
} else {
console.warn(`No FNG data found for date: ${klineDateStr}`);
}
}
return klineObj;
});
console.log('Formatted klines with FNG (sample):', formattedKlines.slice(0, 3));
const payload = {
symbol: coinNameValue,
interval: klineInterval,
klines: formattedKlines,
exchange: exchangeNameValue, // Send the actual exchange name used
fetch_historical_fng: (isFngCompatiblePeriod && shouldFetchFng) // Accurate flag
};
if (messageDiv) messageDiv.textContent = '正在发送数据到后端保存...';
const endpoint = '/api/fetch_save_data';
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await response.json();
if (response.ok && result.success) {
if (messageDiv) {
messageDiv.textContent = result.message || '数据保存成功!';
messageDiv.className = 'success';
if (result.fear_greed_index_included) {
const fngFoundCount = result.fear_greed_values_found || 0;
if (fngFoundCount > 0) {
messageDiv.textContent += ` 其中 ${fngFoundCount} 条记录包含恐惧贪婪指数。`;
} else {
messageDiv.textContent += ` 已尝试获取恐惧贪婪指数,但未匹配到数据。`;
}
}
}
listSavedFiles();
} else {
const errorMsg = result.error || result.message || (response.status ? `HTTP ${response.status}` : '未知后端错误');
const details = result.details || (result.traceback ? "详细信息见控制台。" : "");
throw new Error(`后端保存失败: ${errorMsg} ${details}`);
}
} catch (e) { // Catches errors from kline fetch, FNG processing, or backend communication
if (messageDiv) {
let errorMessage = '操作失败: ' + e.message;
if (e.stack) {
console.error('Error stack:', e.stack);
}
if (e.message && e.message.includes("traceback")) {
console.error('Backend error details:', e.message);
}
messageDiv.textContent = errorMessage;
messageDiv.className = 'error';
}
console.error('Error in fetchAndSaveData main try block:', e);
} finally {
if (fetchButton) fetchButton.disabled = false;
}
}
async function listSavedFiles() {
const listElement = document.getElementById('saved_files_list');
const pathElement = document.getElementById('data_folder_path');
if (!listElement || !pathElement) {
console.error("listSavedFiles: DOM elements for file list not found.");
return;
}
listElement.innerHTML = '<li>正在加载文件列表...</li>';
pathElement.textContent = '加载中...';
try {
const response = await fetch('/list-saved-files');
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error ${response.status}: ${errorText}`);
}
const result = await response.json();
if (result.success) {
pathElement.textContent = result.data_folder_path || '未知路径';
if (result.files && result.files.length > 0) {
listElement.innerHTML = result.files.map(f => `<li>${f}</li>`).join('');
} else {
listElement.innerHTML = `<li>目前没有已保存的文件。(路径: ${result.data_folder_path || '未知'})</li>`;
}
} else {
throw new Error(result.error || '加载文件列表失败 (后端报告)');
}
} catch (error) {
listElement.innerHTML = `<li>加载文件列表时发生错误: ${error.message}</li>`;
pathElement.textContent = '获取路径失败';
console.error("List files error:", error);
}
}
// Initial setup when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
if (timePeriodSelect) {
timePeriodSelect.dispatchEvent(new Event('change')); // Set initial FNG checkbox state
}
if (coinNameSelect && coinNameSelect.value === 'OTHER' && coinNameOtherInput) {
coinNameOtherInput.style.display = 'block'; // Handle pre-selected "OTHER"
}
listSavedFiles(); // Load files when page loads
});
</script>
</body>
</html>
app.py
import os
import pandas as pd
from flask import Flask, render_template, request, jsonify
from flask_cors import CORS
from datetime import datetime, date
import traceback
# --- 全局配置 ---
DATA_FOLDER = 'crypto_data_received'
if not os.path.exists(DATA_FOLDER):
os.makedirs(DATA_FOLDER)
app = Flask(__name__)
CORS(app) # Enable CORS for all routes
# --- Flask 路由 ---
@app.route('/')
def index_page():
coins_available = [
"BTC", "ETH", "XRP", "SOL", "XLM", "DOGE", "ALGO", "ADA", "MATIC", "LINK", "UNI", "SUSHI"
]
default_start_date = "2018-02-01"
today_date = date.today().strftime('%Y-%m-%d')
return render_template(
'index.html',
coins_available=coins_available,
default_start_date=default_start_date,
today_date=today_date
)
@app.route('/api/fetch_save_data', methods=['POST'])
def fetch_and_save_data():
print("\n=== 收到 /api/fetch_save_data 请求 ===")
print(f"请求头: {request.headers}")
print(f"请求内容类型: {request.content_type}")
try:
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "No data provided"}), 400
# 获取参数
symbol = data.get('symbol')
interval = data.get('interval', '1d')
# Klines now expected to be a list of dictionaries from frontend
klines_list_of_dicts = data.get('klines', [])
exchange_name = data.get('exchange', 'binance').lower()
# 验证必要参数
if not symbol or not interval: # klines can be empty if no data was fetched
return jsonify({
'success': False,
'error': 'Missing required parameters: symbol, interval'
}), 400
if not klines_list_of_dicts:
message = f"收到 {symbol} ({interval}) 空数据,未保存文件。"
print(f"ℹ️ {message}")
return jsonify({
'success': True,
'message': message,
'filename': None,
'filepath': None,
'records_count': 0
})
# --- MODIFICATION START ---
# Frontend sends klines as a list of dictionaries.
# We should infer the columns from the first dictionary.
if not isinstance(klines_list_of_dicts[0], dict):
# This indicates an unexpected format from frontend
error_msg = 'Unexpected klines data format: not a list of dictionaries?'
print(f"❌ {error_msg}")
return jsonify({
'success': False,
'error': error_msg,
'details': 'Received klines data is not in the expected list of dictionary format.'
}), 400
# Get column names from the keys of the first dictionary
columns = list(klines_list_of_dicts[0].keys())
print(f"ℹ️ Inferred columns from received data: {columns}")
# Create DataFrame directly from the list of dictionaries
# Pandas will use the keys in the dictionaries as column names, matching the inferred columns.
df = pd.DataFrame(klines_list_of_dicts, columns=columns)
if 'timestamp' in df.columns:
try:
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
df.set_index('timestamp', inplace=True)
# Sort by index (timestamp)
df = df.sort_index()
except Exception as e:
print(f"⚠️ Warning: Timestamp conversion or index setting failed: {e}. Proceeding without timestamp index.")
# Optionally remove the original timestamp column if setting index failed
if 'timestamp' in df.columns:
try:
df.drop(columns=['timestamp'], inplace=True)
except KeyError: # Handle case where drop fails
pass
# Ensure numeric columns are correct types
# Pandas usually does this well from dicts, but explicit coercion can be safer
for col in ['open', 'high', 'low', 'close', 'volume', 'fng_value']:
if col in df.columns:
# Coerce to numeric, invalid parsing becomes NaN
df[col] = pd.to_numeric(df[col], errors='coerce')
# Create data directory if it doesn't exist
if not os.path.exists(DATA_FOLDER):
os.makedirs(DATA_FOLDER)
# Generate filename
# Use safe names based on symbol and exchange
safe_symbol = symbol.replace('/', '-').replace(':', '-')
safe_exchange_name = exchange_name # exchange_name is already lower()
# Remove potentially problematic characters
safe_symbol = "".join(c for c in safe_symbol if c.isalnum() or c in ['_','-','.'])
safe_exchange_name = "".join(c for c in safe_exchange_name if c.isalnum() or c in ['_','-','.'])
# Ensure file name doesn't start with '.' or '_' after cleaning, prevent empty strings
while safe_symbol.startswith('.') or safe_symbol.startswith('_'): safe_symbol = safe_symbol[1:]
while safe_exchange_name.startswith('.') or safe_exchange_name.startswith('_'): safe_exchange_name = safe_exchange_name[1:]
if not safe_symbol: safe_symbol = "unknown_coin"
if not safe_exchange_name: safe_exchange_name = "unknown_exchange"
start_date_str = df.index.min().strftime('%Y%m%d') if not df.empty and isinstance(df.index, pd.DatetimeIndex) else 'nodata_start'
end_date_str = df.index.max().strftime('%Y%m%d') if not df.empty and isinstance(df.index, pd.DatetimeIndex) else 'nodata_end'
current_time_str = datetime.now().strftime('%Y%m%d%H%M%S')
# Filename format: exchange_SYMBOL_INTERVAL_startDATE_to_endDATE_TIMESTAMP.csv
filename = f"{safe_exchange_name}_{safe_symbol}_{interval}_{start_date_str}_to_{end_date_str}_{current_time_str}.csv"
filepath = os.path.join(DATA_FOLDER, filename)
# Save to CSV
# Save with index=True if timestamp index is set, otherwise index=False
df.to_csv(filepath, index=isinstance(df.index, pd.DatetimeIndex))
message = f"成功保存 {symbol} ({len(df)} 条记录) 数据到: {filename}"
print(f"✅ {message}")
response_data = {
'success': True,
'message': message,
'filename': filename,
'filepath': filepath,
'records_count': len(df),
'columns_saved': list(df.columns) # Include saved columns in response
}
# Provide info about FNG data inclusion
if 'fng_value' in df.columns:
response_data['fear_greed_index_included'] = True
non_na_fng_count = df['fng_value'].notna().sum() # Count non-NA values in fng_value column
response_data['fear_greed_values_found'] = int(non_na_fng_count)
fng_series_dropna = df['fng_value'].dropna()
if not fng_series_dropna.empty:
# Use float for sample to handle potential NaN after coercion
response_data['fear_greed_index_sample'] = float(fng_series_dropna.iloc[0])
else:
response_data['fear_greed_index_sample'] = "N/A"
else:
response_data['fear_greed_index_included'] = False
return jsonify(response_data)
except Exception as e:
error_trace = traceback.format_exc()
print(f"❌ /api/fetch_save_data 接口发生错误: {e}")
print(error_trace)
return jsonify({
'success': False,
'error': '服务器内部错误',
'details': str(e),
'traceback': error_trace # Include traceback for debugging
}), 500
@app.route('/list-saved-files', methods=['GET'])
def list_saved_files():
try:
files_with_mtime = []
# Check if DATA_FOLDER exists before listing
if not os.path.exists(DATA_FOLDER):
os.makedirs(DATA_FOLDER, exist_ok=True) # Create if missing
# If folder is just created or empty, return empty list
return jsonify({"success": True, "files": [], "data_folder_path": os.path.abspath(DATA_FOLDER), "message": "数据文件夹刚创建或为空,目前无文件。"}), 200
# Use a more robust way to list files, ignoring hidden files and non-csv
all_items = os.listdir(DATA_FOLDER)
valid_files = [f for f in all_items if os.path.isfile(os.path.join(DATA_FOLDER, f)) and f.lower().endswith('.csv') and not f.startswith('.')] # Ignore hidden files
for f_name in valid_files:
f_path = os.path.join(DATA_FOLDER, f_name)
try:
mtime = os.path.getmtime(f_path)
files_with_mtime.append((f_name, mtime))
except Exception as e:
print(f"⚠️ 获取文件修改时间失败 {f_name}: {e}")
files_with_mtime.append((f_name, 0)) # Add with time 0, will appear at the end
# Sort by modification time, newest first
files_with_mtime.sort(key=lambda x: x[1], reverse=True)
sorted_files = [f[0] for f in files_with_mtime]
return jsonify({"success": True, "files": sorted_files, "data_folder_path": os.path.abspath(DATA_FOLDER)})
except Exception as e:
print(f"❌ 获取文件列表失败: {e}")
return jsonify({"success": False, "error": "获取文件列表失败: " + str(e)}), 500
if __name__ == '__main__':
print(f"🌍 Crypto Data Saver 服务已启动,请在浏览器打开 http://127.0.0.1:5000/")
print(f"💾 接收到的数据将保存在: {os.path.abspath(DATA_FOLDER)}")
print(f"🔧 依赖说明: 需要安装 pandas 和 requests (pip install pandas requests)")
print(f"ℹ️ 此服务等待前端通过POST请求到 /api/fetch_save_data 保存数据。")
print(f"🚀 推荐使用 index.html 中的按钮触发 /api/fetch_save_data 流程。")
app.run(debug=True, use_reloader=False) # use_reloader=False to avoid running startup prints twice
运行:
python app.py