From 07b43c48c170ebc55c84719d930efe47c4d9d73a Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 26 Jan 2026 03:05:50 +0900 Subject: [PATCH] =?UTF-8?q?stock=20lab=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=20-=20=EC=A3=BC=EA=B0=80=EC=A7=80=EC=88=98=20API?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=20(KOSPI/KOSDAQ/NASDAQ=20=EB=93=B1)=20=20?= =?UTF-8?q?-=20=EB=89=B4=EC=8A=A4=20=EC=B9=B4=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8/=ED=83=9C=EA=B7=B8=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=20-=20=EC=95=84=EC=B9=A8=208=EC=8B=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=9E=A9=E2=80=9D=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=ED=83=80=EC=9D=B4=EB=A8=B8/=EC=B9=B4=EC=9A=B4=ED=8A=B8?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api.js | 28 ++++ src/pages/stock/Stock.css | 286 +++++++++++++++++++++++++++++++++++++ src/pages/stock/Stock.jsx | 290 ++++++++++++++++++++++++++++++++++++++ src/routes.jsx | 11 ++ 4 files changed, 615 insertions(+) create mode 100644 src/pages/stock/Stock.css create mode 100644 src/pages/stock/Stock.jsx diff --git a/src/api.js b/src/api.js index 51d379c..6f20c45 100644 --- a/src/api.js +++ b/src/api.js @@ -17,6 +17,22 @@ export async function apiDelete(path) { return res.json(); } +export async function apiPost(path, body) { + const res = await fetch(path, { + method: "POST", + headers: { + "Accept": "application/json", + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`); + } + return res.json(); +} + export function getLatest() { return apiGet("/api/lotto/latest"); } @@ -41,3 +57,15 @@ export function getHistory(limit = 30, offset = 0) { export function deleteHistory(id) { return apiDelete(`/api/history/${id}`); } + +export function getStockNews(limit = 20) { + return apiGet(`/api/stock/news?limit=${limit}`); +} + +export function triggerStockScrap() { + return apiPost("/api/admin/stock/scrap"); +} + +export function getStockHealth() { + return apiGet("/api/stock/health"); +} diff --git a/src/pages/stock/Stock.css b/src/pages/stock/Stock.css new file mode 100644 index 0000000..4b53c7f --- /dev/null +++ b/src/pages/stock/Stock.css @@ -0,0 +1,286 @@ +.stock { + display: grid; + gap: 28px; +} + +.stock-header { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + gap: 24px; + align-items: center; +} + +.stock-kicker { + text-transform: uppercase; + letter-spacing: 0.3em; + font-size: 12px; + color: var(--accent); + margin: 0 0 10px; +} + +.stock-header h1 { + margin: 0 0 12px; + font-family: var(--font-display); + font-size: clamp(30px, 4vw, 40px); +} + +.stock-sub { + margin: 0; + color: var(--muted); +} + +.stock-actions { + display: flex; + gap: 12px; + margin-top: 18px; + flex-wrap: wrap; +} + +.stock-card { + border: 1px solid var(--line); + border-radius: 20px; + padding: 20px; + background: var(--surface); + display: grid; + gap: 14px; +} + +.stock-card__title { + margin: 0; + font-weight: 600; +} + +.stock-status { + display: grid; + gap: 10px; + color: var(--muted); + font-size: 13px; +} + +.stock-status > div { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.stock-status strong { + color: var(--text); +} + +.stock-status__note { + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.stock-pill { + padding: 4px 10px; + border-radius: 999px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.12em; + border: 1px solid var(--line); +} + +.stock-pill.is-ok { + border-color: rgba(106, 220, 187, 0.6); + color: #b5f0dd; +} + +.stock-pill.is-warn { + border-color: rgba(245, 200, 115, 0.6); + color: #f5d28a; +} + +.stock-pill.is-unknown { + color: var(--muted); +} + +.stock-error { + margin: 0; + color: #f9b6b1; + border: 1px solid rgba(249, 182, 177, 0.4); + border-radius: 14px; + padding: 12px; + background: rgba(249, 182, 177, 0.1); +} + +.stock-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 18px; +} + +.stock-panel { + border: 1px solid var(--line); + background: var(--surface); + border-radius: 24px; + padding: 20px; + display: grid; + gap: 16px; +} + +.stock-panel--wide { + grid-column: 1 / -1; +} + +.stock-panel__head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; +} + +.stock-panel__eyebrow { + margin: 0 0 6px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.22em; + color: var(--accent); +} + +.stock-panel__sub { + margin: 6px 0 0; + color: var(--muted); + font-size: 13px; +} + +.stock-panel__actions { + display: flex; + gap: 8px; + align-items: center; +} + +.stock-chip { + font-size: 11px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--line); + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.stock-snapshot { + display: grid; + gap: 12px; +} + +.stock-snapshot__card { + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px; + display: grid; + gap: 6px; + background: rgba(0, 0, 0, 0.2); +} + +.stock-snapshot__card p { + margin: 0; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); +} + +.stock-snapshot__card strong { + font-size: 20px; +} + +.stock-snapshot__card span { + color: var(--muted); + font-size: 12px; +} + +.stock-schedule { + display: grid; + gap: 12px; + font-size: 13px; + color: var(--muted); +} + +.stock-schedule strong { + color: var(--text); +} + +.stock-filter { + display: grid; + gap: 12px; + color: var(--muted); + font-size: 13px; +} + +.stock-filter label { + display: grid; + gap: 8px; +} + +.stock-filter select { + border: 1px solid var(--line); + background: rgba(0, 0, 0, 0.25); + color: var(--text); + border-radius: 12px; + padding: 10px 12px; +} + +.stock-filter__note { + margin: 0; + font-size: 12px; +} + +.stock-news { + display: grid; + gap: 14px; +} + +.stock-news__item { + border: 1px solid var(--line); + border-radius: 16px; + padding: 16px; + display: grid; + gap: 10px; + background: rgba(0, 0, 0, 0.2); +} + +.stock-news__title { + margin: 0; + font-weight: 600; + font-size: 16px; +} + +.stock-news__summary { + margin: 6px 0 0; + color: var(--muted); + font-size: 13px; +} + +.stock-news__meta { + display: flex; + gap: 12px; + flex-wrap: wrap; + font-size: 12px; + color: var(--muted); + align-items: center; +} + +.stock-news__meta a { + color: var(--accent); +} + +.stock-empty { + margin: 0; + color: var(--muted); +} + +@media (max-width: 900px) { + .stock-header { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .stock-panel { + padding: 16px; + } +} diff --git a/src/pages/stock/Stock.jsx b/src/pages/stock/Stock.jsx new file mode 100644 index 0000000..3d9300b --- /dev/null +++ b/src/pages/stock/Stock.jsx @@ -0,0 +1,290 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + getStockHealth, + getStockNews, + triggerStockScrap, +} from '../../api'; +import './Stock.css'; + +const formatDate = (value) => value ?? '-'; + +const toDateValue = (value) => { + if (!value) return null; + const normalized = value.replace(' ', 'T').replace(/\./g, '-'); + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; +}; + +const getLatestBy = (items, key) => { + const filtered = items + .map((item) => ({ ...item, __date: toDateValue(item?.[key]) })) + .filter((item) => item.__date); + if (!filtered.length) return null; + filtered.sort((a, b) => b.__date - a.__date); + return filtered[0]?.[key] ?? null; +}; + +const Stock = () => { + const [news, setNews] = useState([]); + const [limit, setLimit] = useState(20); + const [loading, setLoading] = useState(false); + const [scraping, setScraping] = useState(false); + const [error, setError] = useState(''); + const [health, setHealth] = useState({ + status: 'unknown', + message: '', + }); + + const latestCrawled = useMemo( + () => getLatestBy(news, 'crawled_at'), + [news] + ); + const latestPublished = useMemo( + () => getLatestBy(news, 'pub_date'), + [news] + ); + + const loadNews = async () => { + setLoading(true); + setError(''); + try { + const data = await getStockNews(limit); + setNews(Array.isArray(data) ? data : []); + } catch (err) { + setError(err?.message ?? String(err)); + } finally { + setLoading(false); + } + }; + + const loadHealth = async () => { + try { + const data = await getStockHealth(); + setHealth({ + status: data?.ok ? 'ok' : 'warn', + message: data?.message ?? '', + }); + } catch (err) { + setHealth({ + status: 'unknown', + message: err?.message ?? '', + }); + } + }; + + const onScrap = async () => { + setScraping(true); + setError(''); + try { + const result = await triggerStockScrap(); + if (!result?.ok) { + throw new Error('스크랩 요청이 실패했습니다.'); + } + await loadNews(); + } catch (err) { + setError(err?.message ?? String(err)); + } finally { + setScraping(false); + } + }; + + useEffect(() => { + loadNews(); + }, [limit]); + + useEffect(() => { + loadHealth(); + }, []); + + return ( +
+
+
+

Market Lab

+

Stock Lab

+

+ 매일 오전 8시에 주식 트렌드 기사를 스크랩하고 요약해, 거래 전 + 빠르게 흐름을 파악할 수 있게 구성했습니다. +

+
+ + +
+
+
+

오늘의 상태

+
+
+ Health + + {health.status} + +
+
+ 최근 스크랩 + {formatDate(latestCrawled)} +
+
+ 최근 발행 + {formatDate(latestPublished)} +
+
+ 기사 수 + {news.length}건 +
+
+ {health.message ? ( +

{health.message}

+ ) : null} +
+
+ + {error ?

{error}

: null} + +
+
+
+
+

Snapshot

+

시장 스냅샷

+

+ 지수/가격 API 연동을 위한 준비 구간입니다. +

+
+
+
+ {['KOSPI', 'KOSDAQ', 'NASDAQ'].map((label) => ( +
+

{label}

+ -- + 연동 예정 +
+ ))} +
+
+ +
+
+
+

Schedule

+

스크랩 일정

+

+ 매일 오전 8시에 자동 스크랩이 실행됩니다. +

+
+
+
+
+ 자동 실행 + 08:00 KST +
+
+ 수동 실행 + 관리자 전용 +
+
+ 최근 스크랩 + {formatDate(latestCrawled)} +
+
+
+ +
+
+
+

Filter

+

뉴스 필터

+

+ 표시 개수를 조정해 빠르게 훑어볼 수 있습니다. +

+
+
+
+ +

+ 최신 기사부터 정렬됩니다. +

+
+
+
+ +
+
+
+

Headlines

+

트렌드 기사

+

+ 스크랩된 뉴스 요약을 바로 확인할 수 있습니다. +

+
+
+ {loading ? ( + 불러오는 중 + ) : null} + {news.length}건 +
+
+ + {loading ? ( +

뉴스를 불러오는 중...

+ ) : news.length === 0 ? ( +

표시할 뉴스가 없습니다.

+ ) : ( +
+ {news.map((item) => ( +
+
+

+ {item.title} +

+

+ {item.summary} +

+
+
+ {item.press} + {item.pub_date} + + 원문 보기 + +
+
+ ))} +
+ )} +
+
+ ); +}; + +export default Stock; diff --git a/src/routes.jsx b/src/routes.jsx index a080d29..d6a3859 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -3,6 +3,7 @@ import Home from './pages/home/Home'; import Blog from './pages/blog/Blog'; import Lotto from './pages/lotto/Lotto'; import Travel from './pages/travel/Travel'; +import Stock from './pages/stock/Stock'; export const navLinks = [ { @@ -23,6 +24,12 @@ export const navLinks = [ path: '/lotto', description: '숫자를 뽑고 통계를 확인하는 실험실', }, + { + id: 'stock', + label: 'Stock', + path: '/stock', + description: '아침 시장 흐름을 확인하는 주식 연구실', + }, { id: 'travel', label: 'Travel', @@ -44,6 +51,10 @@ export const appRoutes = [ path: 'lotto', element: , }, + { + path: 'stock', + element: , + }, { path: 'travel', element: ,