Add economic calendar feature with n8n integration
All checks were successful
Deploy to Server / deploy (push) Successful in 36s

- Add economic calendar tab with monthly view
- Display today's events in header
- Add weekly summary section
- Integrate with Forex Factory via n8n webhook
- Add Header Auth API authentication
- Add KST timezone conversion
- Add country filter (US, JP, CN)
- Add importance-based event styling
- Add more events modal for days with many events
- Update calendar grid to show up to 4 events per day
- Add n8n workflow configuration files
This commit is contained in:
kihong.kim
2025-12-31 15:24:30 +09:00
parent 4c351196b7
commit 022fddec9c
7 changed files with 1850 additions and 13 deletions

156
n8n/README.md Normal file
View File

@@ -0,0 +1,156 @@
# n8n 경제 일정 워크플로우 설정 가이드
## 워크플로우 구성
두 개의 워크플로우로 구성됩니다:
1. **Economic Calendar Collector** - 주기적으로 실제 데이터 수집
2. **Economic Calendar API** - Webhook으로 데이터 제공
```
[Schedule: 6시간마다] → [HTTP: Forex Factory] → [Code: 변환] → [File: 저장]
[Webhook 요청] → [File: 읽기] → [Code: 파싱] → [Response: JSON]
```
---
## 설정 방법
### 1. 데이터 수집 워크플로우 설정
1. n8n 웹 UI 접속 (예: `https://n8n.yourdomain.com`)
2. **Workflows****Import from File**
3. `economic-calendar-collector.json` 파일 선택
4. **Save** 클릭
5. **Active** 토글을 켜서 워크플로우 활성화
6. 수동으로 한번 실행하여 초기 데이터 생성 (▶ 버튼 클릭)
### 2. API 워크플로우 설정
1. **Workflows****Import from File**
2. `economic-calendar-api.json` 파일 선택
3. **Save** 클릭
4. **Active** 토글을 켜서 워크플로우 활성화
### 3. Webhook URL 확인
API 워크플로우 활성화 후, Webhook 노드를 클릭하면 Production URL이 표시됩니다:
```
https://n8n.yourdomain.com/webhook/economic-calendar
```
### 4. 프론트엔드 설정 수정
`app.js`에서 `dataUrl`을 n8n webhook URL로 변경:
```javascript
this.dataUrl = 'https://n8n.yourdomain.com/webhook/economic-calendar';
```
---
## 데이터 소스
현재 **Investing.com**의 경제 캘린더 API를 사용합니다:
- URL: `https://sslecal2.investing.com/economic-calendar/Service/getCalendarFilteredData`
- 제공 데이터: 향후 30일간 경제 일정
- 필터 국가: US(미국), JP(일본), KR(한국), CN(중국)
- 필터 중요도: 2성, 3성 이벤트만
- 시간대: KST (한국 표준시, timeZone=88)
### 데이터 수집 주기
기본값: **매일 새벽 3시 (KST)** 자동 수집
Cron 표현식: `0 3 * * *`
변경하려면 Collector 워크플로우의 Schedule Trigger 설정 수정:
- 매일 새벽 3시: `0 3 * * *`
- 매일 자정: `0 0 * * *`
- 6시간마다: `field: "hours"`, `hoursInterval: 6`
---
## 파일 저장 경로
n8n Docker 컨테이너 내부: `/data/economic-calendar.json`
Docker Compose 볼륨 매핑 필요:
```yaml
volumes:
- n8n_data:/home/node/.n8n
- ./data:/data # 추가
```
---
## 대체 워크플로우 (수동 관리용)
실제 API 대신 수동으로 데이터를 관리하려면 기존 `economic-calendar-workflow.json` 사용:
1. `economic-calendar-workflow.json` import
2. Code 노드에서 직접 이벤트 데이터 수정
3. 활성화
---
## 일정 데이터 형식
```json
{
"date": "2025-01-15", // YYYY-MM-DD 형식
"time": "04:00", // HH:MM 형식 (한국시간)
"country": "US", // US, KR, CN, JP
"name": "FOMC 금리 결정", // 일정 이름
"importance": 3, // 1: 낮음, 2: 중간, 3: 높음
"forecast": "4.50%", // 예상치
"previous": "4.50%", // 이전값
"actual": null // 실제값 (발표 전: null)
}
```
---
## 주요 경제 일정 목록 (참고용)
### 미국 (US)
| 일정 | 중요도 | 발표 주기 |
|------|--------|-----------|
| FOMC 금리 결정 | ★★★ | 6주마다 |
| 비농업 고용지수 (NFP) | ★★★ | 매월 첫째 금요일 |
| CPI (소비자물가지수) | ★★★ | 매월 |
| GDP | ★★★ | 분기 |
| ISM 제조업 PMI | ★★★ | 매월 |
| CB 소비자신뢰지수 | ★★★ | 매월 |
### 한국 (KR)
| 일정 | 중요도 | 발표 주기 |
|------|--------|-----------|
| 한국은행 기준금리 | ★★★ | 연 8회 |
| CPI | ★★★ | 매월 |
| 산업생산 | ★★ | 매월 |
| 수출입 | ★★ | 매월 |
### 중국 (CN)
| 일정 | 중요도 | 발표 주기 |
|------|--------|-----------|
| 제조업 PMI | ★★★ | 매월 |
| Caixin 제조업 PMI | ★★★ | 매월 |
| GDP | ★★★ | 분기 |
### 일본 (JP)
| 일정 | 중요도 | 발표 주기 |
|------|--------|-----------|
| 일본은행 금리 결정 | ★★★ | 연 8회 |
| GDP | ★★★ | 분기 |
| 실업률 | ★★ | 매월 |
---
## 경제 일정 데이터 소스
- [Investing.com 경제 캘린더](https://kr.investing.com/economic-calendar/)
- [TradingEconomics](https://tradingeconomics.com/calendar)
- [Forex Factory](https://www.forexfactory.com/calendar)

View File

@@ -0,0 +1,156 @@
{
"name": "Economic Calendar (Combined)",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 3 * * *"
}
]
}
},
"id": "schedule-1",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [250, 200]
},
{
"parameters": {
"httpMethod": "GET",
"path": "economic-calendar",
"authentication": "headerAuth",
"responseMode": "responseNode",
"options": {}
},
"id": "webhook-1",
"name": "API Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [250, 400],
"webhookId": "economic-calendar",
"credentials": {
"httpHeaderAuth": {
"id": "economic-calendar-api-key",
"name": "Economic Calendar API Key"
}
}
},
{
"parameters": {
"method": "GET",
"url": "https://nfs.faireconomy.media/ff_calendar_thisweek.json",
"options": {}
},
"id": "http-1",
"name": "Fetch Forex Factory",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [470, 200]
},
{
"parameters": {
"jsCode": "// Forex Factory 데이터 변환 및 DB 저장\nconst rawData = $input.all();\nconst staticData = $getWorkflowStaticData('global');\n\nconst newEvents = [];\n\nconst countryMap = {\n 'USD': 'US',\n 'JPY': 'JP',\n 'CNY': 'CN'\n};\n\nconst importanceMap = {\n 'Low': 1,\n 'Medium': 2,\n 'High': 3\n};\n\nconst getLocalDateStr = (d) => {\n const y = d.getFullYear();\n const m = String(d.getMonth() + 1).padStart(2, '0');\n const day = String(d.getDate()).padStart(2, '0');\n return `${y}-${m}-${day}`;\n};\n\nconst convertToKST = (dateStr) => {\n const d = new Date(dateStr);\n // UTC 기준으로 KST(+9) 변환 - 서버 timezone 무관\n const kstTime = new Date(d.getTime() + 9 * 60 * 60 * 1000);\n return {\n date: `${kstTime.getUTCFullYear()}-${String(kstTime.getUTCMonth() + 1).padStart(2, '0')}-${String(kstTime.getUTCDate()).padStart(2, '0')}`,\n time: `${String(kstTime.getUTCHours()).padStart(2, '0')}:${String(kstTime.getUTCMinutes()).padStart(2, '0')}`\n };\n};\n\nfor (const item of rawData) {\n const event = item.json;\n const country = countryMap[event.country];\n \n if (!country || !['US', 'JP', 'CN'].includes(country)) continue;\n if (event.impact === 'Low') continue;\n \n const kst = convertToKST(event.date);\n \n newEvents.push({\n date: kst.date,\n time: kst.time,\n country: country,\n name: event.title,\n importance: importanceMap[event.impact] || 2,\n forecast: event.forecast || '-',\n previous: event.previous || '-',\n actual: event.actual || null\n });\n}\n\nnewEvents.sort((a, b) => {\n const dateCompare = a.date.localeCompare(b.date);\n if (dateCompare !== 0) return dateCompare;\n return a.time.localeCompare(b.time);\n});\n\n// Static Data에 저장\nstaticData.events = newEvents;\nstaticData.updated = new Date().toISOString();\nstaticData.count = newEvents.length;\n\nreturn [{\n json: {\n success: true,\n message: 'Data saved to n8n database',\n count: newEvents.length,\n updated: staticData.updated\n }\n}];"
},
"id": "code-save",
"name": "Transform & Save",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [690, 200]
},
{
"parameters": {
"jsCode": "// Static Data에서 이벤트 읽기 (없으면 직접 fetch)\nconst staticData = $getWorkflowStaticData('global');\n\n// 캐시 유효 시간 (6시간)\nconst CACHE_TTL = 6 * 60 * 60 * 1000;\nconst now = Date.now();\nconst lastUpdated = staticData.updated ? new Date(staticData.updated).getTime() : 0;\nconst isCacheValid = staticData.events?.length > 0 && (now - lastUpdated) < CACHE_TTL;\n\nif (isCacheValid) {\n return [{ json: {\n data: staticData.events,\n source: 'forexfactory',\n updated: staticData.updated,\n count: staticData.count,\n cached: true\n }}];\n}\n\n// 캐시가 없거나 만료된 경우 직접 fetch (n8n $http 사용)\nconst response = await this.helpers.httpRequest({\n method: 'GET',\n url: 'https://nfs.faireconomy.media/ff_calendar_thisweek.json',\n json: true\n});\nconst rawData = response;\n\nconst countryMap = { 'USD': 'US', 'JPY': 'JP', 'CNY': 'CN' };\nconst importanceMap = { 'Low': 1, 'Medium': 2, 'High': 3 };\n\nconst getLocalDateStr = (d) => {\n const y = d.getFullYear();\n const m = String(d.getMonth() + 1).padStart(2, '0');\n const day = String(d.getDate()).padStart(2, '0');\n return `${y}-${m}-${day}`;\n};\n\nconst convertToKST = (dateStr) => {\n const d = new Date(dateStr);\n // UTC 기준으로 KST(+9) 변환 - 서버 timezone 무관\n const kstTime = new Date(d.getTime() + 9 * 60 * 60 * 1000);\n return {\n date: `${kstTime.getUTCFullYear()}-${String(kstTime.getUTCMonth() + 1).padStart(2, '0')}-${String(kstTime.getUTCDate()).padStart(2, '0')}`,\n time: `${String(kstTime.getUTCHours()).padStart(2, '0')}:${String(kstTime.getUTCMinutes()).padStart(2, '0')}`\n };\n};\n\nconst newEvents = [];\nfor (const event of rawData) {\n const country = countryMap[event.country];\n if (!country || !['US', 'JP', 'CN'].includes(country)) continue;\n if (event.impact === 'Low') continue;\n \n const kst = convertToKST(event.date);\n newEvents.push({\n date: kst.date,\n time: kst.time,\n country: country,\n name: event.title,\n importance: importanceMap[event.impact] || 2,\n forecast: event.forecast || '-',\n previous: event.previous || '-',\n actual: event.actual || null\n });\n}\n\nnewEvents.sort((a, b) => {\n const dateCompare = a.date.localeCompare(b.date);\n if (dateCompare !== 0) return dateCompare;\n return a.time.localeCompare(b.time);\n});\n\n// 캐시에 저장\nstaticData.events = newEvents;\nstaticData.updated = new Date().toISOString();\nstaticData.count = newEvents.length;\n\nreturn [{ json: {\n data: newEvents,\n source: 'forexfactory',\n updated: staticData.updated,\n count: newEvents.length,\n cached: false\n}}];"
},
"id": "code-read",
"name": "Read Data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [470, 400]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {
"responseHeaders": {
"entries": [
{
"name": "Access-Control-Allow-Origin",
"value": "*"
},
{
"name": "Cache-Control",
"value": "max-age=300"
}
]
}
}
},
"id": "respond-1",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [690, 400]
}
],
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "Fetch Forex Factory",
"type": "main",
"index": 0
}
]
]
},
"API Webhook": {
"main": [
[
{
"node": "Read Data",
"type": "main",
"index": 0
}
]
]
},
"Fetch Forex Factory": {
"main": [
[
{
"node": "Transform & Save",
"type": "main",
"index": 0
}
]
]
},
"Read Data": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": {
"global": {
"events": [],
"updated": null,
"count": 0
}
}
}