리액트 마이그레이션, airflow 틀잡기.

This commit is contained in:
YeoJeongHun1 2025-09-05 17:55:39 +09:00
parent 094b17edbe
commit 163eaa9626
58 changed files with 12890 additions and 1688 deletions

View File

@ -7,7 +7,26 @@
"Bash(del \"detail_page.html\")", "Bash(del \"detail_page.html\")",
"Bash(del \"simulation_page.html\")", "Bash(del \"simulation_page.html\")",
"Bash(rm:*)", "Bash(rm:*)",
"Bash(mv:*)" "Bash(mv:*)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01\\화면설계서/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01\\화면설계서/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI\\src/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI\\src/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI\\src\\types/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI\\src/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI\\src\\components\\common/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01\\화면설계서/**)",
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01\\화면설계서/**)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@ -0,0 +1,55 @@
# 1. 기반 이미지 설정: 공식 Airflow 이미지를 사용합니다.
# docker-compose.yaml의 AIRFLOW_IMAGE_NAME 환경 변수와 버전을 일치시키는 것이 좋습니다.
# 예: apache/airflow:2.9.3
ARG AIRFLOW_VERSION=2.9.3
FROM apache/airflow:${AIRFLOW_VERSION}
# 2. 루트 사용자로 전환 (시스템 패키지 설치 위함)
USER root
# 3. 필요한 시스템 유틸리티 및 Chrome 브라우저 설치 (Debian/Ubuntu 기반)
# 공식 Airflow 이미지는 Debian 기반이므로 apt-get 사용
RUN apt-get update -yqq && \
apt-get install -yqq --no-install-recommends \
wget \
gnupg \
unzip \
# Chrome 실행에 필요한 라이브러리들
libglib2.0-0 libnss3 libgconf-2-4 libfontconfig1 libxi6 libgdk-pixbuf2.0-0 libgtk-3-0 libx11-xcb1 libxcomposite1 libxcursor1 libxdamage1 libxfixes3 libxrandr2 libxrender1 libxss1 libxtst6 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdrm2 libgbm1 libpango-1.0-0 libcairo2 libasound2 && \
# Google Chrome 저장소 추가 및 설치
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list && \
apt-get update -yqq && \
apt-get install -yqq google-chrome-stable && \
# 설치 후 불필요한 파일 정리
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# 4. ChromeDriver 설치
# 중요: 설치된 Google Chrome 버전에 맞는 ChromeDriver 버전을 사용해야 합니다.
# Chrome 버전 확인: google-chrome-stable --version (컨테이너 빌드 중에는 어려우므로, 로컬에서 확인 후 버전 지정)
# ChromeDriver 다운로드 URL: https://googlechromelabs.github.io/chrome-for-testing/
# 아래 ARG는 예시이며, 실제 Chrome 버전에 맞는 ChromeDriver 버전을 명시해야 합니다.
ARG CHROME_DRIVER_VERSION="136.0.7103.92" # 예시: 2025년 5월 기준 최신 Stable과 유사한 버전 (실제 버전에 맞게 수정!)
RUN wget -O /tmp/chromedriver.zip "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/${CHROME_DRIVER_VERSION}/linux64/chromedriver-linux64.zip" && \
unzip /tmp/chromedriver.zip -d /usr/local/bin/ && \
# 압축 해제 시 chromedriver-linux64 디렉토리 안에 chromedriver가 있을 수 있음
(mv /usr/local/bin/chromedriver-linux64/chromedriver /usr/local/bin/chromedriver || echo "chromedriver already in /usr/local/bin or mv failed") && \
rm -rf /tmp/chromedriver.zip /usr/local/bin/chromedriver-linux64 && \
chmod +x /usr/local/bin/chromedriver
# (선택 사항) ChromeDriver 경로 환경 변수 설정 (코드에서 Airflow Variable을 사용하면 필수는 아님)
# ENV CHROMEDRIVER_EXECUTABLE_PATH /usr/local/bin/chromedriver
# 5. Airflow 사용자로 다시 전환
USER airflow
# 6. requirements.txt 파일을 이미지 안으로 복사
# 이 Dockerfile과 같은 디렉토리에 requirements.txt 파일이 있다고 가정
COPY requirements.txt /requirements.txt
# 7. requirements.txt에 명시된 Python 패키지 설치
RUN pip install --no-cache-dir -r /requirements.txt
# 8. (선택 사항) 작업 디렉토리 설정 등
# WORKDIR /opt/airflow

View File

@ -0,0 +1,61 @@
# Airflow Configuration File
[core]
# Airflow의 내부 스케줄링 및 시간 처리 기준 시간대
default_timezone = Asia/Seoul
# DAG가 처음 생성될 때 "paused" 상태로 할지 여부
dags_are_paused_at_creation = True
# 예제 DAG 로드 여부
load_examples = False
# 실행기 유형 (docker-compose.yaml의 환경 변수 설정과 일치시키는 것이 좋음)
# executor = CeleryExecutor
# Fernet 키 (docker-compose.yaml의 환경 변수에서 관리하는 것이 일반적)
# fernet_key = YOUR_FERNET_KEY
[webserver]
# Airflow 웹 UI에 표시되는 시간의 기준 시간대
default_ui_timezone = Asia/Seoul
# API 인증 백엔드 (docker-compose.yaml의 환경 변수 설정과 일치시키는 것이 좋음)
# auth_backends = airflow.api.auth.backend.basic_auth,airflow.api.auth.backend.session
[logging]
# 로그 메시지 형식
# %(asctime)s는 일반적으로 컨테이너의 TZ 환경 변수와 아래 default_date_format, timezone_aware 설정의 영향을 받습니다.
log_format = [%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s
# 로그의 %(asctime)s에 적용될 날짜/시간 형식
# %Z는 시간대 이름 (KST 등), %z는 UTC 오프셋 (+0900 등)을 표시합니다.
default_date_format = %Y-%m-%d %H:%M:%S %Z%z
# 시간대 인식 로깅 활성화 (기본값이 True이지만 명시적으로 설정)
# 이 설정이 True이면, Airflow의 TimezoneAwareFormatter가 사용되어
# default_timezone (Asia/Seoul) 기준으로 시간을 표시하려고 시도합니다.
timezone_aware = True
# 로그 파일 이름 템플릿 (기본값 사용 권장)
# default_log_filename_template = {{ ti.dag_id }}/{{ ti.task_id }}/{{ ts }}/{{ try_number }}.log
# 원격 로깅을 사용하지 않는 경우, 태스크 로그는 파일로 저장됩니다.
# task_log_reader = file.task
# 로그 레벨
# logging_level = INFO
# --- 기타 필요한 설정들 ---
# 예: [database], [celery] 섹션의 설정들은
# docker-compose.yaml의 환경 변수(AIRFLOW__DATABASE__SQL_ALCHEMY_CONN 등)를 통해
# 이미 설정되어 있으므로, 여기서는 보통 명시적으로 다시 설정할 필요가 없습니다.
# 만약 특정 설정을 airflow.cfg에서만 관리하고 싶다면 해당 섹션과 키를 추가할 수 있습니다.
# [database]
# sql_alchemy_conn = postgresql+psycopg2://airflow:airflow@postgres/airflow
# [celery]
# broker_url = redis://:@redis:6379/0
# result_backend = db+postgresql://airflow:airflow@postgres/airflow

View File

@ -0,0 +1,82 @@
# 필요한 모듈
import pendulum # 날짜/시간 처리를 위한 권장 라이브러리 (datetime 대체 가능)
import logging
from airflow.models.dag import DAG # DAG 객체 정의를 위한 클래스
from airflow.operators.bash import BashOperator # Bash 명령어를 실행하는 오퍼레이터
from airflow.operators.python import PythonOperator # Python 함수를 실행하는 오퍼레이터
from airflow.operators.empty import EmptyOperator
from datetime import datetime
from airflow.models.variable import Variable
from imei.tasks.task_collect_news import TaskCollectNews
# 모든 Operator에 공통적으로 적용될 기본값들을 딕셔너리로 정의할 수 있습니다.
# 스캐쥴링 설정
scheduler = "0 0 * * *"
# args 설정
default_args = {
'owner': 'airflow_user', # DAG의 소유자 (관리/알림 목적)
"start_date": datetime(2025, 9, 5, 15, 0, 0), # 첫 실행 날짜
"depends_on_past": False, # 이전 DAG Run 실행 여부에 따른 의존성 설정
}
# DAG 정의
# 'with DAG(...) as dag:' 구문을 사용하면 이 블록 내에서 정의된 오퍼레이터들이 자동으로 'dag' 객체에 속하게 됩니다.
with DAG(
dag_id='collect_news', # DAG의 고유 식별자. Airflow UI에 표시됨. (필수)
description='뉴스 수집 DAG', # DAG에 대한 설명 (UI에 표시됨)
schedule=scheduler, # DAG 실행 스케줄. None은 수동 실행(Manual Trigger)을 의미.
catchup=False, # True면 start_date부터 현재까지 놓친 스케줄을 모두 실행 (Backfill). 보통 False로 설정.
default_args=default_args, # 위에서 정의한 기본 인수 적용
max_active_runs=1,
tags=['뉴스'], # DAG를 분류하고 UI에서 필터링하기 위한 태그 목록
) as dag: # 이 DAG 인스턴스를 'dag' 변수로 사용
# dag 설명
dag.doc_md = """
뉴스 수집 DAG
"""
# 임시 처리용 클래스
task_collect_news = TaskCollectNews()
# Task(작업) 정의
# 각 Task는 Operator 클래스의 인스턴스로 생성됩니다.
####################################################################################################################
# Task: Start
####################################################################################################################
task_start = EmptyOperator(
task_id="task_start"
)
task_start.doc_md = f"""- Empty Task"""
####################################################################################################################
# Task1 : NewsAPI를 통해 뉴스 데이터를 수집합니다.
task_collect_news_newsapi = PythonOperator(
task_id='collect_news_newsapi',
python_callable=task_collect_news.collect_news_newsapi,
op_kwargs = {
"exec_dt_str": "{{ data_interval_end }}"
},
)
task_collect_news_newsapi.doc_md = f"""
- NewsAPI를 통해 뉴스 데이터를 수집합니다.
"""
####################################################################################################################
####################################################################################################################
# Task: End
####################################################################################################################
task_end = EmptyOperator(
task_id="task_end"
)
task_end.doc_md = f"""- Empty Task"""
# 6. Task 의존성(실행 순서) 설정
task_start >> \
task_collect_news_newsapi >> \
task_end

View File

@ -0,0 +1,36 @@
from airflow.exceptions import AirflowException, AirflowSkipException, AirflowFailException
from airflow.models import Variable
from airflow.providers.mysql.hooks.mysql import MySqlHook
from plugins.utils.common_datetime import CommonDatetime
import logging
# Variable
# NEWSAPI
NEWSAPI_API_KEY = Variable.get("NEWSAPI_API_KEY", "")
NEWSAPI_URL = Variable.get("NEWSAPI_URL", "https://newsapi.org/v2/everything")
class TaskCollectNews():
# =======================================================================================================================================
# 임시 처리용 테스트
def collect_news_newsapi(self, exec_dt_str: str):
"""
NewsAPI를 통해 뉴스 데이터를 수집합니다.
"""
logging.info('============================== collect_news_newsapi start ==============================')
try:
# 기본 정보
exec_dt = CommonDatetime.make_datetime_kst_from_ts(exec_dt_str)
logging.info(f"exec_dt(): {exec_dt}")
# NewsAPI를 통해 뉴스 데이터를 수집합니다.
url = f"{NEWSAPI_URL}?q=keyword&apiKey={NEWSAPI_API_KEY}"
response = requests.get(url)
data = response.json()
logging.info(f"data: {data}")
except Exception as e:
logging.error(f"collect_news_newsapi() Exception: {str(e)}")
raise Exception(str(e))
finally :
logging.info('============================== collect_news_newsapi finish ==============================')

View File

@ -0,0 +1,616 @@
import inspect
import pymysql
import logging
import mysql.connector
from sqlalchemy.engine import Engine
from airflow.exceptions import AirflowException, AirflowSkipException, AirflowFailException
from airflow.providers.mysql.hooks.mysql import MySqlHook
from sqlalchemy.engine import Engine
TIMEZONE = "Asia/Seoul"
class CommonHookMySQL:
@staticmethod
def _set_timezone(cursor):
cursor.execute(f"SET time_zone = '{TIMEZONE}'")
@staticmethod
def select_sql(conn: mysql.connector.connection.MySQLConnection, sql: str):
message = ""
try:
cursor = conn.cursor()
cursor.execute(sql)
result = cursor.fetchall()
if result is None:
raise AirflowFailException(f"select result is None")
return result
except AirflowSkipException as e:
message = f"select_user_data() AirflowSkipException: {str(e)}"
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"select_user_data() AirflowFailException: {str(e)}"
raise AirflowFailException(message)
except AirflowException as e:
message = f"select_user_data() AirflowException: {str(e)}"
raise AirflowException(message)
except Exception as e:
message = f"select_user_data() Exception: {str(e)}"
raise AirflowException(message)
finally:
if message:
logging.info(message)
@staticmethod
def select_sql_to_dict(conn: Engine, sql: str): # 파라미터 바인딩 지원 추가
"""
SQL 쿼리를 실행하고 결과를 딕셔너리의 리스트 형태로 반환합니다.
MySqlHook을 사용하며, DictCursor에 직접 의존하지 않습니다.
:param conn_id: Airflow MySQL Connection ID
:param sql: 실행할 SELECT SQL 쿼리 문자열
:param parameters: SQL 쿼리에 바인딩할 파라미터 (튜플 또는 리스트)
:return: 행이 딕셔너리인 리스트 (: [{'col1': val1, 'col2': val2}, ...])
:rtype: list[dict]
"""
message = ""
try:
cursor = conn.cursor()
cursor.execute(sql) # 파라미터 사용
column_names = [desc[0] for desc in cursor.description]
rows = cursor.fetchall() # 튜플의 리스트로 결과 가져오기
result_list = []
if rows:
for row_tuple in rows:
result_dict = dict(zip(column_names, row_tuple))
result_list.append(result_dict)
else:
logging.info("쿼리 결과가 없습니다.")
return result_list
except AirflowSkipException as e:
message = f"select_sql_to_dict() AirflowSkipException: {str(e)}"
logging.warning(message)
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"select_sql_to_dict() AirflowFailException: {str(e)}"
logging.error(message)
raise AirflowFailException(message)
except AirflowException as e: # 다른 Airflow 예외
message = f"select_sql_to_dict() AirflowException: {str(e)}"
logging.error(message)
raise AirflowException(message)
except Exception as e: # DB 연결 오류, SQL 문법 오류 등 포함
message = f"select_sql_to_dict() Exception: {type(e).__name__} - {str(e)}"
logging.error(message, exc_info=True) # Traceback 로깅
raise AirflowException(message) # AirflowException으로 래핑
finally:
if message:
logging.info(message)
@staticmethod
def insert_sql(conn: mysql.connector.connection.MySQLConnection, sql: str):
message = ""
try:
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
cursor.execute(sql)
result = cursor.rowcount
conn.commit()
return result
except AirflowSkipException as e:
message = f"insert_sql() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"insert_sql() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"insert_sql() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"insert_sql() Exception: {str(e)}"
if conn:
conn.rollback()
raise e
finally:
if message:
logging.info(message)
@staticmethod
def insert_sql_many(conn: mysql.connector.connection.MySQLConnection, sql: str, data_list: list):
result = -1
message = ""
try:
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
# Execute bulk insert
cursor.executemany(sql, data_list)
conn.commit()
result = cursor.rowcount
except AirflowSkipException as e:
message = f"insert_sql_many() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"insert_sql_many() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"insert_sql_many() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"insert_sql_many() Exception: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
finally:
if message:
logging.info(message)
return result
@staticmethod
def insert_sql_by_sql_list(conn: mysql.connector.connection.MySQLConnection, sql_list: list):
result = -1
message = ""
try:
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
for sql in sql_list:
try:
cursor.execute(sql)
except Exception as e:
logging.error(f"Error executing SQL: {sql}")
raise e
conn.commit()
except AirflowSkipException as e:
message = f"insert_sql_by_sql_list() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"insert_sql_by_sql_list() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"insert_sql_by_sql_list() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"insert_sql_by_sql_list() Exception: {str(e)}"
logging.error(message, exc_info=True)
if conn:
conn.rollback()
raise AirflowException(message)
finally:
if message:
logging.info(message)
return result
@staticmethod
def update_sql(conn: mysql.connector.connection.MySQLConnection, sql: str):
message = ""
try:
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
cursor.execute(sql)
result = cursor.rowcount
conn.commit()
return result
except AirflowSkipException as e:
message = f"update_sql() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"update_sql() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"update_sql() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"update_sql() Exception: {str(e)}"
if conn:
conn.rollback()
raise e
finally:
if message:
logging.info(message)
@staticmethod
def update_sql_many(conn: mysql.connector.connection.MySQLConnection, sql: str, data_list: list):
result = -1
message = ""
try:
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
# Execute bulk insert
cursor.executemany(sql, data_list)
conn.commit()
result = cursor.rowcount
except AirflowSkipException as e:
message = f"update_sql_many() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"update_sql_many() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"update_sql_many() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"update_sql_many() Exception: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
finally:
if message:
logging.info(message)
return result
@staticmethod
def update_sql_by_sql_list(conn: mysql.connector.connection.MySQLConnection, sql_list: list):
result = -1
message = ""
try:
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
for sql in sql_list:
cursor.execute(sql)
conn.commit()
except AirflowSkipException as e:
message = f"update_sql_many_only_query() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"update_sql_many_only_query() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"update_sql_many_only_query() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"update_sql_many_only_query() Exception: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
finally:
if message:
logging.info(message)
return result
@staticmethod
def transaction_sql(conn: mysql.connector.connection.MySQLConnection, sql_list):
message = ""
try:
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
for sql in sql_list:
cursor.execute(sql)
conn.commit()
except AirflowSkipException as e:
message = f"transaction_sql() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"transaction_sql() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"transaction_sql() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"transaction_sql() Exception: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
finally:
if message:
logging.info(message)
@staticmethod
def process_sql(conn: mysql.connector.connection.MySQLConnection, sql):
message = ""
try:
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
cursor.execute(sql)
conn.commit()
except AirflowSkipException as e:
message = f"process_sql() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"process_sql() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"process_sql() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"process_sql() Exception: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
finally:
if message:
logging.info(message)
@staticmethod
def procedure_sql(conn: mysql.connector.connection.MySQLConnection, procedure_name: str, params: tuple = None):
"""
저장 프로시저를 실행하고 결과를 반환합니다.
Args:
conn_id (str): 데이터베이스 연결 ID
procedure_name (str): 실행할 저장 프로시저 이름
params (tuple, optional): 프로시저 파라미터. 기본값은 None
Returns:
dict: 프로시저 실행 결과. 컬럼명을 키로 하는 딕셔너리 형태로 반환됨
"""
message = ""
try:
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
# 파라미터가 있는 경우 처리
if params:
param_values = []
for p in params:
if p is None:
param_values.append('NULL')
elif isinstance(p, (int, float)):
param_values.append(str(p))
else:
# 문자열 이스케이프 처리
escaped_p = str(p).replace("'", "''")
param_values.append(f"'{escaped_p}'")
# 프로시저 호출 SQL 생성
sql = f"CALL {procedure_name}({', '.join(param_values)})"
else:
# 파라미터가 없는 경우
sql = f"CALL {procedure_name}()"
logging.debug(f"Executing SQL: {sql}")
# 프로시저 실행
cursor.execute(sql)
# 결과 처리
result = None
if cursor.description:
columns = [col[0] for col in cursor.description]
row = cursor.fetchone()
if row:
result = dict(zip(columns, row))
logging.debug(f"Procedure result: {result}")
logging.info(f"Procedure executed. Affected rows (cursor.rowcount): {cursor.rowcount}")
while cursor.nextset():
pass
conn.commit()
return result
except AirflowSkipException as e:
message = f"procedure_sql() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"procedure_sql() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"procedure_sql() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"procedure_sql() Exception: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
finally:
if message:
logging.info(message)
@staticmethod
def procedure_sql_many(conn: mysql.connector.connection.MySQLConnection, procedure_name: str, data_set: list):
"""
저장 프로시저를 여러 실행하고 결과를 반환합니다.
Args:
conn_id (str): 데이터베이스 연결 ID
procedure_name (str): 실행할 저장 프로시저 이름
data_set (list): 프로시저 파라미터 리스트. 항목은 튜플이나 리스트 형태여야
Returns:
list: 프로시저 실행 결과 리스트. 결과는 딕셔너리 형태로 반환됨
"""
message = ""
try:
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
results = []
for params in data_set:
try:
# 파라미터 타입에 따른 SQL 쿼리 생성
param_values = []
for p in params:
if p is None:
param_values.append('NULL')
elif isinstance(p, (int, float)):
param_values.append(str(p))
else:
# 문자열 이스케이프 처리
escaped_p = str(p).replace("'", "''")
param_values.append(f"'{escaped_p}'")
# 프로시저 호출 SQL 생성
sql = f"CALL {procedure_name}({', '.join(param_values)})"
logging.debug(f"Executing SQL: {sql}")
# 프로시저 실행
cursor.execute(sql)
# 결과 처리
if cursor.description:
columns = [col[0] for col in cursor.description]
row = cursor.fetchone()
if row:
result = dict(zip(columns, row))
results.append(result)
logging.debug(f"Procedure result: {result}")
# 커서 초기화
while cursor.nextset():
pass
except Exception as e:
logging.error(f"Error executing procedure with params {params}: {str(e)}")
raise
conn.commit()
logging.info(f"Successfully executed {len(data_set)} procedure calls")
return results
except AirflowSkipException as e:
message = f"procedure_sql_many() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"procedure_sql_many() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"procedure_sql_many() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"procedure_sql_many() Exception: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
finally:
if message:
logging.info(message)

View File

@ -0,0 +1,533 @@
import inspect
import pymysql
import logging
import mysql.connector
from sqlalchemy.engine import Engine
from airflow.exceptions import AirflowException, AirflowSkipException, AirflowFailException
from airflow.providers.mysql.hooks.mysql import MySqlHook
from sqlalchemy.engine import Engine
TIMEZONE = "Asia/Seoul"
class CommonHookMySQL:
@staticmethod
def _set_timezone(cursor):
cursor.execute(f"SET time_zone = '{TIMEZONE}'")
@staticmethod
def select_sql(conn: mysql.connector.connection.MySQLConnection, sql: str):
try:
cursor = conn.cursor()
cursor.execute(sql)
result = cursor.fetchall()
if result is None:
raise AirflowFailException(f"select result is None")
return result
except AirflowSkipException as e:
message = f"select_user_data() AirflowSkipException: {str(e)}"
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"select_user_data() AirflowFailException: {str(e)}"
raise AirflowFailException(message)
except AirflowException as e:
message = f"select_user_data() AirflowException: {str(e)}"
raise AirflowException(message)
except Exception as e:
message = f"select_user_data() Exception: {str(e)}"
raise AirflowException(message)
@staticmethod
def select_sql_to_dict(conn_id: str, sql: str): # 파라미터 바인딩 지원 추가
"""
SQL 쿼리를 실행하고 결과를 딕셔너리의 리스트 형태로 반환합니다.
MySqlHook을 사용하며, DictCursor에 직접 의존하지 않습니다.
:param conn_id: Airflow MySQL Connection ID
:param sql: 실행할 SELECT SQL 쿼리 문자열
:param parameters: SQL 쿼리에 바인딩할 파라미터 (튜플 또는 리스트)
:return: 행이 딕셔너리인 리스트 (: [{'col1': val1, 'col2': val2}, ...])
:rtype: list[dict]
"""
conn = None
cursor = None
try:
db_hook = MySqlHook(mysql_conn_id=conn_id)
conn = db_hook.get_conn()
cursor = conn.cursor()
cursor.execute(sql) # 파라미터 사용
column_names = [desc[0] for desc in cursor.description]
rows = cursor.fetchall() # 튜플의 리스트로 결과 가져오기
result_list = []
if rows:
for row_tuple in rows:
result_dict = dict(zip(column_names, row_tuple))
result_list.append(result_dict)
else:
logging.info("쿼리 결과가 없습니다.")
return result_list
except AirflowSkipException as e:
message = f"select_sql_to_dict() AirflowSkipException: {str(e)}"
logging.warning(message)
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"select_sql_to_dict() AirflowFailException: {str(e)}"
logging.error(message)
raise AirflowFailException(message)
except AirflowException as e: # 다른 Airflow 예외
message = f"select_sql_to_dict() AirflowException: {str(e)}"
logging.error(message)
raise AirflowException(message)
except Exception as e: # DB 연결 오류, SQL 문법 오류 등 포함
message = f"select_sql_to_dict() Exception: {type(e).__name__} - {str(e)}"
logging.error(message, exc_info=True) # Traceback 로깅
raise AirflowException(message) # AirflowException으로 래핑
@staticmethod
def insert_sql(conn_id: str, sql: str):
try:
db_hook = MySqlHook(mysql_conn_id=conn_id)
conn = db_hook.get_conn()
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
cursor.execute(sql)
result = cursor.rowcount
conn.commit()
return result
except AirflowSkipException as e:
message = f"insert_sql() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"insert_sql() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"insert_sql() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
if conn:
conn.rollback()
raise e
@staticmethod
def insert_sql_many(conn_id: str, sql: str, data_list: list):
result = -1
conn = None
cursor = None
try:
db_hook = MySqlHook(mysql_conn_id=conn_id)
conn = db_hook.get_conn()
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
# Execute bulk insert
cursor.executemany(sql, data_list)
conn.commit()
result = cursor.rowcount
except AirflowSkipException as e:
message = f"insert_sql_many() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"insert_sql_many() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"insert_sql_many() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"insert_sql_many() Exception: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
finally:
return result
@staticmethod
def update_sql_many(conn_id: str, sql: str, data_list: list):
result = -1
conn = None
cursor = None
try:
db_hook = MySqlHook(mysql_conn_id=conn_id)
conn = db_hook.get_conn()
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
# Execute bulk insert
cursor.executemany(sql, data_list)
conn.commit()
result = cursor.rowcount
except AirflowSkipException as e:
message = f"update_sql_many() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"update_sql_many() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"update_sql_many() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"update_sql_many() Exception: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
finally:
return result
@staticmethod
def update_sql_by_sql_list(conn_id: str, sql_list: list):
result = -1
conn = None
cursor = None
try:
db_hook = MySqlHook(mysql_conn_id=conn_id)
conn = db_hook.get_conn()
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
for sql in sql_list:
cursor.execute(sql)
conn.commit()
except AirflowSkipException as e:
message = f"update_sql_many_only_query() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"update_sql_many_only_query() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"update_sql_many_only_query() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"update_sql_many_only_query() Exception: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
finally:
return result
@staticmethod
def transaction_sql(conn_id: str, sql_list):
conn = None
cursor = None
try:
db_hook = MySqlHook(mysql_conn_id=conn_id)
conn = db_hook.get_conn()
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
for sql in sql_list:
cursor.execute(sql)
conn.commit()
except AirflowSkipException as e:
message = f"transaction_sql() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"transaction_sql() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"transaction_sql() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"transaction_sql() Exception: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
@staticmethod
def process_sql(conn_id: str, sql):
conn = None
cursor = None
try:
db_hook = MySqlHook(mysql_conn_id=conn_id)
conn = db_hook.get_conn()
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
cursor.execute(sql)
conn.commit()
except AirflowSkipException as e:
message = f"process_sql() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"process_sql() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"process_sql() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"process_sql() Exception: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
@staticmethod
def procedure_sql(conn_id: str, procedure_name: str, params: tuple = None):
"""
저장 프로시저를 실행하고 결과를 반환합니다.
Args:
conn_id (str): 데이터베이스 연결 ID
procedure_name (str): 실행할 저장 프로시저 이름
params (tuple, optional): 프로시저 파라미터. 기본값은 None
Returns:
dict: 프로시저 실행 결과. 컬럼명을 키로 하는 딕셔너리 형태로 반환됨
"""
conn = None
cursor = None
try:
db_hook = MySqlHook(mysql_conn_id=conn_id)
conn = db_hook.get_conn()
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
# 파라미터가 있는 경우 처리
if params:
param_values = []
for p in params:
if p is None:
param_values.append('NULL')
elif isinstance(p, (int, float)):
param_values.append(str(p))
else:
# 문자열 이스케이프 처리
escaped_p = str(p).replace("'", "''")
param_values.append(f"'{escaped_p}'")
# 프로시저 호출 SQL 생성
sql = f"CALL {procedure_name}({', '.join(param_values)})"
else:
# 파라미터가 없는 경우
sql = f"CALL {procedure_name}()"
logging.debug(f"Executing SQL: {sql}")
# 프로시저 실행
cursor.execute(sql)
# 결과 처리
result = None
if cursor.description:
columns = [col[0] for col in cursor.description]
row = cursor.fetchone()
if row:
result = dict(zip(columns, row))
logging.debug(f"Procedure result: {result}")
logging.info(f"Procedure executed. Affected rows (cursor.rowcount): {cursor.rowcount}")
while cursor.nextset():
pass
conn.commit()
return result
except AirflowSkipException as e:
message = f"procedure_sql() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"procedure_sql() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"procedure_sql() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"procedure_sql() Exception: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
finally:
if cursor:
cursor.close()
if conn:
conn.close()
@staticmethod
def procedure_sql_many(conn_id: str, procedure_name: str, data_set: list):
"""
저장 프로시저를 여러 실행하고 결과를 반환합니다.
Args:
conn_id (str): 데이터베이스 연결 ID
procedure_name (str): 실행할 저장 프로시저 이름
data_set (list): 프로시저 파라미터 리스트. 항목은 튜플이나 리스트 형태여야
Returns:
list: 프로시저 실행 결과 리스트. 결과는 딕셔너리 형태로 반환됨
"""
conn = None
cursor = None
try:
db_hook = MySqlHook(mysql_conn_id=conn_id)
conn = db_hook.get_conn()
cursor = conn.cursor()
CommonHookMySQL._set_timezone(cursor)
results = []
for params in data_set:
try:
# 파라미터 타입에 따른 SQL 쿼리 생성
param_values = []
for p in params:
if p is None:
param_values.append('NULL')
elif isinstance(p, (int, float)):
param_values.append(str(p))
else:
# 문자열 이스케이프 처리
escaped_p = str(p).replace("'", "''")
param_values.append(f"'{escaped_p}'")
# 프로시저 호출 SQL 생성
sql = f"CALL {procedure_name}({', '.join(param_values)})"
logging.debug(f"Executing SQL: {sql}")
# 프로시저 실행
cursor.execute(sql)
# 결과 처리
if cursor.description:
columns = [col[0] for col in cursor.description]
row = cursor.fetchone()
if row:
result = dict(zip(columns, row))
results.append(result)
logging.debug(f"Procedure result: {result}")
# 커서 초기화
while cursor.nextset():
pass
except Exception as e:
logging.error(f"Error executing procedure with params {params}: {str(e)}")
raise
conn.commit()
logging.info(f"Successfully executed {len(data_set)} procedure calls")
return results
except AirflowSkipException as e:
message = f"procedure_sql_many() AirflowSkipException: {str(e)}"
if conn:
conn.rollback()
raise AirflowSkipException(message)
except AirflowFailException as e:
message = f"procedure_sql_many() AirflowFailException: {str(e)}"
if conn:
conn.rollback()
raise AirflowFailException(message)
except AirflowException as e:
message = f"procedure_sql_many() AirflowException: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
except Exception as e:
message = f"procedure_sql_many() Exception: {str(e)}"
if conn:
conn.rollback()
raise AirflowException(message)
finally:
if cursor:
cursor.close()
if conn:
conn.close()

View File

@ -0,0 +1,22 @@
import pendulum
from datetime import datetime, timezone, timedelta
TIMEZONE_KST = timezone(timedelta(hours=9))
class CommonDatetime:
@staticmethod
def make_kst_timezone():
return pendulum.timezone("Asia/Seoul")
@staticmethod
def make_datetime_kst_from_ts(ts: str) -> datetime:
return datetime.fromisoformat(ts).astimezone(TIMEZONE_KST)
# DATETIME으로 입력받고 한국 시간적용된 시간으로 리턴턴
@staticmethod
def make_datetime_kst_from_datetime(dt: datetime) -> datetime:
return dt.astimezone(TIMEZONE_KST)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,204 @@
syscollectItem = {
"dcode": "",
"whereis": "",
"dkey": "",
"dkey_ext": "",
"stats": "",
"rebid_no": "",
"link": "",
"link_post": "",
"ext_info1": "",
"ext_info2": "",
"ext_info3": "",
"rs_bidid": "",
"rs_msg": "",
"proc": "",
"set_time": "",
"seq": "",
"retrycnt": "",
"uptime": "",
"crawl_data_type": "",
"crawl_data_view": "",
"crawl_data_html": "",
}
bidkeyItem = {
"bidid": "",
"whereis": "",
"syscode": "",
"bidtype": "",
"bidview": "",
"notinum": "",
"notinum_ex": "",
"constnm": "",
"org": "",
"org_i": "",
"orgcode_i": "",
"org_y": "",
"orgcode_y": "",
"bidproc": "",
"contract": "",
"bidcls": "",
"succls": "",
"conlevel": "",
"concode": "",
"sercode": "",
"purcode": "",
"location": "",
"convention": "",
"presum": "",
"basic": "",
"pct": "",
"opt": "",
"noticedt": "",
"registdt": "",
"explaindt": "",
"agreedt": "",
"opendt": "",
"closedt": "",
"constdt": "",
"writedt": "",
"pqdt": "",
"docdt": "",
"resdt": "",
"editdt": "",
"inputer": "",
"inspecter": "",
"state": "",
"no": "",
"lock": "",
"isclosed": "",
"org_real": "",
"nbidcls": "",
"breakdown_bid_check": "",
"local_join_check": "",
"first_join_part": "",
"first_join_part_prevamt": "",
"first_join_part_presum": "",
"assessment_percent": "",
"plus_local": "",
"safebid": "",
"etc_option": "",
"state_a": "",
"blocal": "",
"big_part": "",
"auto_service_code": "",
}
bidvalueItem = {
"bidid": "",
"scrcls": "",
"scrid": "",
"constno": "",
"refno": "",
"realorg": "",
"yegatype": "",
"yegarng": "",
"prevamt": "",
"multispare": "",
"parbasic": "",
"lvcnt": "",
"contloc": "",
"contper": "",
"charger": "",
"exptax": "",
"contract_money": "",
"government_money": "",
"promise_org": "",
"c_exp_cost": "",
"pre_cost": "",
"industry_code": "",
"work_field": "",
"result_check_dt": "",
"explain_local": "",
"join_part_total_percent": "",
"ser_check": "",
"ser_join_agree_check": "",
"compute": "",
"jk_info_service": "",
"mutual_mayor": "",
"bid_statement_text": "",
}
bidcontentItem = {
"bidid": "",
"bidcomment_mod": "",
"bidcomment": "",
"nbidcomment": "",
"orign_lnk": "",
"s_orign_lnk": "",
"attchd_lnk": "",
"pur_lnk": "",
"bid_html": "",
"nbid_html": "",
"bid_file": "",
"nbid_file": "",
"pur_goods": "",
"partition_seq": "",
}
bidresItem = {
"bidid": "",
"yega": "",
"selms": "",
"multicnt": "",
"multispare": "",
"innum": "",
"officenm1": "",
"prenm1": "",
"officeno1": "",
"success1": "",
"reswdt": "",
"mult_yega_fg": "",
}
bidsuccomItem = {
"bidid": "",
"seq": "",
"officeno": "",
"officenm": "",
"prenm": "",
"success": "",
"pct": "",
"regdt": "",
"rank": "",
"selms": "",
"etc": "",
"partition_seq": "",
}
bidlocalItem = {
"bidid": "",
"code": "",
"name": "",
}
bidgoodsItem = {
"bidid": "",
"seq": "",
"gcode": "",
"gname": "",
"gorg": "",
"standard": "",
"cnt": "",
"unit": "",
"unitcost": "",
"period": "",
"place": "",
"condition": "",
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,154 @@
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
import logging
import os
import time
from airflow.exceptions import AirflowException
from airflow.models import Variable
from airflow.providers.mysql.hooks.mysql import MySqlHook
from plugins.sql.hooks.mysql_hook import CommonHookMySQL
class SetupUtils:
# DB 연결 ID
DB_CONN_ID = "COLLECT_SERVER_DB"
# 나라장터 메인 URL
G2B_MAIN_URL = Variable.get("G2B_MAIN_URL", '')
# 크롬 드라이버 경로
CHROMEDRIVER_EXECUTABLE_PATH = Variable.get("CHROMEDRIVER_EXECUTABLE_PATH", '/usr/local/bin/chromedriver')
def __init__(self):
self.driver = None
self._all_cookies = {}
self.CommonHookMySQL = CommonHookMySQL()
logging.info(f"=============== MySQL 연결 시작 ================")
self.db_hook = MySqlHook(mysql_conn_id=self.DB_CONN_ID)
self.conn = self.db_hook.get_conn()
logging.info(f"===============================================")
# 소멸자
def __del__(self):
logging.info(f"=============== MySQL 연결 종료 ================")
self.conn.close()
logging.info(f"===============================================")
# Selenium 드라이버 초기화
def _setup_selenium(self):
logging.info("Selenium Chrome 드라이버 초기화 중...")
chrome_options = Options()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument('--disable-gpu')
chrome_options.add_argument('--window-size=1920,1080')
chrome_options.add_argument('user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36')
logging.info(f"사용할 ChromeDriver 경로: {self.CHROMEDRIVER_EXECUTABLE_PATH}")
if not os.path.exists(self.CHROMEDRIVER_EXECUTABLE_PATH):
error_msg = f"ChromeDriver 실행 파일이 지정된 경로에 없습니다: {self.CHROMEDRIVER_EXECUTABLE_PATH}. " \
"Airflow Variable 'CHROMEDRIVER_EXECUTABLE_PATH' 설정을 확인하거나, " \
"Airflow 워커 이미지에 해당 경로로 ChromeDriver가 설치되었는지 확인해주세요."
logging.error(error_msg)
raise FileNotFoundError(error_msg)
if not os.access(self.CHROMEDRIVER_EXECUTABLE_PATH, os.X_OK):
error_msg = f"ChromeDriver 실행 파일에 실행 권한이 없습니다: {self.CHROMEDRIVER_EXECUTABLE_PATH}. " \
"파일에 실행 권한(chmod +x)을 부여해주세요."
logging.error(error_msg)
raise PermissionError(error_msg)
try:
service = Service(executable_path=self.CHROMEDRIVER_EXECUTABLE_PATH)
self.driver = webdriver.Chrome(
service=service,
options=chrome_options
)
logging.info("Selenium Chrome 드라이버가 성공적으로 초기화되었습니다.")
except Exception as e:
logging.error(f"Selenium Chrome 드라이버 초기화 중 심각한 오류 발생: {e}")
logging.error(f"ChromeDriver 경로: {self.CHROMEDRIVER_EXECUTABLE_PATH}, 사용된 옵션: {chrome_options.arguments}")
raise AirflowException(f"Selenium 드라이버 초기화 실패: {e}")
# =======================================================================================================================================
# 크롬 드라이버 닫기
def _close_driver(self):
try:
if self.driver:
self.driver.quit()
logging.info("Selenium 드라이버가 안전하게 종료되었습니다.")
self.driver = None
else:
logging.warning("Selenium 드라이버가 이미 종료되었거나 초기화되지 않았습니다.")
except Exception as e:
logging.error(f"Selenium 드라이버 종료 중 오류 발생: {e}")
raise
# =======================================================================================================================================
# 쿠키 가져오기
def _get_cookies(self):
main_page_url = self.G2B_MAIN_URL
try:
self._setup_selenium()
logging.info(f"{main_page_url} 에 접속하여 쿠키를 가져오고 있습니다...")
self.driver.get(main_page_url)
time.sleep(5) # 페이지 로딩 및 JS 실행 시간 고려
cookies = self.driver.get_cookies()
# 모든 쿠키를 딕셔너리에 저장
self._all_cookies = {cookie['name']: cookie['value'] for cookie in cookies}
logging.info(f"Selenium을 사용하여 모든 쿠키를 가져왔습니다: {self._all_cookies}")
# 최소한 필요한 쿠키(JSESSIONID, XTVID 등)가 있는지 확인 (선택 사항이지만 권장)
if not self._all_cookies.get('JSESSIONID') or not self._all_cookies.get('XTVID'):
logging.warning("필요한 쿠키(JSESSIONID or XTVID)가 없을 수 있습니다.")
self._close_driver()
except Exception as e:
logging.error(f"Selenium을 사용하여 쿠키를 가져오는 중 오류가 발생했습니다: {e}")
raise
# =======================================================================================================================================
# 코드 데이터 DB UPSERT
def common_code_db_upsert(self, code_data):
try:
query = f"""
INSERT INTO common_code (group_id, code_key, code_value, update_dt)
VALUES ('{code_data['group_id']}', '{code_data['code_key']}', '{code_data['code_value']}', NOW())
ON DUPLICATE KEY UPDATE
group_id = '{code_data['group_id']}', code_key = '{code_data['code_key']}', code_value = '{code_data['code_value']}', update_dt = NOW();
"""
self.CommonHookMySQL.process_sql(self.conn, query)
except Exception as e:
logging.error(f"쿼리: {query}")
logging.error(f"코드 데이터 DB UPSERT 중 오류가 발생했습니다: {e}")
raise
# =======================================================================================================================================
# 코드 데이터 select
def common_code_db_select(self, group_id, code_key):
try:
query = f"""
SELECT code_value FROM common_code WHERE group_id = '{group_id}' AND code_key = '{code_key}'
"""
result = self.CommonHookMySQL.select_sql(self.conn, query)
return result[0][0]
except Exception as e:
logging.error(f"쿼리: {query}")
logging.error(f"코드 데이터 DB select 중 오류가 발생했습니다: {e}")
raise

View File

@ -0,0 +1,15 @@
class StatusEnum:
@staticmethod
def status_enum(status: str):
if status == 'WAIT':
return 1
elif status == 'RUNNING':
return 2
elif status == 'FAIL':
return 3
elif status == 'SUCCESS':
return 4
else:
return 0

View File

@ -0,0 +1,153 @@
import uuid
import random
import urllib
import html.entities
class AttchdLnk:
# make_guid 함수 정의
def make_guid(self, a=None):
if hasattr(uuid, 'uuid4'):
c = str(uuid.uuid4()).replace("-", "")
else:
c = ''.join([hex(random.randint(0, 65535))[2:] for _ in range(8)])
if a is not None:
c = "{}-{}".format(a, c)
return c
# 문자열 삽입 함수
def insertAt(self, a, c, b):
return a[:c] + b + a[c:]
# UTF-8 인코딩 함수
def utf8_encode(self, b):
b = b.replace('\r\n', '\n')
c = ""
for a in range(len(b)):
d = ord(b[a])
if d < 128:
c += chr(d)
else:
if 127 < d < 2048:
c += chr((d >> 6) | 192)
else:
c += chr((d >> 12) | 224)
c += chr(((d >> 6) & 63) | 128)
c += chr((d & 63) | 128)
return c
# Base64 인코딩 함수
def base64_encode(self, a):
keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
c = ""
l = 0
a = self.utf8_encode(a)
while l < len(a):
b = ord(a[l])
l += 1
if l < len(a):
e = ord(a[l])
l += 1
else:
e = None
if l < len(a):
f = ord(a[l])
l += 1
else:
f = None
d = b >> 2
if e is not None:
b_new = ((b & 3) << 4) | (e >> 4)
if f is not None:
h = ((e & 15) << 2) | (f >> 6)
k = f & 63
else:
h = ((e & 15) << 2)
k = 64 # Padding index for '='
else:
b_new = ((b & 3) << 4)
h = k = 64 # Padding indices for '='
c += keyStr[d]
c += keyStr[b_new]
c += keyStr[h]
c += keyStr[k]
return c
# 암호화 파라미터 생성 함수
def makeEncryptParam(self, a):
#a = self.base64_encode(self.utf8_encode(a))
# 중복 사용이어서 수정
a = self.base64_encode(a)
if len(a) >= 10:
a = self.insertAt(a, 8, "r")
a = self.insertAt(a, 6, "a")
a = self.insertAt(a, 9, "o")
a = self.insertAt(a, 7, "n")
a = self.insertAt(a, 8, "w")
a = self.insertAt(a, 6, "i")
a = self.insertAt(a, 9, "z")
else:
a = self.insertAt(a, len(a) - 1, "$")
a = self.insertAt(a, 0, "$")
a = a.replace('+', '%2B')
return a
# 최종 암호화 파라미터 함수
def makeEncryptParamFinal(self, a, c):
b = {
'name': "",
'value': ""
}
e = "1"
f = "1"
d = "2018.1548512.1555.33"
if "1" >= e:
if f == "0":
b['name'] = "k10"
b['value'] = self.makeEncryptParam(a) if c and c == 1 else d + self.makeEncryptParam(a)
else:
b['name'] = "k00"
b['value'] = self.makeEncryptParam(a)
return b
def attchd_lnk_set(self, attchd_lnk_data):
# 데이터 없을 경우 데이터 반환하지 않도록 처리 필요
# 첨부파일 여러개이면 여러개 데이터 세팅하도록 처리 필요
#print("attchd_lnk_data > ", attchd_lnk_data)
returnData = ""
orgnlAtchFileNm = html.unescape(urllib.parse.unquote(attchd_lnk_data['orgnlAtchFileNm']))
atchFilePathNm = html.unescape(urllib.parse.unquote(attchd_lnk_data['atchFilePathNm']))
#print("orgnlAtchFileNm > ", orgnlAtchFileNm)
#print("atchFilePathNm > ", atchFilePathNm)
file_set_data = "kc\fc11\u000b"
file_set_data += "k01\f1\u000b"
file_set_data += "k12\f{}\u000b".format(self.make_guid())
file_set_data += "k26\f{}\u000b".format(atchFilePathNm)
file_set_data += "k31\f{}\u000b".format(orgnlAtchFileNm)
file_set_data += "k21\f{},{}\u000b".format(attchd_lnk_data['untyAtchFileNo'], attchd_lnk_data['atchFileSqno'])
#print("file_set_data > ", file_set_data)
# 마지막 수직 탭 제거 (원하지 않으면 생략 가능)
# file_set_data = file_set_data.rstrip("\u000b")
# print("file_set_data 수직 탭 제거> ", file_set_data)
# Now, process a using makeEncryptParamFinal
file_set_data_result = self.makeEncryptParamFinal(file_set_data, c=1)
file_set_data_name = file_set_data_result.get('name')
file_set_data_value = file_set_data_result.get('value')
returnData = "{orgnlAtchFileNm}#=====#https://www.g2b.go.kr/fs/fsc/fsca/fileUpload.do?{file_set_data_name}={file_set_data_value}".format(orgnlAtchFileNm=orgnlAtchFileNm, file_set_data_name=file_set_data_name, file_set_data_value=file_set_data_value)
#print("file_set_data_result > ", file_set_data_result)
#print("https://www.g2b.go.kr/fs/fsc/fsca/fileUpload.do?" + file_set_data_name + "=")
#print("file_set_data_value > ", file_set_data_value)
return returnData

View File

@ -0,0 +1,240 @@
import plugins.utils.scraplib as scraplib #공통 라이브러리
from plugins.utils.transformers.transformer_helper import TransformerHelper
from plugins.utils.transformers.attchd_lnk import AttchdLnk
from bs4 import BeautifulSoup
class BidContentTransformer(TransformerHelper):
# todo 진행중
# 자격조건
def bidcomment(self, api_data, param):
bidcomment = ""
if param["bidtype"] == "견적":
bidcomment = "공고원문을 참고하시기 바랍니다.\n\n"
else:
if param["pqdt"] is not None and param["pqdt"] != "":
bidcomment += "PQ심사신청서 신청기한 : " + param["pqdt"] + " \n\n&nbsp;"
if param["jkdt"] is not None and param["jkdt"] != "":
bidcomment += "적격성심사신청서 신청기한 : " + param["jkdt"] + " \n\n&nbsp;"
# 입찰보증서접수 마감일시
guarantee = self.extract_value(api_data, "dmItemMap", "bidDepoPayTermDt")
if guarantee is not None: # 보증서접수마감일시 있는경우 추가
bidcomment += "보증서접수마감일시 : {guarantee}\n보증서 접수마감일시를 입력하지 않은 경우에는, 입찰서 접수마감일 전일 18시까지 제출이 가능합니다.\n" \
"(단, 입찰보증금지급각서로 대체하는 경우 보증금이 면제됩니다.)\n\n&nbsp;".format(guarantee=guarantee.replace("/", "-"))
if "notice_memo" in param["item_bidnoticememo"]: # 공지사항이 있는경우 추가
bidcomment += param["notice_memo"] # 첨부파일까지 넣으려면 item_bidnoticememo["notice_memo"] 를 넣으면 됨
# 첨부파일 없을 때, 해당 문구 뜰 수 있게 적용
if not param['attchd_lnk']:
bidcomment += " \n [* 공고 원문을 참조하시기 바랍니다. *]"
return bidcomment
# todo 진행중
# 공고변경사유
def bidcomment_mod(self, noticeClCd, bidcomment_mod_notice_memo):
bidcomment_mod = None
if noticeClCd != "" and bidcomment_mod_notice_memo is not None: # 공지사항이 있고, 취소 또는 정정일 경우
bidcomment_mod = self.Etl.clean_page_source(bidcomment_mod_notice_memo)
return bidcomment_mod
# todo 진행중
# 공고문첨부파일
def attchd_lnk(self, json_res_file):
file_params = self.extract_value(json_res_file, "dlUntyAtchFileL")
if file_params is None:
# print("첨부파일 없음")
return None
make_attchd_lnk = AttchdLnk()
file_list = []
for item in file_params:
attchd_lnk_data = {
"untyAtchFileNo": self.extract_value(item, "untyAtchFileNo", default=""),
"atchFileSqno": self.extract_value(item, "atchFileSqno", default=""),
"orgnlAtchFileNm": self.extract_value(item, "orgnlAtchFileNm", default=""),
"atchFilePathNm": self.extract_value(item, "atchFilePathNm", default="")
}
file_list.append(make_attchd_lnk.attchd_lnk_set(attchd_lnk_data))
return "|~~~~~|".join(file_list)
"""
print("bidtype :", bidtype)
if bidtype == "견적":
attchd_lnk_nm = driver.rtn_elements("//caption[.='첨부문서']/parent::table/tbody/tr/td[@class='tl'][2]", "XPATH", "")
#print("attchd_lnk_nm :", attchd_lnk_nm)
attchd_lnk_href = list(map(lambda x: x.replace("javascript:toFile('", "").replace("');", "").replace(" ", "").split("','"), driver.rtn_elements("//caption[.='첨부문서']/parent::table/tbody/tr/td[@class='tl'][2]/a", "XPATH", "href")))
attchd_lnk_href = list(map(lambda x: x[0] if len(x) < 2 else "https://www.g2b.go.kr:8402/gtob/all/pr/estimate/fileDownloadG2B.do?estmtReqNo={estmtReqNo},{_idx}".format(estmtReqNo=x[0], _idx=x[1]), attchd_lnk_href))
#print("attchd_lnk_href :", attchd_lnk_href)
elif bidtype == "기타":
attchd_lnk_nm = driver.rtn_elements("//caption[.='공고서 테이블']/parent::table/tbody/tr/td/a", "XPATH", "")
attchd_lnk_href = list(map(lambda x: x.replace("javascript:otherBidDtl_fileDownload('", "").replace("');", "").replace(" ", ""), driver.rtn_elements("//caption[.='공고서 테이블']/parent::table/tbody/tr/td/a", "XPATH", "href")))
attchd_lnk_href = list(map(lambda x: "https://www.g2b.go.kr:8101/ep/tbid/downloadOtherBidAttafile.do?path=&fileName=&bidattaFileNo={bidattaFileNo}&tbidno={tbidno}&bidseq={bidseq}".format(bidattaFileNo=x, tbidno=PARAMS["tbidno"][0], bidseq=PARAMS["bidseq"][0]), attchd_lnk_href))
elif len(driver.rtn_elements("//iframe[@title='혁신장터 첨부파일']", "XPATH", ""))>0 :#혁신장터
driver.switch_to_frame("//iframe[@title='혁신장터 첨부파일']")
attchd_lnk_nm = driver.rtn_elements("//tbody/tr/td[@class='left retTD']//em", "XPATH", "")
attchd_lnk_href = list(map(lambda x: x.replace("fileModule.download('", "").replace("')", "").replace(" ", "").split("','"), driver.rtn_elements("//tbody/tr/td[@class='left retTD']//a[contains(@onclick,'fileModule')]", "XPATH", "onclick")))
attchd_lnk_href = list(map(lambda x: "https://ppi.g2b.go.kr:8914/cmmn/atachFile/downloadPlainFile.do?untyAtchmnflNo={untyAtchmnflNo}&atchmnflSno={atchmnflSno}".format(untyAtchmnflNo=x[0], atchmnflSno=x[1]), attchd_lnk_href))
driver.switch_to_default_frame()
else:
attchd_lnk_nm = driver.rtn_elements("//caption[.='업로드 된 파일 정보']/parent::table/tbody/tr/td[@class='tl']//a[contains(@href,'dtl_fileDownload')]", "XPATH", "")
attchd_lnk_href = list(map(lambda x: x.replace("javascript:dtl_fileDownload('", "").replace("');", "").replace(" ", "").split("','"), driver.rtn_elements("//caption[.='업로드 된 파일 정보']/parent::table/tbody/tr/td[@class='tl']//a[contains(@href,'dtl_fileDownload')]", "XPATH", "href")))
attchd_lnk_href = list(map(lambda x: "https://www.g2b.go.kr:8081/ep/co/fileDownload.do?fileTask=NOTIFY&fileSeq={fileSeq}".format(fileSeq=x[0]), attchd_lnk_href))
attchd_lnk_nm2 = driver.rtn_elements("//caption[.='E발주 제안요청서 첨부파일']/parent::table/tbody/tr/td[@class='tl']//a[contains(@href,'eeOrderAttachFileDownload')]","XPATH", "")
attchd_lnk_href2 = list(map(lambda x: x.replace("javascript:eeOrderAttachFileDownload('", "").replace("');", "").replace(" ", "").split("','"), driver.rtn_elements("//caption[.='E발주 제안요청서 첨부파일']/parent::table/tbody/tr/td[@class='tl']//a[contains(@href,'eeOrderAttachFileDownload')]","XPATH", "href")))
attchd_lnk_href2 = list(map(lambda x: "https://rfp.g2b.go.kr:8426/cmm/FileDownload.do?atchFileId={fileSeq1}&fileSn={fileSeq2}".format(fileSeq1=x[1], fileSeq2=x[2]), attchd_lnk_href2))
attchd_lnk_nm = attchd_lnk_nm + attchd_lnk_nm2
attchd_lnk_href = attchd_lnk_href + attchd_lnk_href2
#print("attchd_lnk_nm :", attchd_lnk_nm)
#print("attchd_lnk_href :", attchd_lnk_href)
item_bidcontent['attchd_lnk'] = "|~~~~~|".join(list(map(lambda x, y: x + "#=====#" + y, attchd_lnk_nm, attchd_lnk_href)))
#print("attchd_lnk_str :", item_bidcontent['attchd_lnk'])
return ""
"""
# todo 진행중
# 입찰상세내용(공고문)
def bid_html(self, bidtype, bidproc, bidcls, whereis):
# todo None이면 암것도 안하고 ''이면 공백으로 덮어 쓰는듯??
if bidproc == 'M':
bid_html = '' # 정정공고일경우 전차수 bid_html 초기화
else:
bid_html = None
# todo 민간 견적공고 공고문 만들기
# 공고 시스템 수집
# 공사/용역 이면서 전자시담
# 민간공고 이면서 취소건
# 견적공고
#if (bidcls == '06' and whereis == '01') or (item_bidkey['bidproc'] == 'C' and whereis == '84') or whereis == '85':
if (item_bidkey['bidproc'] == 'C' and whereis == '84') or whereis == '85':
bid_source = page_source.split('\n')
html_tmp = ""
html_check = False
for i in bid_source:
#print(i)
i = self.Etl.clean_page_source(i)
if whereis == '01' or whereis == '84':
if i.find('<h3>%[공고일반') != -1 or i.find('<h3>[공고일반') != -1:
html_check = True
if i.find('<h3>%[첨부 파일') != -1 or i.find('<h3>[첨부 파일') != -1:
html_check = False
if whereis == '85':
if i.find('infoleft') != -1:
print("3")
html_check = True
if i.find('button_wrap') != -1:
html_check = False
print("4")
if html_check == True:
print(i)
html_sub_check = True
if i.find('class=hide') != -1 or i.find('<caption') != -1:
html_sub_check = False
if i.find("<table") != -1:
i = self.Util.clean_str(i, [["<table ", "<TABLE border='1' width='100%%' cellpadding='5' cellspacing='0' "]])
if i.find("<h3>") != -1:
i = self.Util.clean_str(i, [["<h3>","<BR><BR><H3>"]])
if i.find("<th>") != -1:
i = self.Util.clean_str(i, [["<th>","<TH style='background-color:#ebf3fe'>"]])
if i.find("<th ") != -1:
i = self.Util.clean_str(i, [["<th ","<TH style='background-color:#ebf3fe' "]])
if html_sub_check == True:
html_tmp = html_tmp + i
html_tmp = html_tmp + "<br/><br/><b>**공고원문참조**<b/>" if whereis == '85' else html_tmp
bid_html = html_tmp + "<br><br>"
if bidtype == 'pur':
try:
bid_html = self.get_pageSource_to_html(page_source)
except :
bid_html = ""
return bid_html
#첨부파일
def attchd_file_no(self, api_data):
if self.tran_bidtype == "기타" or self.tran_bidtype == "민간":
attchd_file_no = self.extract_value(api_data, "dmItemMap", "untyAtchFileNo")
else:
attchd_file_no = self.extract_value(api_data, "dmItemMap", "itemPbancUntyAtchFileNo")
if attchd_file_no is None:
# print("첨부파일 번호 없음")
return None
return attchd_file_no
##########################################################################################
def get_pageSource_to_html(self, page_source):
html_list = []
soup_html = BeautifulSoup(page_source.replace("<br>", " "), 'html.parser')
element_list = soup_html.select("#container > div")
if len(element_list) == 0 :
element_list = soup_html.select("#container > #resultForm > div")
# 불필요한 요소 우선삭제 처리
decompose_dict = {"div": {'class': ['description']},
"img": {},
"p": {'class': ['hide']},
"caption": {},
"td": {'class': ['tline']},
}
for tag_name, attr_dict in decompose_dict.items():
for element_tag in soup_html.find_all(tag_name, attrs=attr_dict):
element_tag.decompose()
# CSS를 위해 class 추가
# { "HTML 태그" : { "attr" : attr_dict } }, }
classAdd_dict = {
"div": {"attr": {'class': ['results', 'section']}, "addClass": ['ij_table', 'type1', 'border']},
"h3": {"attr": {}, "addClass": ['mt-5']},
}
for tag_name, attr_dict in classAdd_dict.items():
for element_tag in soup_html.find_all(tag_name, attrs=attr_dict['attr']):
element_tag['class'] = " ".join(attr_dict['addClass'])
for idx, element in enumerate(element_list, 0):
# select된 HTML tag의 Class 추출하여 타이틀 판단 후, 포함할지에 대한 판단
try:
element_class = element.get("class")
except:
element_class = None
# 서브타이틀 항목 텍스트 우선추출
if element_class is not None and "infoleft" in element_class:
element_text = element.find("h3")
if element_text.text.strip() not in ['[첨부 파일 (e-발주시스템)]', "[나의공고 관리 ]"]:
html_list.append(str(element_list[idx]))
html_list.append(str(element_list[idx + 1]))
if "inforight" in element_list[idx + 1].get("class"):
html_list.append(str(element_list[idx + 2]))
html_source = ""
try:
html_source = self.Util.clean_str( "".join(html_list) ).replace("\"","'")
except:
html_source = ""
return html_source

View File

@ -0,0 +1,87 @@
import re
from plugins.utils.transformers.transformer_helper import TransformerHelper
class BidGoodsTransformer(TransformerHelper):
# 구매물품리스트 (구매대상물품)
def bid_goods(self, api_data):
item_list = self.extract_value(api_data, "dmItemItemList")
if item_list is None:
# print("구매대상물품 없음")
return {}
bidgoods = {}
for value in item_list:
# 분류 번호
item_no = self.extract_value(value, "bidClsfNo")
# 리스트로 넣기 위해 초기화 (if조건 없으면 계속 빈값됨)
if item_no not in bidgoods:
bidgoods[item_no] = {}
list_idx = len(bidgoods[item_no])+1
item_row = {
"seq" : list_idx,
"gcode" : self.extract_value(value, "dtlsPrnmNo", default=""), # 세부품명번호
"gname" : self.extract_value(value, "dtlsPrnmNm", default=""), # 세부품명
"gorg" : self.extract_value(value, "dmstUntyGrpNm", default=""), # 수요기관
"standard" : self.Etl.safe_encode(self.extract_value(value, "itemDtlSpecCn", default="")), # 규격
"cnt" : self.extract_value(value, "prchsDtlItemQty", default=""), # 수량
"unit" : self.extract_value(value, "prchsDtlItemUntValNm", default=""), # 단위
"unitcost" : self.extract_value(value, "prchsDtlItemUprc", default=""), # 추정단가
"period" : self.extract_value(value, "dlvgdsTermDt", default=""), # 납품기한
"place" : self.Etl.safe_encode(self.extract_value(value, "dlvgdsPlacNm", default="")), # 남품장소
"condition" : self.extract_value(value, "devyCndtNm", default=""), # 인도조건
}
bidgoods[item_no][len(bidgoods[item_no])] = item_row
return bidgoods
# MAS 구매물품리스트 (구매대상물품)
def bid_goods_mas(self, api_data):
item_list = self.extract_value(api_data, "dlPbancItemL")
if item_list is None:
# print("구매대상물품 없음")
return {}
bidgoods = {}
for value in item_list:
# 분류 번호
item_no = self.extract_value(value, "bidClsfNo")
# 리스트로 넣기 위해 초기화 (if조건 없으면 계속 빈값됨)
if item_no not in bidgoods:
bidgoods[item_no] = {}
list_idx = len(bidgoods[item_no]) + 1
item_row = {
"seq": list_idx,
"gcode": self.extract_value(value, "dtlsPrnmNo", default=""), # 세부품명번호
"gname": self.extract_value(value, "dtlsPrnmNm", default=""), # 세부품명
"gorg": self.extract_value(value, "dmstUntyGrpNm", default=""), # 수요기관
"standard": self.Etl.safe_encode(self.extract_value(value, "itemDtlSpecCn", default="")), # 규격
"cnt": self.extract_value(value, "prchsDtlItemQty", default=""), # 수량
"unit": self.extract_value(value, "prchsDtlItemUntVal", default=""), # 단위
"unitcost": self.extract_value(value, "prchsDtlItemUprc", default=""), # 추정단가
"period": self.extract_value(value, "dlvgdsTermDt", default=""), # 납품기한
"place": self.extract_value(value, "dlvgdsPlacNm", default=""), # 남품장소
"condition": self.extract_value(value, "devyCndtNm", default=""), # 인도조건
}
bidgoods[item_no][len(bidgoods[item_no])] = item_row
return bidgoods
# 복수공고여부
def multi_bid_fg(self, item_bidgoods):
multi_bid_fg = False
if len(item_bidgoods) > 1:
multi_bid_fg = True
return multi_bid_fg

View File

@ -0,0 +1,777 @@
import re
import itertools
from plugins.utils.transformers.transformer_helper import TransformerHelper
import logging
class BidKeyTransformer(TransformerHelper):
# 공고분류
def bidtype(self, api_data, bidtype, constnm):
# 시설, 용역, 물품일경우
pattern = [
{"pattern": {"P1": "용역", }, "value": "ser"},
{"pattern": {"P1": "공사", }, "value": "con"},
{"pattern": {"P1": "물품", }, "value": "pur"},
]
if len(re.findall("공사|용역|물품", bidtype)) > 0:
return self.Etl.mapping_pattern_value(pattern, bidtype, None)
elif len(re.findall("민간|견적", bidtype)) > 0: # 민간, 견적
civil_bidtype = self.extract_value(api_data, "dmItemMap", "prcmBsneSeCd")
civil_pattern = [
{"pattern": {"P1": "조070001", }, "value": "pur"}, # 물품
{"pattern": {"P1": "조070002", }, "value": "ser"}, # 일반용역
{"pattern": {"P1": "조070003", }, "value": "ser"}, # 기술용역
{"pattern": {"P1": "조070004", }, "value": "con"}, # 공사
{"pattern": {"P1": "조070005", }, "value": "pur"}, # 기타 -> 기타가 따로 없음
]
return self.Etl.mapping_pattern_value(civil_pattern, civil_bidtype, "pur")
else: # 기타
pattern = [
{"pattern": {"P1": "용역", }, "value": "ser"},
{"pattern": {"P1": "공사", }, "value": "con"},
]
return self.Etl.mapping_pattern_value(pattern, constnm, "pur")
# 면허
def partcode(self, api_data):
partcode_element = self.extract_value(api_data, "dmItemMap", "intpLmtCn")
if partcode_element is None:
return []
try:
partcode = re.findall(r'\((\d{4})\)', partcode_element)
except Exception:
partcode = []
#허용업종 추가
if api_data.get("dmItemList6") is not None:
for item in api_data["dmItemList6"]:
item["bidPrmsIntpCd"]
if item.get("bidPrmsIntpCd") is not None:
partcode.append(item["bidPrmsIntpCd"])
#중복값 제거
partcode = list(set(partcode))
# 주력분야 체크 후 있으면 주력분야 코드로 면허변경
try:
# main_partcode = list(set(re.findall(r'\(주력분야: (.*?)\)', partcode_element)))
# "(주력분야: 난방공사(제1종))"처럼 이중괄호일 경우 마지막 괄호 수집이 되도록 아래와 같이 수정
main_partcode = list(set(re.findall(r'\(주력분야: ([^)]+\([^)]+\)|[^)]+)', partcode_element)))
main_partcode = [partcode.replace('또는', '').split('') for partcode in main_partcode]
main_partcode = list(itertools.chain(*main_partcode))
if len(main_partcode) > 0:
except_partcode = [self.get_mainPartcode(partcode) for partcode in main_partcode if
self.get_mainPartcode(partcode) is not None]
# partcode = main_partcode
# 주력분야 코드가 있을 경우 해당 면허코드는 없애버림.
partcode = [part for part in partcode if part not in except_partcode]
partcode = partcode + main_partcode
except Exception:
print('주력분야 에러')
partcode = list(filter(lambda x: x is not None, partcode))
partcode.sort()
return partcode
# 물품면허
def partcode_pur(self, api_data):
item_list = self.extract_value(api_data, "dmItemItemList")
if item_list is None:
# print("partcode_pur 구매대상물품 없음")
return {}
partcode = []
for value in item_list:
#세부품명번호:물품코드
dtlsPrnmNo = self.extract_value(value, "dtlsPrnmNo", default="")
partcode.append(dtlsPrnmNo)
return partcode
# 공고번호
def notinum(self, api_data):
if self.tran_bidtype == "기타":
notinum = self.extract_value(api_data, "dmItemMap", "bidPbancUntyNo", default="")
notinum_ord = self.extract_value(api_data, "dmItemMap", "bidPbancUntyOrd", default="")
else:
notinum = self.extract_value(api_data, "dmItemMap", "bidPbancNo", default="")
notinum_ord = self.extract_value(api_data, "dmItemMap", "bidPbancOrd", default="")
rs_notinum = notinum + "-" + notinum_ord
rs_notinum = rs_notinum.replace(" ", "")
return rs_notinum
# 공사명
def constnm(self, api_data):
constnm = self.extract_value(api_data, "dmItemMap", "bidPbancNm", default="")
constnm = self.Etl.safe_encode(constnm)
# 한글 깨지는 현상으로 추가 -> 다른 방법이나 다른 데이터도 수정되게 옮기거나 해야함
constnm = constnm.replace("", "")
# 슬러지2처리장 농축기 슬러지공급펌프 수리 용역\ <- 이런 공고명 발생, 쿼리 에러로인해 임시조치
# constnm = constnm.rstrip('\\')
if constnm.endswith("\\"):
constnm = constnm[:-1] + "\\\\" # 마지막 백슬래시를 이스케이프 처리
return constnm
def pct(self, pct_org, succls_detail_org, org_dict, item_bidkey, yegatype):
try:
# print("[투찰율 자동 세팅] start")
pct = None
pct_update = {}
# print("투찰율 > ", pct_org)
# print("발주처 코드 > ", org_dict['orgcode_y'])
# print("낙찰방법세부기준 > ", succls_detail_org)
# print("공고분류 > ", item_bidkey["bidtype"])
# print("추정가격 > ", item_bidkey["presum"])
# 투찰율 있을 경우 진행
pct_update['1'] = True if pct_org else False
# if not pct_update['1']: print('투찰율 X')
# 시설공고 일때 진행
pct_update['2'] = True if "con" in item_bidkey["bidtype"] else False
# if not pct_update['2']: print('시설 X')
# 농어촌(XS), 산림청(AT) 발주처일 경우 진행
#pct_update['2'] = True if org_dict['orgcode_y'] and ('XS' in org_dict['orgcode_y'] or 'AT' in org_dict['orgcode_y']) else False
#if not pct_update['2']: print('발주처 조건 X')
# 낙찰방법세부기준 텍스트 매칭 될경우 or 농어촌(XS), 산림청(AT) 발주처일 경우 -> 진행
compare_texts = ['지방자치단체 입찰시 낙찰자 결정기준', '조달청 시설공사 적격심사세부기준', '소액수의견적(2인 이상 견적 제출)']
pct_update['3'] = True if ( any(text.replace(" ", "") in succls_detail_org.replace(" ", "") for text in compare_texts) ) or ( org_dict['orgcode_y'] and ('XS' in org_dict['orgcode_y'] or 'AT' in org_dict['orgcode_y']) ) else False
# if not pct_update['3']: print('낙찰방법세부기준 매칭 X OR 발주처 조건 X')
# 낙찰방법세부기준 제외 기준 텍스트 매칭 될경우 제외
# 농어촌(XS)은 제외되지 않도록 추가
except_texts = ['관리규정외(기타) - 관리규정외 수기심사(총점입력)']
pct_update['4'] = False if any(text.replace(" ", "") in succls_detail_org.replace(" ", "") for text in except_texts) and 'XS' not in org_dict['orgcode_y'] else True
# if not pct_update['4']: print('낙찰방법세부기준 제외조건, 농어촌 아님 -> 제외')
# 추정가격 50억 이상이면 제외
pct_update['5'] = False if item_bidkey["presum"] and int(item_bidkey["presum"]) > 5000000000 else True
# if not pct_update['5']: print('추정가격 50억 이상으로 제외')
# 비예가, 단일예가인 경우 제외
pct_update['6'] = True if yegatype not in ("00", "11") else False
# if not pct_update['6']: print('단일예가, 비예가 제외')
# 수집 조건에 맞으면 투찰율 세팅
if all(value is not False for value in pct_update.values()):
pct = pct_org
# print("세팅된 투찰율 : ", pct)
else:
# print("투찰율 세팅 X")
pass
# print("[투찰율 자동 세팅] end")
return pct
except Exception as e:
logging.error(f"pct 오류 : {e}")
return None
def pct_ser(self, pct_org, yegatype, bidcls):
# 입찰방식이 전자입찰이 아니면 제외
# 비예가, 단일예가인 경우 제외
# 90보다 크면 제외
# 50보다 작으면 제외
# 점포함 2-6자리 아니면 제외
pct = None
pct_update = {}
# 1. 투찰율 있을 경우 진행
pct_update['1'] = True if pct_org else False
# 2. 전자입찰 일때 진행
pct_update['2'] = True if "01" in bidcls else False
# 3. 비예가, 단일예가인 경우 제외
pct_update['3'] = True if yegatype not in ("00", "11") else False
# 4. 투찰율 값 판단
# 50보다 작거나 90보다 크면 제외
# 점포함 2-6자리 아니면 제외
pct_update['4'] = True if 50 <= float(pct_org) <= 90 and 2 <= len(str(pct_org)) <= 6 else False
# 수집 조건에 맞으면 투찰율 세팅
if all(value is not False for value in pct_update.values()):
pct = pct_org
return pct
# 발주처
def org(self, api_data, syscollect, data_code_org_i, data_code_org_y, data_order_name):
if self.tran_bidtype == "민간":
org = self.extract_value(api_data, "dmItemMap", "pbancInstUntyGrpNoNm")
elif self.tran_bidtype == "기타":
org = self.extract_value(api_data, "dmItemMap", "oderInstUntyGrpNm")
else:
org = self.extract_value(api_data, "dmItemMap", "pbancInstUntyGrpNm")
if syscollect["ext_info3"] == "MAS":
# print("MAS 공고")
return {"org": '조달청', "org_i": None, "orgcode_i": None, "org_y": '조달청', "orgcode_y": None}
if org is None:
# print("발주처 없음")
return {"org": None, "org_i": None, "orgcode_i": None, "org_y": None, "orgcode_y": None}
result = {}
result['org'] = org
org_i = self.Etl.getOrg("code_org_i", result['org'], data_code_org_i, data_code_org_y, data_order_name)
result['org_i'] = org_i['order_name']
result['orgcode_i'] = org_i['order_code']
org_y = self.Etl.getOrg("code_org_y", result['org'], data_code_org_i, data_code_org_y, data_order_name)
result['org_y'] = org_y['order_name']
result['orgcode_y'] = org_y['order_code']
return result
# 입찰진행상태
def bidproc(self, stats):
pattern = [
{"pattern": {"P1": "변경", }, "value": "M"},
{"pattern": {"P1": "취소|견적취소", }, "value": "C"},
]
bidproc = self.Etl.mapping_pattern_value(pattern, stats, "B")
return bidproc
# 계약방법
def contract(self, api_data, syscollect):
contract = self.extract_value(api_data, "dmItemMap", "stdCtrtMthdNm", default="")
if not contract: # 민간
contract = self.extract_value(api_data, "dmItemMap", "stdCtrtMthdCdNm", default="")
if syscollect["ext_info3"] == "MAS":
contract = self.extract_value(api_data, "dmItemMap", "bidMthd", default="")
return contract
# 전자입찰여부
def bidcls(self, api_data):
bidcls = self.extract_value(api_data, "dmItemMap", "bidMthdNm", default="")
if not bidcls: # 민간
bidcls = self.extract_value(api_data, "dmItemMap", "bidMthdCdNm", default="")
return bidcls
# 낙찰방식(낙찰자결정방법)
def succls(self, api_data):
succls = self.extract_value(api_data, "dmItemMap", "scsbdMthdNm", default="")
succls = self.Etl.safe_encode(succls)
if not succls: # 민간
succls = self.extract_value(api_data, "dmItemMap", "scsbdMthdCdNm", default="")
return succls
# 계약방법(등급)
def conlevel(self, api_data):
conlevel_txt = self.extract_value(api_data, "dmItemMap", "lmtGrdNm")
if conlevel_txt is None:
# print("계약방법(등급) 없음")
return None
match1 = re.search(r'(\d{1,2})등급', conlevel_txt)
conlevel = match1.group(1) if match1 else None
return conlevel
# 협정마감일
def agreedt(self, api_data):
if api_data.get("dmItemList1") is not None:
for item in api_data["dmItemList1"]:
if item.get("subject") is not None and item["subject"] == "공동수급협정서제출" and item.get("endDt") is not None:
agreedt = item["endDt"]
return agreedt.replace("/", "-")
return None
# 20250513 YJH 추가 (bid_key 안에서 처리하기 위해 bid_value_transformer 에서 가져옴옴)
# 도급비율(공동도급비율)
def contper(self, api_data):
contper = self.extract_value(api_data, "dmItemMap", "rgnDutyJintSyddRt")
return contper
# 공동도급
def convention(self, api_data, agreedt, contper):
jintSyddCmnMthoNm = self.extract_value(api_data, "dmItemMap", "jintSyddCmnMthoNm")
if agreedt is not None:
convention = "1" if contper is not None else "2" # 공동도급비율이 있으면 의무 없으면 협정
elif jintSyddCmnMthoNm is not None and (jintSyddCmnMthoNm == '공동이행' or jintSyddCmnMthoNm == '공동이행 또는 분담이행'):
convention = "2"
else:
convention = None
# enum타입으로 0,1,2,3만 존재 None일경우 0으로 처리 [20250529 YJH]
if convention is None:
convention = "0"
return convention
# 지역 처리를 위한 원본데이터 list형태로 반환
def location_list(self, api_data):
if self.tran_bidtype == "민간":
location = self.extract_value(api_data, "result", "bidLmtRgnList")
result = []
for item in location:
result.append(item['lgdngNm'])
else:
location = self.extract_value(api_data, "dmItemMap", "rgnLmtCn")
if location is None:
# print("지역(참가가능지역) 없음")
return ""
result = re.findall("\[([^\]]+)\]", location) # 지역값
return result
# 지역(참가가능지역)
def location(self, location_list, convention, bidtype, api_data):
odnDutyLgdngNm = self.extract_value(api_data, "dmItemMap", "odnDutyLgdngNm") # 공동도급지역
odnDutyLgdngNm_text = odnDutyLgdngNm if odnDutyLgdngNm is not None else ""
if len(location_list) > 0:
location = self.Etl.pow_sum(self.Etl.loction_to_code(location_list))
else: # 참가가능지역이 없는경우 => 공동도급??
if convention == "1": # 의무인 경우
location = re.findall("\[([^\]]+)\]", odnDutyLgdngNm_text) # 지역값
location.append("전국")
location = self.Etl.pow_sum(self.Etl.loction_to_code(location))
elif bidtype == "pur":
location = "1" # 물품일 경우 전국으로 체크
else: # 의무가 아닌경우
location = None
return location
# 추정가격
def presum(self, api_data):
presum = self.extract_value(api_data, "dmItemMap", "prspPrce")
if presum is None:
# print("추정가격 없음")
return None
return float(presum) if presum != "" else None # 추정가격
# 기초금액
def basic(self, api_data, multi_bid_fg):
# print("기초금액")
prcmBsneSeCd = self.extract_value(api_data, "result", "bidInfo", "prcmBsneSeCd") # 조달업무구분코드
srvcePrdctCmplsYn = self.extract_value(api_data, "result", "bidInfo", "srvcePrdctCmplsYn") # 일반용역물품필수여부
if prcmBsneSeCd == "01" or (prcmBsneSeCd == "03" and srvcePrdctCmplsYn == "Y"): # 차세대 js기준 조건
# 복수
basic_list = self.extract_value(api_data, "result", "listBaseEstiPrice", default=[])
if len(basic_list) == 0:
basic_data = {}
else:
basic = {}
for value in basic_list:
# 분류 번호
item_no = self.extract_value(value, "bidClsfNo")
basic[item_no] = self.extract_value(value, "bsamt") # todo 기초금액 None으로 반환해야 될까 0으로 반환해야될까?
basic_data = basic
else:
# 단수
basic = self.extract_value(api_data, "result", "baseEstiPrice", "bsamt", default=None)
basic_data = basic
if multi_bid_fg: # 복수공고
if isinstance(basic_data, dict):
return basic_data
else:
return None
else: # 단수
if isinstance(basic_data, dict):
if len(basic_data) > 0:
first_key = next(iter(basic_data))
return basic_data[first_key]
else:
return None
else:
return basic_data
# 물품 면허 추가
def purcode(self, bidgoods, bidkey_codes, data_g2b_pur_code_change):
purchangecode = self.Etl.G2BpurCodeChange(data_g2b_pur_code_change)
result = None
add_equalcode = []
add_likepartcode = []
add_partcode = []
add_concode = []
add_sercode = []
add_purcode = []
for key in bidgoods:
for key2 in bidgoods[key]:
for key3 in bidgoods[key][key2]:
if key3 == 'gcode':
part_gcode = bidgoods[key][key2][key3]
if part_gcode in purchangecode:
add_equalcode.append(purchangecode[part_gcode])
part_gcodelike = self.Etl.G2BpurCodeLikeChange(part_gcode, data_g2b_pur_code_change)
if part_gcodelike is not None:
for like_key in part_gcodelike:
# print(like_key)
add_likepartcode.append(part_gcodelike[like_key])
if len(add_equalcode + add_likepartcode) > 0:
for row in add_equalcode + add_likepartcode:
# print(row)
if re.findall('|', row) != -1:
tmp_row = row.split("|")
for row2 in tmp_row:
add_partcode.append(row2)
else:
add_partcode.append(row)
# print("3")
for row in add_partcode:
if re.findall('C', row) != -1:
add_concode.append(row)
if re.findall('S', row) != -1:
add_sercode.append(row)
if re.findall('P', row) != -1:
add_purcode.append(row)
# bidkey_codes의 각 코드 값도 None 또는 빈 문자열일 수 있음을 고려
tmp_concode_base = []
if bidkey_codes.get('concode'): # None이 아니고 빈 문자열도 아닌 경우
tmp_concode_base = [code for code in bidkey_codes['concode'].split("|") if code] # 빈 문자열 제거
tmp_sercode_base = []
if bidkey_codes.get('sercode'):
tmp_sercode_base = [code for code in bidkey_codes['sercode'].split("|") if code]
tmp_purcode_base = []
if bidkey_codes.get('purcode'):
tmp_purcode_base = [code for code in bidkey_codes['purcode'].split("|") if code]
sum_concode = list(set(tmp_concode_base + add_concode))
sum_sercode = list(set(tmp_sercode_base + add_sercode))
sum_purcode = list(set(tmp_purcode_base + add_purcode))
# lambda 함수 수정: 빈 문자열을 안전하게 처리
# x가 존재하고(None이나 빈 문자열이 아니고) 첫 글자가 'C'인지 확인
filtered_concode = list(filter(lambda x: x and x.startswith("C"), sum_concode))
filtered_sercode = list(filter(lambda x: x and x.startswith("S"), sum_sercode))
filtered_purcode = list(filter(lambda x: x and x.startswith("P"), sum_purcode))
result = {
'concode': "|".join(filtered_concode) if filtered_concode else None,
'sercode': "|".join(filtered_sercode) if filtered_sercode else None,
'purcode': "|".join(filtered_purcode) if filtered_purcode else None
}
return result
# 공고분류번호(혼합)
def bidview(self, bidtype, dic_partcode):
bidviewDic = {"concode": "con", "sercode": "ser", "purcode": "pur"}
# 공사, 용역건에 물품건을 보여주진 않는다.
if bidtype == 'con' or bidtype == 'ser':
bidviewDic = {
"concode": "con",
"sercode": "ser",
}
bidview = ",".join(list(set(list(filter(lambda x: x is not None, list(map(lambda x: bidviewDic[x] if x in bidviewDic and dic_partcode[x] is not None else None, dic_partcode)))) + [bidtype])))
return bidview
# 옵션
def opt(self, api_data, opt_param):
if self.tran_bidtype == "민간":
explain = self.extract_value(api_data, "dmItemMap", "pbancDscrTrgtYnLtrs")
explain_flag = True if explain is not None and explain == '' else False
else:
explain = self.extract_value(api_data, "dmItemMap", "bidPrcpLmtYn")
explain_flag = True if explain is not None and explain == 'Y' else False
opt = []
if opt_param["bidproc"] == "M": # 정정
opt.append(1)
if self.extract_value(api_data, "dmItemMap", "emrgPbancYn", default="N") == "Y":
opt.append(2)
if explain_flag: # 현장설명 제한여부
opt.append(3)
if opt_param["convention"] == "1": # 공동도급의무
opt.append(8)
if opt_param["basic"] is not None and (not isinstance(opt_param["basic"], dict)) and int(opt_param["basic"]) > 0: # 기초금액이 있을때
opt.append(9)
if len(opt_param["item_bidlocal"]) > 0: # 관내
opt.append(11)
if len(re.findall("\(단가\)", opt_param["contract"])) > 0: # 단가
opt.append(12)
if len(re.findall("여성기업", opt_param["contract"])) > 0: # 여성기업
opt.append(13)
if opt_param["bidproc"] == "C": # 취소
opt.append(16)
if opt_param["bidproc"] == "R": # 재입찰
opt.append(17)
return self.Etl.pow_sum(opt)
# 공고게시일
def noticedt(self, api_data):
noticedt = self.extract_value(api_data, "dmItemMap", "pbancPstgDt")
if noticedt is None:
# print("공고게시일 없음")
return None
return noticedt.replace("/", "-")
# 등록마감일
def registdt(self, api_data):
registdt = self.extract_value(api_data, "dmItemMap", "bidQlfcRegDt")
if registdt is None:
# print("등록마감일 없음")
return None
return registdt.replace("/", "-")
# 현장설명일
def explaindt(self, api_data):
explaindt = self.extract_value(api_data, "dmItemMap", "pbancBfssDt")
if explaindt is None:
# print("현장설명일 없음")
return None
return explaindt.replace("/", "-")
# 투찰시작일
def opendt(self, api_data):
opendt = self.extract_value(api_data, "dmItemMap", "slprRcptBgngDt")
if opendt is None:
# print("투찰시작일 없음")
return None
return opendt.replace("/", "-")
# 투찰마감일
def closedt(self, api_data, syscollect):
closedt = self.extract_value(api_data, "dmItemMap", "slprRcptDdlnDt")
if syscollect["ext_info3"] == "MAS":
closedt = self.extract_value(api_data, "dmItemMap", "elgtEvlDataSbmsnTermDate")
if closedt is None:
# print("투찰마감일 없음")
return None
return closedt.replace("/", "-")
# 입찰일/개찰일
def constdt(self, api_data, syscollect):
if self.tran_bidtype == "기타":
constdt = self.extract_value(api_data, "dmItemMap", "onbsPrnmntDtStr")
else:
constdt = self.extract_value(api_data, "dmItemMap", "onbsPrnmntDt")
if syscollect["ext_info3"] == "MAS":
constdt = self.extract_value(api_data, "dmItemMap", "pbancEndDate")
if constdt is None:
# print("입찰일/개찰일 없음")
return None
return constdt.replace("/", "-")
# 적격성심사신청서
def jkdt(self, api_data):
if api_data.get("dmItemList1") is not None:
for item in api_data["dmItemList1"]:
if item.get("subject") is not None and item["subject"] == "적격성심사신청기한" and item.get("endDt") is not None:
jkdt = item["endDt"]
return jkdt.replace("/", "-")
return ""
# PQ심사신청일
def pqdt(self, api_data):
if api_data.get("dmItemList1") is not None:
for item in api_data["dmItemList1"]:
if item.get("subject") is not None and item["subject"] == "PQ심사신청기한" and item.get("endDt") is not None:
pqdt = item["endDt"]
return pqdt.replace("/", "-")
return ""
# 실수요기관
def org_real(self, api_data, syscollect):
if self.tran_bidtype == "민간":
org_real = self.extract_value(api_data, "dmItemMap", "grpNm")
elif self.tran_bidtype == "기타":
org_real = self.extract_value(api_data, "dmItemMap", "oderInstUntyGrpNm")
else:
org_real = self.extract_value(api_data, "dmItemMap", "dmstUntyGrpNm")
if syscollect["ext_info3"] == "MAS":
# print("MAS 공고")
org_real = "각 수요기관"
if org_real is None:
# print("실수요기관 없음")
return None
return org_real
# 내역입찰여부 text
def breakdown_bid_check(self, api_data):
breakdown_bid_check = self.extract_value(api_data, "dmItemMap", "lisBidYnNm")
if breakdown_bid_check is None:
# print("내역입찰여부 없음")
return None
return breakdown_bid_check
# 지역의무공동도급
def local_join_check(self, api_data):
local_join_check = self.extract_value(api_data, "dmItemMap", "rgnDutyJintSyddYnNm")
if local_join_check is None:
# print("지역의무공동도급 없음")
return None
return '1' if local_join_check == "해당" else '2'
# 주공종 면허
def first_join_part(self, api_data, data_code_item_match):
first_join_part_str = self.extract_value(api_data, "dmItemMap", "mindNm")
if first_join_part_str is None:
# print("주공종 면허 없음")
return None
first_join_part = self.Etl.G2BNmToYERAMCode(first_join_part_str, data_code_item_match)
return first_join_part
# 주공종 추정금액
def first_join_part_prevamt(self, api_data):
first_join_part_prevamt = self.extract_value(api_data, "dmItemMap", "mindCstrnPrnmntAmt")
return first_join_part_prevamt
# 주공종 추정가격
def first_join_part_presum(self, api_data):
first_join_part_presum = self.extract_value(api_data, "dmItemMap", "mindPrspPrce")
return first_join_part_presum
# 업종평가비율
def assessment_percent(self, api_data):
assessment_percent = self.extract_value(api_data, "dmItemMap", "intpEvlRt", default="")
return str(assessment_percent)
# 부공종 면허
def sub_first_join_part(self, api_data, data_code_item_match):
item_joinpartlist = []
sub_first_join_part_list = self.extract_value(api_data, "dmItemList4")
if sub_first_join_part_list is None:
# print("부공종 데이터 없음")
return None
for item in sub_first_join_part_list:
if "subIntpCd" in item:
tmp_joinpartlist_arr = {}
tmp_joinpartlist_arr["join_part_code"] = self.Etl.G2BNmToYERAMCode(item["subIntpCd"], data_code_item_match)["g2b_code"]
tmp_joinpartlist_arr["join_part_name"] = item["subIntpNm"]
tmp_joinpartlist_arr["join_part_assessment_percent"] = item["subIntpEvlRt"]
tmp_joinpartlist_arr["join_part_presum"] = item["subIntpPrspPrce"]
tmp_joinpartlist_arr["join_part_prevamt"] = None
tmp_joinpartlist_arr["join_part_category"] = "2"
item_joinpartlist.append(tmp_joinpartlist_arr)
return item_joinpartlist
# 가산지역
def plus_local(self, api_data):
plus_local = []
plus_local1 = self.extract_value(api_data, "dmItemMap", "bidLmtUntyNm1")
if plus_local1 is not None:
plus_local.append("가산지역1 : " + plus_local1)
plus_local2 = self.extract_value(api_data, "dmItemMap", "bidLmtUntyNm2")
if plus_local2 is not None:
plus_local.append("가산지역2 : " + plus_local2)
plus_local3 = self.extract_value(api_data, "dmItemMap", "bidLmtUntyNm3")
if plus_local3 is not None:
plus_local.append("가산지역3 : " + plus_local3)
plus_local4 = self.extract_value(api_data, "dmItemMap", "bidLmtUntyNm4")
if plus_local4 is not None:
plus_local.append("가산지역4 : " + plus_local4)
if plus_local:
return " ".join(plus_local)
else:
return None
################################
def get_mainPartcode(self, text):
# 주력분야 텍스트 : 매칭번호
match_dict = {
'토공사 와 포장공사': '4898',
'토공사 와 보링·그라우팅·파일공사': '4898',
'포장공사 와 토공사': '4898',
'포장공사 와 보링·그라우팅·파일공사': '4898',
'보링·그라우팅·파일공사 와 포장공사': '4898',
'보링·그라우팅·파일공사 와 토공사': '4898',
'토공사': '4989',
'포장공사': '4989',
'보링·그라우팅·파일공사': '4989',
'실내건축공사': '4990',
'금속구조물·창호·온실공사': '4991',
'지붕판금·건축물조립공사': '4991',
'도장공사': '4992',
'습식·방수공사': '4992',
'석공사': '4992',
'조경식재공사': '4993',
'조경시설물설치공사': '4993',
'철근·콘크리트공사': '4994',
'구조물해체·비계공사': '4995',
'상하수도설비공사': '4996',
'철도·궤도공사': '4997',
'철강구조물공사': '4998', #
'수중공사': '4999',
'준설공사': '4999',
'승강기설치공사': '6201',
'삭도설치공사': '6201',
'기계설비공사': '6202',
'가스시설공사(제1종)': '6202',
'가스시설공사(제2종)': '6203',
'가스시설공사(제3종)': '6203',
'난방공사(제1종)': '6203',
'난방공사(제2종)': '6203',
'난방공사(제3종)': '6203',
}
return match_dict.get(text)
################################
# bbbbbb
def aaaaaaaa(self, api_data):
aaaaaaaa = self.extract_value(api_data, "dmItemMap", "cCccCccCnm")
if aaaaaaaa is None:
print("bbbbbb 없음")
return None
return aaaaaaaa

View File

@ -0,0 +1,317 @@
import re
from plugins.utils.transformers.transformer_helper import TransformerHelper
import logging
class BidValueTransformer(TransformerHelper):
# 도급비율(공동도급비율)
def contper(self, api_data):
contper = self.extract_value(api_data, "dmItemMap", "rgnDutyJintSyddRt")
return contper
# 난이도계수
def lvcnt(self, api_data):
lvcnt = self.extract_value(api_data, "dmItemMap", "lvdfCfct")
if lvcnt is None:
# print("난이도계수 없음")
return None
return lvcnt
# 예가범위
def yegarng(self, api_data, multi_bid_fg):
# print("예가범위")
# 차세대 베타기간이슈로 시간이없어서 basic -> yegarng로 붙여넣기 한 코드라 변수명이 basic으로 되어있으니 헷갈리지 않도록 주의하세요
prcmBsneSeCd = self.extract_value(api_data, "result", "bidInfo", "prcmBsneSeCd") # 조달업무구분코드
srvcePrdctCmplsYn = self.extract_value(api_data, "result", "bidInfo", "srvcePrdctCmplsYn") # 일반용역물품필수여부
if prcmBsneSeCd == "01" or (prcmBsneSeCd == "03" and srvcePrdctCmplsYn == "Y"): # 차세대 js기준 조건
# 복수
basic_list = self.extract_value(api_data, "result", "listBaseEstiPrice", default=[])
logging.info(f"basic_list: {basic_list}")
if len(basic_list) == 0:
basic_data = {}
else:
basic = {}
for value in basic_list:
prceCrtRangRt = self.extract_value(value, "prceCrtRangRt") #예가범위
rsvePrceRangSeCd = self.extract_value(value, "rsvePrceRangSeCd") #예가범위 분류 기준
success_flag = True
if prceCrtRangRt is None:
# print("예가범위 없음")
success_flag = False
if rsvePrceRangSeCd is None:
# print("예가범위 분류 기준이 없음")
success_flag = False
result_yegarng = None
if success_flag is not None:
if rsvePrceRangSeCd == "예070003":
# sign = "기초금액기준 ± " + prceCrtRangRt + " \r % 범위 내에서 작성됩니다.";
result_yegarng = "-" + str(prceCrtRangRt) + "|+" + str(prceCrtRangRt)
if rsvePrceRangSeCd == "예070001":
# sign = "기초금액기준 + " + prceCrtRangRt + " \r % 범위 내에서 작성됩니다.";
result_yegarng = "0|+" + str(prceCrtRangRt)
if rsvePrceRangSeCd == "예070002":
# sign = "기초금액기준 - " + prceCrtRangRt + " \r % 범위 내에서 작성됩니다.";
result_yegarng = "-" + str(prceCrtRangRt) + "|0"
else:
result_yegarng = None
# 분류 번호
item_no = self.extract_value(value, "bidClsfNo")
basic[item_no] = result_yegarng # todo 기초금액 None으로 반환해야 될까 0으로 반환해야될까?
basic_data = basic
logging.info(f"basic_data: {basic_data}")
else:
# 단수
prceCrtRangRt = self.extract_value(api_data, "result", "baseEstiPrice", "prceCrtRangRt")
rsvePrceRangSeCd = self.extract_value(api_data, "result", "baseEstiPrice", "rsvePrceRangSeCd")
logging.info(f"prceCrtRangRt: {prceCrtRangRt}, rsvePrceRangSeCd: {rsvePrceRangSeCd}")
if prceCrtRangRt is None or rsvePrceRangSeCd is None:
# print("예가범위 or 분류기준 없음")
basic_data = None
else:
if rsvePrceRangSeCd == "예070003":
# sign = "기초금액기준 ± " + prceCrtRangRt + " \r % 범위 내에서 작성됩니다.";
basic_data = "-" + str(prceCrtRangRt) + "|+" + str(prceCrtRangRt)
if rsvePrceRangSeCd == "예070001":
# sign = "기초금액기준 + " + prceCrtRangRt + " \r % 범위 내에서 작성됩니다.";
basic_data = "0|+" + str(prceCrtRangRt)
if rsvePrceRangSeCd == "예070002":
# sign = "기초금액기준 - " + prceCrtRangRt + " \r % 범위 내에서 작성됩니다.";
basic_data = "-" + str(prceCrtRangRt) + "|0"
logging.info(f"basic_data: {basic_data}")
if multi_bid_fg: # 복수공고
if isinstance(basic_data, dict):
return basic_data
else:
return None
else: # 단수
if isinstance(basic_data, dict):
if len(basic_data) > 0:
first_key = next(iter(basic_data))
return basic_data[first_key]
else:
return None
else:
return basic_data
# 평가기준
def scrcls(self, api_data, bidtype):
if bidtype == '공사':
scrcls = self.extract_value(api_data, "dmItemMap", "scsbdMthdDtlsNm", default="")
pattern = [
{"pattern": {"P1": "조달청", }, "value": "g2b"},
{"pattern": {"P1": "지방자치단체", }, "value": "mop"},
{"pattern": {"P1": "시설공사적격심사세부기준", "N1": "조달청시설공사적격심사세부기준"}, "value": "juk"},
{"pattern": {"P1": "관리규정외\(기타\)-관리규정외수기심사\(총점입력\)"}, "value": "juk"},
]
trim_scrcls = scrcls.replace(" ", "")
result = self.Etl.mapping_pattern_value(pattern, trim_scrcls, "")
else:
result = ""
return result
# 참조번호
def refno(self, api_data):
refno = self.extract_value(api_data, "dmItemMap", "usrDocNoVal", default="")
refno = self.Etl.safe_encode(refno)
return refno
# 예가타입(예가방법)
def yegatype(self, api_data):
yegatype = self.extract_value(api_data, "dmItemMap", "pnprDcsnMthoNm", default="")
pattern = [
{"pattern": {"P1": "비예가", }, "value": "00"},
{"pattern": {"P1": "단일예가", }, "value": "11"},
{"pattern": {"P1": "3.+\/.+7", }, "value": "17"},
{"pattern": {"P1": "3.+\/.+10", }, "value": "20"},
{"pattern": {"P1": "4.+\/.+10", }, "value": "23"},
{"pattern": {"P1": "4.+\/.+15", }, "value": "25"},
{"pattern": {"P1": "3.+\/.+15", }, "value": "27"},
{"pattern": {"P1": "2.+\/.+5", }, "value": "28"},
{"pattern": {"P1": "3.+\/.+5", }, "value": "29"},
{"pattern": {"P1": "7.+\/.+15", }, "value": "30"},
{"pattern": {"P1": "5.+\/.+15", }, "value": "31"},
{"pattern": {"P1": "2.+\/.+15", }, "value": "32"},
{"pattern": {"P1": "1.+\/.+3", }, "value": "33"},
]
return self.Etl.mapping_pattern_value(pattern, yegatype, None)
# 공사예정(추정금액)
def prevamt(self, api_data):
prevamt = self.extract_value(api_data, "dmItemMap", "bgtAmt", default="")
prevamt_sub = self.extract_value(api_data, "dmItemMap", "alotBgtAmt", default="")
if prevamt == '' and prevamt_sub != '':
prevamt = prevamt_sub
return int(float(prevamt)) if prevamt !="" else None
# 공동도급지역
def contloc(self, api_data):
contloc = self.extract_value(api_data, "dmItemMap", "odnDutyLgdngNm", default="")
return self.Etl.blank_None("|".join(re.findall("\[([^\]]+)\]", contloc))) # 지역값
# 공고담당자(이름|연락처|이메일)
def charger(self, api_data, syscollect):
# print("공고담당자")
# 빈값에 따른 우선순위 처리
# 1. 공고기관 담당자 연락처
# 2. 수요기관 담당자 연락처
# 3. 공고기관담당자 이름
# 공고기관 담당자 연락처
phone1 = self.extract_value(api_data, "dmItemMap", "pbancPicTlphNo", default="")
staff_name1 = self.extract_value(api_data, "dmItemMap", "pbancPicNm", default="")
if syscollect["ext_info3"] == "MAS":
phone1 = self.extract_value(api_data, "dmItemMap", "tlphNo", default="")
staff = []
if phone1 != "":
if staff_name1 != "":
staff.append(staff_name1) # 공고기관 담당자 이름
phone = phone1
if phone != "":
phone = self.format_phone(phone)
staff.append(phone) # 연락처
else:
staff.append("") # 연락처
staff.append("") # 이메일
else:
# 수요기관 담당자 연락처
staff_name2 = ""
phone2 = ""
# dmItemList3이 None이 아니고, 리스트가 비어있지 않다면 첫 번째 요소를 처리
if api_data.get("dmItemList3") is not None:
if len(api_data["dmItemList3"]) > 0: # 리스트가 비어있지 않은지 확인
first_item = api_data["dmItemList3"][0]
# picNm과 tlphNo 값을 추출하고, 없을 경우 빈 문자열을 유지
staff_name2 = first_item.get("picNm", "")
phone2 = first_item.get("tlphNo", "")
if phone2 != "":
if staff_name2 != "":
staff.append(staff_name2) # 수요기관 담당자 이름
phone = phone2
if phone != "":
phone = self.format_phone(phone)
staff.append(phone) # 연락처
else:
staff.append("") # 연락처
staff.append("") # 이메일
elif staff_name1 != "":
staff.append(staff_name1) #공고기관 담당자 이름
staff.append("") # 연락처
staff.append("") # 이메일
if staff:
return "|".join(staff)
else:
return None
def format_phone(self, t="", e="-"):
try:
t = re.sub(r"\s+", "", t)
prefixes = ["0303", "0304", "0305", "0501", "0502", "0503", "0504", "0505", "0506", "0507", "0508", "0509"]
prefix = t[:4]
if prefix in prefixes:
return re.sub(r"^(01[0136789]|00|02|0[3-9][0-9][0-9])-?([*0-9]{3,4})-?([0-9]{4})$", "\\1{}\\2{}\\3".format(e, e), t)
elif len(t) == 7 or len(t) == 8:
return re.sub(r"^([*0-9]{3,4})-?([0-9]{4})$", "\\1{}\\2".format(e), t)
elif len(t) == 10:
if t[:2] == "02":
return re.sub(r"([0-9]{2})([0-9]{4})([0-9]+)", "\\1{}\\2{}\\3".format(e, e), t)
else:
return re.sub(r"([0-9]{3})([0-9]{3})([0-9]+)", "\\1{}\\2{}\\3".format(e, e), t)
elif len(t) == 11:
return re.sub(r"^(\d{3})-?(\d{4})-?(\d{4})$", "\\1{}\\2{}\\3".format(e, e), t)
elif len(t) < 11:
return re.sub(r"^(01[0136789]|00|02|070|0[3-9][0-9][0-9])-?([*0-9]{3,4})-?([0-9]{4})$", "\\1{}\\2{}\\3".format(e, e), t)
else:
return t
except Exception as ex:
logging.error("Error: {}".format(ex))
# 도급자설치관급자재금액
def contract_money(self, api_data):
contract_money = self.extract_value(api_data, "dmItemMap", "etcAmt")
return contract_money
# 관급자설치관급자재금액
def government_money(self, api_data):
government_money = self.extract_value(api_data, "dmItemMap", "gvspAmt")
return government_money
# 공사현장
def work_field(self, api_data):
work_field = self.extract_value(api_data, "dmItemMap", "bidLmtUntyNm")
return work_field
# 실적심사신청서 신청기한
def result_check_dt(self, api_data):
result_check_dt = self.extract_value(api_data, "dmItemMap", "prqdcRcptDdlnDt")
return result_check_dt
# 현장설명장소 또는 과업설명장소
def explain_local(self, api_data):
explain_local = self.extract_value(api_data, "dmItemMap", "pbancBfssPlacNm")
explain_local = self.Etl.safe_encode(explain_local)
return explain_local
# 공종별 전체 지분율
def join_part_total_percent(self, api_data, data_code_item_match):
item_list = []
result = None
if self.tran_bidtype != '공사':
return result
for item in api_data["dmItemList2"]:
join_part_name = item["subIntpNm"]
i2_code = self.Etl.G2BNmToYERAMCode(item["subIntpCd"], data_code_item_match)["g2b_code"]
join_part_rate = str(item["subIntpEvlRt"])
item_list.append(join_part_name + "/" + i2_code + "/" + join_part_rate)
result = "|".join(item_list) if len(item_list) > 0 else None
return result
# 용역구분
def ser_check(self, api_data):
ser_check_nm = self.extract_value(api_data, "dmItemMap", "prcmBsneSeNm")
if ser_check_nm == "일반용역":
ser_check = "1"
elif ser_check_nm == "기술용역":
ser_check = "2"
else:
ser_check = None
return ser_check
# 상호시장진출 허용여부
def mutual_mayor(self, api_data):
#상호시장 기존 입력 값이 Y, N인데 아예 파라미터값이 Y,N으로 리턴되어 별도의 처리없음(default:None)
mutual_mayor = self.extract_value(api_data, "dmItemMap", "cbsarConmAdncPsbltyYn")
return mutual_mayor

View File

@ -0,0 +1,98 @@
from plugins.utils.transformers.transformer_helper import TransformerHelper
class EtcTransformer(TransformerHelper):
# 관내정보 수집
def bid_local(self, location_list, data_code_local):
item_bidlocal = []
for item_location in location_list:
location_arr = item_location.split(" ")
if len(location_arr) > 1 and location_arr[1] != '': # 관내건 확인
tmp = location_arr[0] + " " + location_arr[1]
item_bidlocal.append(self.Etl.getCodeLocal(tmp, data_code_local))
elif len(location_arr) == 1: # 관내건 확인
tmp = location_arr[0]
# item_bidlocal.append(self.Etl.getCodeLocal(tmp))
# bid_local중복제거
item_bidlocal_tmp = self.Etl.listTOdict(item_bidlocal)
result_bid_local = dict()
if len(item_bidlocal_tmp) > 0:
duple_bidlocal = []
for key, val in item_bidlocal_tmp.items():
if val not in duple_bidlocal:
duple_bidlocal.append(val)
result_bid_local[key] = val
return result_bid_local
# A값
def premium_list(self, api_data):
cost_total = self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "aamtinfosumamt2")
a_cost_flag = self.extract_value(api_data, "result", "isConstBidOfNewQlfdJudgeYn")
if cost_total is not None and a_cost_flag is not None and a_cost_flag == 'Y':
# 최초 반환 타입은 int형이지만, 0은 0으로 null은 ""로 처리해야 해서 아래와 같이 처리
premium_list = {
"cost1": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "mrfnAnntPrem", default=""), # 국민연금보험료
"cost2": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "mrfnHlthPrem", default=""), # 국민건강보험료
"cost3": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "rtrfundCt", default=""), # 퇴직공제부금비
"cost4": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "odpsLotmRcprPrem", default=""), # 노인장기요양보험
"cost5": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "sftyMgcs", default=""), # 산업안전보건관리비
"cost6": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "sftyChckMgcs", default=""), # 안전관리비
"cost8": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "qltyMgcs", default=""), # 품질관리비
"cost_total": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "aamtinfosumamt2", default=""), # 품질관리비
}
premium_list["cost7"] = "" # 근로자재해보장보험료 - 항목 없음
premium_list["direct_labor_cost"] = self.extract_value(api_data, "dmItemMap", "qltyMgcs", default="") # 직접노무비 - 항목 없음
premium_list = {key: str(value) for key, value in premium_list.items()}
else:
premium_list = {}
# 반환된 금액이 int형이라 강제로 str로 전체 변경
return premium_list
# 순공사원가
def bid_const_cost_list(self, api_data):
bid_const_cost_list = self.extract_value(api_data, "result", "baseEstiPrice", "bsamtPcsp")
if bid_const_cost_list is None:
# print("순공사원가 없음")
return {}
return {"const_cost":bid_const_cost_list}
# 건설산업기본법 적용대상여부
def mutual_text(self, api_data):
mutual_text = self.extract_value(api_data, "dmItemMap", "cbsarSeNm", default="")
if mutual_text is None and mutual_text == "" and mutual_text == " - ":
return ""
result = mutual_text.split("-")[0].strip()
return result
# 낙찰방법세부기준 /// 민간은 세부기준 정보 없음 /// 기타는 세부기준 정보 없음
def succls_detail_org(self, api_data):
succls_detail = self.extract_value(api_data, "dmItemMap", "scsbdMthdDtlsNm", default="")
succls_detail = self.Etl.safe_encode(succls_detail)
return succls_detail
# 투찰율 /// 민간은 투찰율 정보 없음 /// 기타는 모두 0으로 나오는것으로 확인, 확인시 공고들 기준
def pct_org(self, api_data):
pct = self.extract_value(api_data, "dmItemMap", "scsbdLwlmRat", default="")
return pct
def joint_method_org(self, api_data):
# 시설 기술용역 -> jintCtrtCmnMthoNm 제공
# 일반용역, 물품 -> jintSyddCmnMthoNm 미제공
# jintSyddCmnMthoNm 세팅 후 데이터 없으면 jintCtrtCmnMthoNm 세팅하여 처리
# 시설, 기술용역
joint_method = self.extract_value(api_data, "dmItemMap", "jintSyddCmnMthoNm", default="")
# 일반용역, 물품
if not joint_method:
joint_method = self.extract_value(api_data, "dmItemMap", "jintCtrtCmnMthoNm", default="")
return joint_method

View File

@ -0,0 +1,103 @@
import plugins.utils.scraplib as scraplib # 공통 라이브러리
import html
import sys
import os
import traceback
import logging
def handle_exceptions(func):
"""
모든 메서드에서 공통적으로 예외 처리를 수행하는 데코레이터
"""
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception as e:
logging.error("############## Wrapper Error Start ##############")
logging.error("############## Wrapper Error Start #!@#!@#!@#!@##")
logging.error("############## Wrapper Error Start ##############")
# 예외 발생 시 클래스 이름, 함수 이름과 예외 메시지 출력
class_name = args[0].__class__.__name__ if args else "알 수 없는 클래스"
notinum = getattr(args[0], 'tran_notinum', '알 수 없는 값')
logging.error("Wrapper 에러 =>공고번호:{} / 클래스:{} / 메소드:{} / 오류내용:{}".format(notinum, class_name, func.__name__, str(e)))
traceback_info = traceback.format_exc()
logging.error("\n")
logging.error(traceback_info)
logging.error("############## Wrapper Error End ##############")
logging.error("############## Wrapper Error End ##############")
logging.error("############## Wrapper Error End ##############")
# 에러 발생 시 None 반환
return None
return wrapper
class ExceptionMeta(type):
"""
모든 메서드에 handle_exceptions 데코레이터를 자동으로 적용하는 메타클래스
"""
def __new__(cls, name, bases, attrs):
for key, value in attrs.items():
if callable(value): # 메서드인 경우만
attrs[key] = handle_exceptions(value)
return super(ExceptionMeta, cls).__new__(cls, name, bases, attrs)
class TransformerHelper(metaclass=ExceptionMeta):
Util = scraplib.Util()
Etl = scraplib.Etl()
tran_bidtype = ""
tran_notinum = ""
def extract_value(self, data, key1, key2=None, key3=None, default=None):
"""
딕셔너리에서 1 또는 2 키의 값을 반환하는 함수.
key1: 1
key2: 2 (선택사항)
존재하지 않으면 None을 반환
"""
# 1차 키 확인
if key1 not in data or data[key1] is None:
# logging.error(key1+" 키가 존재하지 않음")
return default
# 3차 키가 전달된 경우 확인하고 값 반환
if key3 is not None:
if (key2 not in data[key1] or data[key1][key2] is None) or (key3 not in data[key1][key2] or data[key1][key2][key3] is None):
# logging.error(key1 + "." + key2 + "." + key3 + " 키가 존재하지 않음")
return default
if isinstance(data[key1][key2][key3], str):
return html.unescape(data[key1][key2][key3])
else:
return data[key1][key2][key3]
# 2차 키가 전달된 경우 확인하고 값 반환
if key2 is not None:
if key2 not in data[key1] or data[key1][key2] is None:
# logging.error(key1+"."+key2+" 키가 존재하지 않음")
return default
if isinstance(data[key1][key2], str):
return html.unescape(data[key1][key2])
else:
return data[key1][key2]
# 2차, 3차 키가 없으면 1차 키 값 반환
if isinstance(data[key1], str):
return html.unescape(data[key1])
else:
return data[key1]
"""
사용예시
# 두 개의 키 중 하나라도 값이 있는지 확인
partcode_element = self.extract_value(api_data, "dmItemMap", "intpLmtCn")
partcode_element_alt = self.extract_value(api_data, "dmItemMap", "intpLmtCp")
# 두 키 모두 값이 없을 경우 처리
if partcode_element is None and partcode_element_alt is None:
print("업종제한이 없음")
return []
"""

View File

@ -0,0 +1,82 @@
# 필요한 모듈
import pendulum # 날짜/시간 처리를 위한 권장 라이브러리 (datetime 대체 가능)
import logging
from airflow.models.dag import DAG # DAG 객체 정의를 위한 클래스
from airflow.operators.bash import BashOperator # Bash 명령어를 실행하는 오퍼레이터
from airflow.operators.python import PythonOperator # Python 함수를 실행하는 오퍼레이터
from airflow.operators.empty import EmptyOperator
from datetime import datetime
from airflow.models.variable import Variable
from test_group.tasks.test import Test
# 모든 Operator에 공통적으로 적용될 기본값들을 딕셔너리로 정의할 수 있습니다.
# 스캐쥴링 설정
scheduler = "0 0 * * *"
# args 설정
default_args = {
'owner': 'airflow_user', # DAG의 소유자 (관리/알림 목적)
"start_date": datetime(2025, 9, 5, 15, 0, 0), # 첫 실행 날짜
"depends_on_past": False, # 이전 DAG Run 실행 여부에 따른 의존성 설정
}
# DAG 정의
# 'with DAG(...) as dag:' 구문을 사용하면 이 블록 내에서 정의된 오퍼레이터들이 자동으로 'dag' 객체에 속하게 됩니다.
with DAG(
dag_id='test_dag', # DAG의 고유 식별자. Airflow UI에 표시됨. (필수)
description='임시 처리용 DAG', # DAG에 대한 설명 (UI에 표시됨)
schedule=scheduler, # DAG 실행 스케줄. None은 수동 실행(Manual Trigger)을 의미.
catchup=False, # True면 start_date부터 현재까지 놓친 스케줄을 모두 실행 (Backfill). 보통 False로 설정.
default_args=default_args, # 위에서 정의한 기본 인수 적용
max_active_runs=1,
tags=['임시'], # DAG를 분류하고 UI에서 필터링하기 위한 태그 목록
) as dag: # 이 DAG 인스턴스를 'dag' 변수로 사용
# dag 설명
dag.doc_md = """
임시 처리용 DAG
"""
# 임시 처리용 클래스
test = Test()
# Task(작업) 정의
# 각 Task는 Operator 클래스의 인스턴스로 생성됩니다.
####################################################################################################################
# Task: Start
####################################################################################################################
task_start = EmptyOperator(
task_id="task_start"
)
task_start.doc_md = f"""- Empty Task"""
####################################################################################################################
# Task1 : 임시 처리용 테스트
task_test = PythonOperator(
task_id='test',
python_callable=test.test,
op_kwargs = {
"exec_dt_str": "{{ data_interval_end }}"
},
)
task_test.doc_md = f"""
- 임시 처리용 테스트
"""
####################################################################################################################
####################################################################################################################
# Task: End
####################################################################################################################
task_end = EmptyOperator(
task_id="task_end"
)
task_end.doc_md = f"""- Empty Task"""
# 6. Task 의존성(실행 순서) 설정
task_start >> \
task_test >> \
task_end

View File

@ -0,0 +1,21 @@
from airflow.exceptions import AirflowException, AirflowSkipException, AirflowFailException
from airflow.models import Variable
from airflow.providers.mysql.hooks.mysql import MySqlHook
class Test():
# =======================================================================================================================================
# 임시 처리용 테스트
def test(self, exec_dt_str: str):
logging.info('============================== test start ==============================')
try:
# 기본 정보
exec_dt = CommonDatetime.make_datetime_kst_from_ts(exec_dt_str)
logging.info(f"exec_dt(): {exec_dt}")
except Exception as e:
logging.error(f"test() Exception: {str(e)}")
raise Exception(str(e))
finally :
logging.info('============================== test finish ==============================')

View File

@ -0,0 +1,278 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
# Basic Airflow cluster configuration for CeleryExecutor with Redis and PostgreSQL.
#
# WARNING: This configuration is for local development. Do not use it in a production deployment.
#
# This configuration supports basic configuration using environment variables or an .env file
# The following variables are supported:
#
# AIRFLOW_IMAGE_NAME - Docker image name used to run Airflow.
# Default: apache/airflow:2.9.3
# AIRFLOW_UID - User ID in Airflow containers
# Default: 50000
# AIRFLOW_PROJ_DIR - Base path to which all the files will be volumed.
# Default: .
# Those configurations are useful mostly in case of standalone testing/running Airflow in test/try-out mode
#
# _AIRFLOW_WWW_USER_USERNAME - Username for the administrator account (if requested).
# Default: airflow
# _AIRFLOW_WWW_USER_PASSWORD - Password for the administrator account (if requested).
# Default: airflow
# _PIP_ADDITIONAL_REQUIREMENTS - Additional PIP requirements to add when starting all containers.
# Use this option ONLY for quick checks. Installing requirements at container
# startup is done EVERY TIME the service is started.
# A better way is to build a custom image or extend the official image
# as described in https://airflow.apache.org/docs/docker-stack/build.html.
# Default: ''
#
# Feel free to modify this file to suit your needs.
---
networks: # <--- 이 섹션 추가 또는 수정
airflow_default: # Airflow 내부 통신용 기본 네트워크 (기존에 있다면 유지)
driver: bridge
db_network: # MySQL 컨테이너가 사용하는 네트워크 이름과 동일하게 지정
external: true # 이 네트워크가 다른 docker-compose 파일 또는 명령으로 이미 생성되었다고 가정
x-airflow-common: &airflow-common
build:
context: .
dockerfile: Dockerfile.airflow
networks: # <--- 모든 Airflow 서비스가 사용할 네트워크 지정
- airflow_default # Airflow 내부 서비스 간 통신용
- db_network # MySQL DB 접속용
environment: &airflow-common-env
AIRFLOW__CORE__EXECUTOR: CeleryExecutor
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW__CORE__FERNET_KEY:-$(python -c "from cryptography.fernet import Fernet; FERNET_KEY = Fernet.generate_key().decode(); print(FERNET_KEY)")}
AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION: "true"
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
AIRFLOW__CORE__DEFAULT_TIMEZONE: "Asia/Seoul" # Airflow 내부 처리 기준 KST
# Database Settings
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:airflow@postgres/airflow
# Celery Settings
AIRFLOW__CELERY__RESULT_BACKEND: db+postgresql://airflow:airflow@postgres/airflow
AIRFLOW__CELERY__BROKER_URL: redis://:@redis:6379/0
# Webserver Settings
AIRFLOW__WEBSERVER__DEFAULT_UI_TIMEZONE: "Asia/Seoul"
AIRFLOW__API__AUTH_BACKENDS: "airflow.api.auth.backend.basic_auth,airflow.api.auth.backend.session"
# Scheduler Settings
AIRFLOW__SCHEDULER__ENABLE_HEALTH_CHECK: "true"
# Logging Settings
AIRFLOW__LOGGING__LOG_FORMAT: "[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s"
# AIRFLOW__LOGGING__DEFAULT_DATE_FORMAT: '%Y-%m-%d %H:%M:%S%z' # 필요시 날짜 포맷 명시적 지정 고려
# PIP Requirements (handled by Dockerfile build)
_PIP_ADDITIONAL_REQUIREMENTS: ${_PIP_ADDITIONAL_REQUIREMENTS:-}
# Container OS Timezone
TZ: "Asia/Seoul" # Docker 컨테이너 OS 시간대 KST
volumes:
- ${AIRFLOW_PROJ_DIR:-.}/dags:/opt/airflow/dags
- ${AIRFLOW_PROJ_DIR:-.}/logs:/opt/airflow/logs
- ${AIRFLOW_PROJ_DIR:-.}/config:/opt/airflow/config
- ${AIRFLOW_PROJ_DIR:-.}/plugins:/opt/airflow/plugins
user: "${AIRFLOW_UID:-50000}:0"
depends_on: &airflow-common-depends-on
redis:
condition: service_healthy
postgres:
condition: service_healthy
services:
postgres:
image: postgres:13
environment:
POSTGRES_USER: airflow
POSTGRES_PASSWORD: airflow
POSTGRES_DB: airflow
TZ: "Asia/Seoul" # DB 컨테이너 OS 시간대
PGTZ: "Asia/Seoul" # PostgreSQL 서버 시간대 설정 추가
volumes:
- postgres-db-volume:/var/lib/postgresql/data
networks:
- airflow_default
healthcheck:
test: ["CMD", "pg_isready", "-U", "airflow"]
interval: 10s
retries: 5
start_period: 5s
ports:
- "5432:5432"
restart: always
redis:
# Redis is limited to 7.2-bookworm due to licencing change
# https://redis.io/blog/redis-adopts-dual-source-available-licensing/
image: redis:7.2-bookworm
expose:
- 6379
networks:
- airflow_default
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 30s
retries: 50
start_period: 30s
restart: always
airflow-webserver:
<<: *airflow-common
command: webserver
ports:
- "8080:8080"
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
restart: always
depends_on:
<<: *airflow-common-depends-on
airflow-init:
condition: service_completed_successfully
airflow-scheduler:
<<: *airflow-common
command: scheduler
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8974/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
restart: always
depends_on:
<<: *airflow-common-depends-on
airflow-init:
condition: service_completed_successfully
airflow-worker:
<<: *airflow-common
command: celery worker
healthcheck:
# yamllint disable rule:line-length
test:
- "CMD-SHELL"
- 'celery --app airflow.providers.celery.executors.celery_executor.app inspect ping -d "celery@$${HOSTNAME}" || celery --app airflow.executors.celery_executor.app inspect ping -d "celery@$${HOSTNAME}"'
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
environment:
<<: *airflow-common-env
# Required to handle warm shutdown of the celery workers properly
# See https://airflow.apache.org/docs/docker-stack/entrypoint.html#signal-propagation
DUMB_INIT_SETSID: "0"
restart: always
depends_on:
<<: *airflow-common-depends-on
airflow-init:
condition: service_completed_successfully
airflow-triggerer:
<<: *airflow-common
command: triggerer
healthcheck:
test:
[
"CMD-SHELL",
'airflow jobs check --job-type TriggererJob --hostname "$${HOSTNAME}"',
]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
restart: always
depends_on:
<<: *airflow-common-depends-on
airflow-init:
condition: service_completed_successfully
airflow-init:
<<: *airflow-common
entrypoint: /bin/bash
# yamllint disable rule:line-length
command:
- -c
- |
if [[ -z "${AIRFLOW_UID}" ]]; then
echo
echo -e "\033[1;33mWARNING!!!: AIRFLOW_UID not set!\e[0m"
# ... (rest of the init messages from your original file) ...
echo
fi
mkdir -p /sources/logs /sources/dags /sources/plugins
chown -R "${AIRFLOW_UID}:0" /sources/{logs,dags,plugins}
exec /entrypoint airflow version
# yamllint enable rule:line-length
environment:
<<: *airflow-common-env
_AIRFLOW_DB_MIGRATE: "true"
_AIRFLOW_WWW_USER_CREATE: "true"
_AIRFLOW_WWW_USER_USERNAME: ${_AIRFLOW_WWW_USER_USERNAME:-imai_master}
_AIRFLOW_WWW_USER_PASSWORD: ${_AIRFLOW_WWW_USER_PASSWORD:-imai240510!}
_PIP_ADDITIONAL_REQUIREMENTS: ""
user: "0:0"
volumes:
- ${AIRFLOW_PROJ_DIR:-.}:/sources
airflow-cli:
<<: *airflow-common
profiles:
- debug
environment:
<<: *airflow-common-env
CONNECTION_CHECK_MAX_COUNT: "0"
# Workaround for entrypoint issue. See: https://github.com/apache/airflow/issues/16252
command:
- bash
- -c
- airflow
# You can enable flower by adding "--profile flower" option e.g. docker-compose --profile flower up
# or by explicitly targeted on the command line e.g. docker-compose up flower.
# See: https://docs.docker.com/compose/profiles/
flower:
<<: *airflow-common
command: celery flower
profiles:
- flower
ports:
- "5555:5555"
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:5555/"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
restart: always
depends_on:
<<: *airflow-common-depends-on
airflow-init:
condition: service_completed_successfully
volumes:
postgres-db-volume:

View File

@ -1 +0,0 @@
""

View File

@ -0,0 +1,122 @@
FROM python:3.11
WORKDIR /app
# 기본 패키지 설치 (MySQL, 빌드 도구, 로케일)
RUN apt-get update && apt-get install -y \
default-libmysqlclient-dev \
build-essential \
pkg-config \
locales \
wget \
unzip \
ca-certificates \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Selenium 및 Chrome 관련 패키지 설치
RUN apt-get update && apt-get install -y \
libnss3 \
libxss1 \
libappindicator3-1 \
fonts-liberation \
libasound2 \
libnspr4 \
libdbus-1-3 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libpango-1.0-0 \
libcairo2 \
libatspi2.0-0 \
chromium \
chromium-driver \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# 폰트 관련 패키지 설치 (기본)
RUN apt-get update && apt-get install -y \
fontconfig \
fonts-dejavu-core \
fonts-liberation \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# 한글 폰트 설치 (최소한으로 안전하게)
RUN apt-get update && \
apt-get install -y fonts-dejavu && \
(apt-get install -y fonts-nanum || true) && \
(apt-get install -y fonts-noto-cjk || true) && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# ChromeDriver 설치
RUN wget https://storage.googleapis.com/chrome-for-testing-public/127.0.6533.88/linux64/chromedriver-linux64.zip \
&& unzip chromedriver-linux64.zip -d /usr/local/bin/ \
&& rm chromedriver-linux64.zip \
&& mv /usr/local/bin/chromedriver-linux64/chromedriver /usr/local/bin/chromedriver \
&& rm -rf /usr/local/bin/chromedriver-linux64
# MeCab 한국어 형태소 분석기 설치
RUN wget https://bitbucket.org/eunjeon/mecab-ko/downloads/mecab-0.996-ko-0.9.2.tar.gz \
&& tar -xzf mecab-0.996-ko-0.9.2.tar.gz \
&& cd mecab-0.996-ko-0.9.2 \
&& ./configure \
&& make \
&& make install \
&& ldconfig \
&& cd .. \
&& rm -rf mecab-0.996-ko-0.9.2*
# 한국어 로케일 설정
RUN sed -i -e 's/# ko_KR.UTF-8 UTF-8/ko_KR.UTF-8 UTF-8/' /etc/locale.gen \
&& locale-gen
# 한국어 로케일 환경 변수 설정
ENV LANG=ko_KR.UTF-8
ENV LANGUAGE=ko_KR:ko
ENV LC_ALL=ko_KR.UTF-8
ENV PYTHONIOENCODING=utf-8
ENV PYTHONENCODING=utf-8
# 폰트 캐시 업데이트 및 WeasyPrint 폰트 설정
RUN fc-cache -fv \
&& echo "폰트 설치 확인:" \
&& fc-list :lang=ko | head -10 \
&& echo "WeasyPrint 테스트용 HTML 생성 및 PDF 변환 테스트" \
&& printf '<!DOCTYPE html><html><head><meta charset="UTF-8"><style>body{font-family:"Nanum Gothic",NanumGothic,"Noto Sans CJK KR",sans-serif;}</style></head><body><h1>한글 테스트</h1><p>가나다라마바사</p></body></html>' > /tmp/test_korean.html
COPY ./app /app
# document_files 디렉토리 생성 및 sn3hcv 파일 변환기 복사
RUN mkdir -p /home/user1/yjh/fastapi/document_files
COPY ./document_files /home/user1/yjh/fastapi/document_files
# pip 업그레이드 & Selenium 별도 설치 (버전 고정, no-cache-dir로 캐시 피함, retry 추가)
RUN pip install --upgrade pip --no-cache-dir \
&& pip install selenium==4.10.0 webdriver-manager --no-cache-dir --retries 10 --timeout 60
# PDF 변환 도구들 설치 (WeasyPrint 및 pdfkit 지원)
RUN apt-get update && \
(apt-get install -y libpango-1.0-0 || true) && \
(apt-get install -y libpangocairo-1.0-0 || true) && \
(apt-get install -y libgdk-pixbuf2.0-0 || true) && \
(apt-get install -y wkhtmltopdf || true) && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# Python 패키지 설치
RUN pip install -r requirements.txt --no-cache-dir --retries 10 --timeout 60
# =============================================================================
# 포트 설정
# - 컨테이너 내부에서는 8097 포트 사용 (표준화)
# - 외부 포트는 docker-compose에서 매핑
# =============================================================================
EXPOSE 8097
# =============================================================================
# 서버 시작 명령어
# - uvicorn: ASGI 서버 (FastAPI 권장)
# - 워커 수: CPU 코어 수의 2-4배가 적절 (예: 4코어 = 8-16 워커)
# - 비동기 처리로 적은 워커로도 많은 동시 요청 처리 가능
# =============================================================================
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8097", "--workers", "4"]

View File

@ -0,0 +1,51 @@
services:
api:
build:
context: .
dockerfile: Dockerfile-fastapi
image: fastapi:v1
container_name: fastapi-dev-test
ports:
- "8096:8097" # 개발 테스트용 포트 (외부:8096 → 내부:8097)
volumes:
# 실시간 코드 반영을 위한 볼륨 마운트
- /home/user1/yjh/fastapi/app:/app
# 문서 파일 공유
- /home/user1/yjh/fastapi/document_files:/home/user1/yjh/fastapi/document_files
- /home/user1/yjh/rebbitMQ/logs:/rebbitMQ/logs
# rebbitMQ 문서 파일 마운트 (데이터 공유)
- /home/user1/yjh/rebbitMQ/document_files:/home/user1/yjh/rebbitMQ/document_files
- /home/user1/yjh/rebbitMQ/document_files_test:/home/user1/yjh/rebbitMQ/document_files_test
environment:
- SERVER_NAME=dev-test # 서버 식별용
- PORT=8097
- ENV=development # 개발 환경 표시
# 운영서버 접속정보
- MYSQL_HOST=222.234.3.20
- MYSQL_PORT=3306
- MYSQL_USER=i2
- MYSQL_PW=whdgus&cndaks
- MYSQL_DB=i2
# AI API 키
- GEMINI_API_KEY=AIzaSyDafw8OSsYEFBPvjtrB5kkwIDdJgaKjKyk
networks:
- db_network # 데이터베이스 네트워크 연결
restart: unless-stopped
# 개발 중에는 자동 재시작으로 편의성 제공
healthcheck:
# 개발 서버 상태 확인
test: ["CMD", "curl", "-f", "http://localhost:8097/docs"]
interval: 60s # 개발 환경이므로 느슨한 체크
timeout: 10s
retries: 3
start_period: 30s
# ===========================================================================
# 네트워크 설정
# - 외부 데이터베이스 네트워크에 연결
# - 다른 서비스들과 네트워크 분리
# ===========================================================================
networks:
db_network:
external: true # 외부에서 생성된 네트워크 사용

View File

@ -1 +0,0 @@
""

View File

@ -0,0 +1,3 @@
[AWS KEY INFO]
AWS_YOUR_ACCESS_KEY=AKIA1234567890EXAMPLE
AWS_YOUR_SECRET_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

File diff suppressed because it is too large Load Diff

View File

@ -10,18 +10,33 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-slot": "^1.2.3",
"@types/chart.js": "^2.9.41",
"chart.js": "^4.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.542.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",
"@tailwindcss/postcss": "^4.1.13",
"@types/node": "^24.3.0",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@vitejs/plugin-react-swc": "^4.0.0", "@vitejs/plugin-react-swc": "^4.0.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.33.0", "eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0", "globals": "^16.3.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.12",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.39.1", "typescript-eslint": "^8.39.1",
"vite": "^7.1.2" "vite": "^7.1.2"

View File

@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

View File

@ -1,35 +1,98 @@
import { useState } from 'react' import React, { useEffect } from 'react';
import reactLogo from './assets/react.svg' import { useAppStore } from './stores/useAppStore';
import viteLogo from '/vite.svg' import Header from './components/common/Header';
import './App.css' import Dashboard from './components/dashboard/Dashboard';
import ProductDetail from './components/detail/ProductDetail';
import Simulation from './components/simulation/Simulation';
import Settings from './components/settings/Settings';
import Loading from './components/common/Loading';
import AdBanner from './components/common/AdBanner';
function App() { function App() {
const [count, setCount] = useState(0) const { currentView, isLoading, theme } = useAppStore();
// 테마 적용 (body 클래스 변경)
useEffect(() => {
document.body.className = theme === 'dark' ? 'dark' : 'light';
}, [theme]);
// 현재 뷰에 따른 컴포넌트 렌더링
const renderCurrentView = () => {
switch (currentView) {
case 'dashboard':
return <Dashboard />;
case 'detail':
return <ProductDetail />;
case 'simulation':
return <Simulation />;
case 'settings':
return <Settings />;
default:
return <Dashboard />;
}
};
return ( return (
<> <div className="min-h-screen">
<div> {/* 사이드 광고 배너 */}
<a href="https://vite.dev" target="_blank"> <AdBanner size="side" position="left" />
<img src={viteLogo} className="logo" alt="Vite logo" /> <AdBanner size="side" position="right" />
</a>
<a href="https://react.dev" target="_blank"> {/* 헤더 상단 광고 */}
<img src={reactLogo} className="logo react" alt="React logo" /> <div className="px-4 pt-4">
</a> <AdBanner size="top" className="mb-4" />
</div> </div>
<h1>Vite + React</h1>
<div className="card"> <Header />
<button onClick={() => setCount((count) => count + 1)}>
count is {count} {/* 메인 상단 광고 */}
</button> <div className="px-4 mb-6">
<p> <AdBanner size="main" />
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div> </div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more <main className="pb-8 px-4">
</p> <div className="max-w-7xl mx-auto">
</> {isLoading ? (
) <div className="flex items-center justify-center min-h-96">
<Loading size="lg" text="로딩 중..." />
</div>
) : (
<div className="grid grid-cols-1 xl:grid-cols-4 gap-6">
{/* 메인 콘텐츠 */}
<div className="xl:col-span-3">
{renderCurrentView()}
</div>
{/* 사이드바 광고 */}
<div className="xl:col-span-1 space-y-4 hidden xl:block">
<AdBanner size="inline" />
<AdBanner size="inline" />
</div>
</div>
)}
</div>
</main>
{/* 하단 대형 배너 */}
<div className="px-4 mb-6">
<AdBanner size="bottom" />
</div>
{/* 푸터 */}
<footer className="glass rounded-2xl mx-4 mb-4 p-6 text-center">
<div className="flex flex-col md:flex-row md:items-center md:justify-between">
<div className={`text-sm mb-2 md:mb-0 ${theme === 'dark' ? 'text-white/60' : 'text-slate-600'}`}>
© 2024 I'm AI . , .
</div>
<div className={`flex items-center space-x-4 text-xs ${theme === 'dark' ? 'text-white/40' : 'text-slate-400'}`}>
<span>Made with React + TypeScript</span>
<span></span>
<span>Powered by AI</span>
</div>
</div>
</footer>
</div>
);
} }
export default App export default App;

View File

@ -0,0 +1,79 @@
import React from 'react';
interface AdBannerProps {
size: 'top' | 'main' | 'side' | 'bottom' | 'inline';
className?: string;
position?: 'left' | 'right';
}
const AdBanner: React.FC<AdBannerProps> = ({ size, className = '', position }) => {
const getAdContent = () => {
switch (size) {
case 'top':
return {
width: '100%',
height: '80px',
text: '헤더 상단 배너 광고 (1400x80px)'
};
case 'main':
return {
width: '100%',
height: '100px',
text: '메인 상단 배너 광고 (1400x100px)'
};
case 'side':
return {
width: '100%',
height: '100%',
text: '사이드 배너\n(120x600px)'
};
case 'bottom':
return {
width: '100%',
height: '200px',
text: '하단 대형 배너 광고 (1400x200px)'
};
case 'inline':
return {
width: '100%',
height: '200px',
text: '인라인 광고 (320x200px)'
};
default:
return {
width: '100%',
height: '100px',
text: '광고 배너'
};
}
};
const adContent = getAdContent();
const sizeClasses = {
top: 'h-20',
main: 'h-25',
side: 'h-full w-30',
bottom: 'h-50',
inline: 'h-50'
};
if (size === 'side') {
return (
<div className={`ad-banner ad-side-banner ad-side-${position} ${className}`}>
<div className="text-xs whitespace-pre-line">
{adContent.text}
</div>
</div>
);
}
return (
<div className={`ad-banner ${sizeClasses[size]} ${className}`} style={{ minHeight: adContent.height }}>
<div className="text-sm">
{adContent.text}
</div>
</div>
);
};
export default AdBanner;

View File

@ -0,0 +1,122 @@
import React from 'react';
import { Moon, Sun, Menu, X } from 'lucide-react';
import { useAppStore } from '../../stores/useAppStore';
const Header: React.FC = () => {
const { theme, setTheme, currentView, setCurrentView } = useAppStore();
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const toggleTheme = () => {
setTheme(theme === 'dark' ? 'light' : 'dark');
};
const toggleMenu = () => {
setIsMenuOpen(!isMenuOpen);
};
const handleNavigation = (view: 'dashboard' | 'detail' | 'simulation' | 'settings') => {
setCurrentView(view);
setIsMenuOpen(false);
};
return (
<header className="glass rounded-2xl mx-4 mt-4 mb-6 sticky top-4 z-50">
<div className="px-6 py-4">
<div className="flex items-center justify-between">
{/* 로고 */}
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-primary rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">AI</span>
</div>
<h1 className="text-xl font-bold bg-gradient-primary bg-clip-text text-transparent">
I'm AI
</h1>
</div>
{/* 데스크톱 네비게이션 */}
<nav className="hidden md:flex items-center space-x-6">
<button
onClick={() => handleNavigation('dashboard')}
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
currentView === 'dashboard'
? 'bg-primary text-white'
: 'text-white/70 hover:text-white hover:bg-white/10'
}`}
>
</button>
<button
onClick={() => handleNavigation('settings')}
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
currentView === 'settings'
? 'bg-primary text-white'
: 'text-white/70 hover:text-white hover:bg-white/10'
}`}
>
</button>
</nav>
{/* 테마 토글 및 모바일 메뉴 */}
<div className="flex items-center space-x-3">
{/* 테마 토글 */}
<button
onClick={toggleTheme}
className="p-2 rounded-lg hover:bg-white/10 transition-colors duration-200"
aria-label="테마 전환"
>
{theme === 'dark' ? (
<Sun className="w-5 h-5 text-yellow-400" />
) : (
<Moon className="w-5 h-5 text-blue-400" />
)}
</button>
{/* 모바일 메뉴 토글 */}
<button
onClick={toggleMenu}
className="md:hidden p-2 rounded-lg hover:bg-white/10 transition-colors duration-200"
aria-label="메뉴 열기/닫기"
>
{isMenuOpen ? (
<X className="w-5 h-5 text-white" />
) : (
<Menu className="w-5 h-5 text-white" />
)}
</button>
</div>
</div>
{/* 모바일 네비게이션 메뉴 */}
{isMenuOpen && (
<nav className="md:hidden mt-4 pt-4 border-t border-white/10">
<div className="flex flex-col space-y-2">
<button
onClick={() => handleNavigation('dashboard')}
className={`px-4 py-3 rounded-lg transition-all duration-200 text-left ${
currentView === 'dashboard'
? 'bg-primary text-white'
: 'text-white/70 hover:text-white hover:bg-white/10'
}`}
>
</button>
<button
onClick={() => handleNavigation('settings')}
className={`px-4 py-3 rounded-lg transition-all duration-200 text-left ${
currentView === 'settings'
? 'bg-primary text-white'
: 'text-white/70 hover:text-white hover:bg-white/10'
}`}
>
</button>
</div>
</nav>
)}
</div>
</header>
);
};
export default Header;

View File

@ -0,0 +1,32 @@
import React from 'react';
interface LoadingProps {
size?: 'sm' | 'md' | 'lg';
text?: string;
className?: string;
}
const Loading: React.FC<LoadingProps> = ({
size = 'md',
text = '로딩 중...',
className = ''
}) => {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12'
};
return (
<div className={`flex flex-col items-center justify-center space-y-2 ${className}`}>
<div
className={`${sizeClasses[size]} animate-spin rounded-full border-2 border-white/20 border-t-primary`}
/>
{text && (
<p className="text-sm text-white/70 animate-pulse">{text}</p>
)}
</div>
);
};
export default Loading;

View File

@ -0,0 +1,366 @@
import React, { useEffect, useState } from 'react';
import { Search, Filter, TrendingUp, TrendingDown, DollarSign, Activity } from 'lucide-react';
import { useAppStore } from '../../stores/useAppStore';
import ProductCard from './ProductCard';
import Loading from '../common/Loading';
import type { InvestProduct, AIAnalysis, ProductType, SortBy, SortOrder } from '../../types';
const Dashboard: React.FC = () => {
const {
products,
isLoading,
setProducts,
setAnalyses,
setLoading,
getLatestAnalysis
} = useAppStore();
// 로컬 상태
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState<ProductType>('all');
const [sortBy, setSortBy] = useState<SortBy>('name');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
// 목업 데이터 로드
useEffect(() => {
const loadMockData = async () => {
setLoading(true);
// 시뮬레이션 로딩 딜레이
await new Promise(resolve => setTimeout(resolve, 1500));
// 목업 투자 상품 데이터
const mockProducts: InvestProduct[] = [
{
code: 'BTC',
name: 'Bitcoin',
type: 'crypto',
currentPrice: 65420.50,
changeRate: 2.34,
changeAmount: 1498.20,
volume: 25640.32
},
{
code: 'ETH',
name: 'Ethereum',
type: 'crypto',
currentPrice: 3821.45,
changeRate: -1.56,
changeAmount: -60.32,
volume: 18945.67
},
{
code: 'AAPL',
name: 'Apple Inc.',
type: 'stock',
currentPrice: 189.25,
changeRate: 0.85,
changeAmount: 1.59,
volume: 1245678,
marketCap: 2980000000000
},
{
code: 'TSLA',
name: 'Tesla Inc.',
type: 'stock',
currentPrice: 248.50,
changeRate: -2.10,
changeAmount: -5.34,
volume: 892345,
marketCap: 789000000000
},
{
code: 'GOOGL',
name: 'Alphabet Inc.',
type: 'stock',
currentPrice: 142.80,
changeRate: 1.23,
changeAmount: 1.73,
volume: 567890,
marketCap: 1800000000000
}
];
// 목업 AI 분석 데이터
const mockAnalyses: Record<string, AIAnalysis[]> = {
'BTC': [{
requestId: 1,
investCode: 'BTC',
analysisDate: new Date().toISOString(),
entryPrice: 63000,
entryPriceReason: '기술적 지지선 근처에서 반등 신호 포착',
entryNewsIds: [1, 2],
targetPrice: 75000,
targetPriceReason: '기관 투자자들의 지속적인 유입 예상',
targetNewsIds: [3, 4],
stopLoss: 58000,
stopLossReason: '주요 지지선 하회 시 추가 하락 우려',
stopLossNewsIds: [5],
investScore: 85
}],
'ETH': [{
requestId: 2,
investCode: 'ETH',
analysisDate: new Date().toISOString(),
entryPrice: 3700,
entryPriceReason: '스테이킹 수익률 상승으로 매력도 증가',
entryNewsIds: [6, 7],
targetPrice: 4200,
targetPriceReason: '이더리움 2.0 업그레이드 기대감',
targetNewsIds: [8, 9],
stopLoss: 3400,
stopLossReason: '주요 이동평균선 하회 시 약세 전환',
stopLossNewsIds: [10],
investScore: 78
}],
'AAPL': [{
requestId: 3,
investCode: 'AAPL',
analysisDate: new Date().toISOString(),
entryPrice: 185,
entryPriceReason: 'iPhone 15 출시 효과 및 서비스 매출 성장',
entryNewsIds: [11, 12],
targetPrice: 210,
targetPriceReason: 'AI 관련 제품 출시 기대감 및 배당 증액',
targetNewsIds: [13, 14],
stopLoss: 175,
stopLossReason: '분기 실적 부진 시 주가 조정 불가피',
stopLossNewsIds: [15],
investScore: 82
}],
'TSLA': [{
requestId: 4,
investCode: 'TSLA',
analysisDate: new Date().toISOString(),
entryPrice: 240,
entryPriceReason: '전기차 시장 성장률 둔화에도 기술력 우위 유지',
entryNewsIds: [16, 17],
targetPrice: 280,
targetPriceReason: '완전 자율주행 기술 상용화 기대',
targetNewsIds: [18, 19],
stopLoss: 220,
stopLossReason: '경쟁 심화 및 마진 압박 우려',
stopLossNewsIds: [20],
investScore: 72
}],
'GOOGL': [{
requestId: 5,
investCode: 'GOOGL',
analysisDate: new Date().toISOString(),
entryPrice: 140,
entryPriceReason: 'AI 검색 기술 발전으로 경쟁력 강화',
entryNewsIds: [21, 22],
targetPrice: 165,
targetPriceReason: '클라우드 사업 성장 가속화 및 광고 매출 회복',
targetNewsIds: [23, 24],
stopLoss: 130,
stopLossReason: '규제 리스크 및 광고 시장 침체 우려',
stopLossNewsIds: [25],
investScore: 80
}]
};
setProducts(mockProducts);
// 각 상품별 분석 데이터 설정
Object.entries(mockAnalyses).forEach(([code, analyses]) => {
setAnalyses(code, analyses);
});
setLoading(false);
};
if (products.length === 0) {
loadMockData();
}
}, [products.length, setProducts, setAnalyses, setLoading]);
// 필터링 및 정렬된 상품 목록
const filteredAndSortedProducts = React.useMemo(() => {
let filtered = products.filter(product => {
// 검색 필터
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
product.code.toLowerCase().includes(searchTerm.toLowerCase());
// 타입 필터
const matchesType = filterType === 'all' || product.type === filterType;
return matchesSearch && matchesType;
});
// 정렬
filtered.sort((a, b) => {
let aValue: number | string, bValue: number | string;
switch (sortBy) {
case 'price':
aValue = a.currentPrice;
bValue = b.currentPrice;
break;
case 'change':
aValue = a.changeRate;
bValue = b.changeRate;
break;
case 'score':
const aAnalysis = getLatestAnalysis(a.code);
const bAnalysis = getLatestAnalysis(b.code);
aValue = aAnalysis?.investScore || 0;
bValue = bAnalysis?.investScore || 0;
break;
default:
aValue = a.name;
bValue = b.name;
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
return sortOrder === 'asc'
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
} else {
return sortOrder === 'asc'
? (aValue as number) - (bValue as number)
: (bValue as number) - (aValue as number);
}
});
return filtered;
}, [products, searchTerm, filterType, sortBy, sortOrder, getLatestAnalysis]);
// 시장 개요 계산
const marketSummary = React.useMemo(() => {
const totalProducts = products.length;
const gainers = products.filter(p => p.changeRate > 0).length;
const losers = products.filter(p => p.changeRate < 0).length;
const totalVolume = products.reduce((sum, p) => sum + p.volume, 0);
return { totalProducts, gainers, losers, totalVolume };
}, [products]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-96">
<Loading size="lg" text="투자 상품 데이터를 불러오는 중..." />
</div>
);
}
return (
<div className="space-y-6">
{/* 시장 개요 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="glass rounded-xl p-4">
<div className="flex items-center space-x-2">
<Activity className="w-5 h-5 text-primary" />
<div>
<p className="text-xs text-white/60"> </p>
<p className="text-lg font-bold text-white">{marketSummary.totalProducts}</p>
</div>
</div>
</div>
<div className="glass rounded-xl p-4">
<div className="flex items-center space-x-2">
<TrendingUp className="w-5 h-5 text-success" />
<div>
<p className="text-xs text-white/60"></p>
<p className="text-lg font-bold text-success">{marketSummary.gainers}</p>
</div>
</div>
</div>
<div className="glass rounded-xl p-4">
<div className="flex items-center space-x-2">
<TrendingDown className="w-5 h-5 text-danger" />
<div>
<p className="text-xs text-white/60"></p>
<p className="text-lg font-bold text-danger">{marketSummary.losers}</p>
</div>
</div>
</div>
<div className="glass rounded-xl p-4">
<div className="flex items-center space-x-2">
<DollarSign className="w-5 h-5 text-warning" />
<div>
<p className="text-xs text-white/60"> </p>
<p className="text-lg font-bold text-white">
{(marketSummary.totalVolume / 1000000).toFixed(1)}M
</p>
</div>
</div>
</div>
</div>
{/* 필터 및 검색 */}
<div className="glass rounded-2xl p-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
{/* 검색 */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/40" />
<input
type="text"
placeholder="상품명 또는 코드 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{/* 필터 */}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Filter className="w-4 h-4 text-white/60" />
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value as ProductType)}
className="bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all"></option>
<option value="crypto"></option>
<option value="stock"></option>
</select>
</div>
<div className="flex items-center space-x-2">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortBy)}
className="bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="name"></option>
<option value="price"></option>
<option value="change"></option>
<option value="score">AI점수순</option>
</select>
</div>
<button
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 transition-colors duration-200 text-white/60 hover:text-white"
>
{sortOrder === 'asc' ? '↑' : '↓'}
</button>
</div>
</div>
</div>
{/* 상품 그리드 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredAndSortedProducts.map((product) => (
<ProductCard
key={product.code}
product={product}
analysis={getLatestAnalysis(product.code)}
/>
))}
</div>
{/* 결과 없음 */}
{filteredAndSortedProducts.length === 0 && (
<div className="text-center py-12">
<p className="text-white/60"> .</p>
</div>
)}
</div>
);
};
export default Dashboard;

View File

@ -0,0 +1,141 @@
import React from 'react';
import { TrendingUp, TrendingDown, Star, BarChart3 } from 'lucide-react';
import type { InvestProduct, AIAnalysis } from '../../types';
import { useAppStore } from '../../stores/useAppStore';
interface ProductCardProps {
product: InvestProduct;
analysis?: AIAnalysis;
onClick?: () => void;
}
const ProductCard: React.FC<ProductCardProps> = ({ product, analysis, onClick }) => {
const { setSelectedProduct, setCurrentView } = useAppStore();
// 숫자 포맷팅 함수
const formatPrice = (price: number): string => {
if (price >= 1000000) {
return `${(price / 1000000).toFixed(1)}M`;
} else if (price >= 1000) {
return `${(price / 1000).toFixed(1)}K`;
}
return price.toLocaleString();
};
const formatPercent = (percent: number): string => {
return `${percent > 0 ? '+' : ''}${percent.toFixed(2)}%`;
};
// 카드 클릭 핸들러
const handleCardClick = () => {
setSelectedProduct(product);
setCurrentView('detail');
if (onClick) {
onClick();
}
};
// 등락률에 따른 색상 결정
const isPositive = product.changeRate >= 0;
const changeColor = isPositive ? 'text-success' : 'text-danger';
const TrendIcon = isPositive ? TrendingUp : TrendingDown;
// 매력도 점수에 따른 색상
const getScoreColor = (score: number) => {
if (score >= 80) return 'text-success';
if (score >= 60) return 'text-warning';
return 'text-danger';
};
return (
<div
onClick={handleCardClick}
className="glass rounded-2xl p-6 hover:bg-white/10 transition-all duration-300 cursor-pointer group hover:scale-[1.02] hover:shadow-glass"
>
{/* 헤더 */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-3">
{/* 코인/주식 아이콘 */}
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
product.type === 'crypto' ? 'bg-orange-500/20' : 'bg-blue-500/20'
}`}>
<span className="text-sm font-bold text-white">
{product.code.slice(0, 2)}
</span>
</div>
<div>
<h3 className="font-semibold text-white group-hover:text-primary transition-colors duration-200">
{product.name}
</h3>
<p className="text-xs text-white/50">{product.code}</p>
</div>
</div>
{/* AI 점수 */}
{analysis && (
<div className="flex items-center space-x-1 bg-white/5 rounded-full px-2 py-1">
<Star className={`w-3 h-3 ${getScoreColor(analysis.investScore)}`} />
<span className={`text-xs font-medium ${getScoreColor(analysis.investScore)}`}>
{analysis.investScore}
</span>
</div>
)}
</div>
{/* 가격 정보 */}
<div className="mb-4">
<div className="flex items-baseline space-x-2">
<span className="text-2xl font-bold text-white">
${formatPrice(product.currentPrice)}
</span>
<div className={`flex items-center space-x-1 ${changeColor}`}>
<TrendIcon className="w-4 h-4" />
<span className="text-sm font-medium">
{formatPercent(product.changeRate)}
</span>
</div>
</div>
<p className="text-xs text-white/50 mt-1">
: {formatPrice(product.volume)}
</p>
</div>
{/* AI 분석 요약 */}
{analysis && (
<div className="space-y-2 mb-4">
<div className="flex justify-between items-center">
<span className="text-xs text-white/70"></span>
<span className="text-xs font-medium text-info">
${formatPrice(analysis.entryPrice)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-white/70"></span>
<span className="text-xs font-medium text-warning">
${formatPrice(analysis.targetPrice)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-white/70"></span>
<span className="text-xs font-medium text-danger">
${formatPrice(analysis.stopLoss)}
</span>
</div>
</div>
)}
{/* 액션 버튼 */}
<div className="flex items-center justify-between pt-4 border-t border-white/10">
<span className="text-xs text-white/50">
{product.type === 'crypto' ? '암호화폐' : '주식'}
</span>
<div className="flex items-center space-x-1 text-primary group-hover:text-white transition-colors duration-200">
<BarChart3 className="w-4 h-4" />
<span className="text-xs font-medium"> </span>
</div>
</div>
</div>
);
};
export default ProductCard;

View File

@ -0,0 +1,290 @@
import React, { useEffect, useState } from 'react';
import { ArrowLeft, TrendingUp, TrendingDown, BarChart3, Clock, Newspaper, Star, Target, DollarSign } from 'lucide-react';
import { useAppStore } from '../../stores/useAppStore';
import type { InvestProduct, AIAnalysis } from '../../types';
const ProductDetail: React.FC = () => {
const {
selectedProduct,
getAnalysesByProduct,
setCurrentView,
setSelectedAnalysis,
selectedAnalysis
} = useAppStore();
const [selectedAnalysisIndex, setSelectedAnalysisIndex] = useState(0);
// 상품이 없으면 대시보드로 리다이렉트
useEffect(() => {
if (!selectedProduct) {
setCurrentView('dashboard');
}
}, [selectedProduct, setCurrentView]);
if (!selectedProduct) {
return null;
}
const analyses = getAnalysesByProduct(selectedProduct.code);
const currentAnalysis = analyses[selectedAnalysisIndex] || null;
// 뒤로가기 핸들러
const handleBack = () => {
setCurrentView('dashboard');
};
// 시뮬레이션 페이지로 이동
const handleSimulation = () => {
if (currentAnalysis) {
setSelectedAnalysis(currentAnalysis);
setCurrentView('simulation');
}
};
// 숫자 포맷팅
const formatPrice = (price: number): string => {
if (price >= 1000000) {
return `${(price / 1000000).toFixed(1)}M`;
} else if (price >= 1000) {
return `${(price / 1000).toFixed(1)}K`;
}
return price.toLocaleString();
};
const formatPercent = (percent: number): string => {
return `${percent > 0 ? '+' : ''}${percent.toFixed(2)}%`;
};
// 등락률에 따른 색상
const isPositive = selectedProduct.changeRate >= 0;
const changeColor = isPositive ? 'text-success' : 'text-danger';
const TrendIcon = isPositive ? TrendingUp : TrendingDown;
// AI 점수에 따른 색상
const getScoreColor = (score: number) => {
if (score >= 80) return 'text-success';
if (score >= 60) return 'text-warning';
return 'text-danger';
};
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="glass rounded-2xl p-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={handleBack}
className="p-2 rounded-lg hover:bg-white/10 transition-colors duration-200"
>
<ArrowLeft className="w-6 h-6 text-white/70" />
</button>
<div className="flex items-center space-x-4">
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
selectedProduct.type === 'crypto' ? 'bg-orange-500/20' : 'bg-blue-500/20'
}`}>
<span className="text-lg font-bold text-white">
{selectedProduct.code.slice(0, 2)}
</span>
</div>
<div>
<h1 className="text-2xl font-bold text-white">
{selectedProduct.name}
</h1>
<p className="text-white/60">{selectedProduct.code}</p>
</div>
</div>
</div>
<button
onClick={handleSimulation}
disabled={!currentAnalysis}
className="bg-primary hover:bg-primary-dark px-6 py-3 rounded-lg font-medium text-white transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
{/* 현재 가격 정보 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="glass rounded-xl p-4">
<div className="flex items-center space-x-2">
<DollarSign className="w-5 h-5 text-primary" />
<div>
<p className="text-xs text-white/60"></p>
<p className="text-xl font-bold text-white">
${formatPrice(selectedProduct.currentPrice)}
</p>
</div>
</div>
</div>
<div className="glass rounded-xl p-4">
<div className="flex items-center space-x-2">
<TrendIcon className={`w-5 h-5 ${changeColor}`} />
<div>
<p className="text-xs text-white/60"></p>
<p className={`text-xl font-bold ${changeColor}`}>
{formatPercent(selectedProduct.changeRate)}
</p>
</div>
</div>
</div>
<div className="glass rounded-xl p-4">
<div className="flex items-center space-x-2">
<BarChart3 className="w-5 h-5 text-info" />
<div>
<p className="text-xs text-white/60"></p>
<p className="text-xl font-bold text-white">
{formatPrice(selectedProduct.volume)}
</p>
</div>
</div>
</div>
<div className="glass rounded-xl p-4">
<div className="flex items-center space-x-2">
<Star className="w-5 h-5 text-warning" />
<div>
<p className="text-xs text-white/60">AI </p>
<p className={`text-xl font-bold ${currentAnalysis ? getScoreColor(currentAnalysis.investScore) : 'text-white/50'}`}>
{currentAnalysis ? currentAnalysis.investScore : 'N/A'}
</p>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* 차트 영역 */}
<div className="xl:col-span-2">
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-bold text-white mb-4"> </h2>
<div className="bg-white/5 rounded-lg p-8 text-center">
<BarChart3 className="w-16 h-16 text-white/40 mx-auto mb-4" />
<p className="text-white/60 text-lg">
TradingView
</p>
<p className="text-white/40 text-sm mt-2">
TradingView
</p>
</div>
</div>
</div>
{/* AI 분석 결과 */}
<div className="space-y-6">
{/* AI 분석 선택 */}
{analyses.length > 0 && (
<div className="glass rounded-2xl p-6">
<h3 className="text-lg font-bold text-white mb-4">AI </h3>
{analyses.length > 1 && (
<div className="mb-4">
<select
value={selectedAnalysisIndex}
onChange={(e) => setSelectedAnalysisIndex(Number(e.target.value))}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
{analyses.map((analysis, index) => (
<option key={analysis.requestId} value={index}>
{new Date(analysis.analysisDate).toLocaleString('ko-KR')} (: {analysis.investScore})
</option>
))}
</select>
</div>
)}
{currentAnalysis && (
<div className="space-y-4">
{/* 투자 지표 */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-white/70"></span>
<span className="font-medium text-info">
${formatPrice(currentAnalysis.entryPrice)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-white/70"></span>
<span className="font-medium text-warning">
${formatPrice(currentAnalysis.targetPrice)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-white/70"></span>
<span className="font-medium text-danger">
${formatPrice(currentAnalysis.stopLoss)}
</span>
</div>
<div className="flex justify-between items-center pt-2 border-t border-white/10">
<span className="text-white/70"> </span>
<span className={`font-bold text-lg ${getScoreColor(currentAnalysis.investScore)}`}>
{currentAnalysis.investScore}/100
</span>
</div>
</div>
{/* 분석 근거 */}
<div className="space-y-3">
<div className="bg-white/5 rounded-lg p-3">
<h4 className="font-medium text-info mb-2"> </h4>
<p className="text-sm text-white/80">{currentAnalysis.entryPriceReason}</p>
</div>
<div className="bg-white/5 rounded-lg p-3">
<h4 className="font-medium text-warning mb-2"> </h4>
<p className="text-sm text-white/80">{currentAnalysis.targetPriceReason}</p>
</div>
<div className="bg-white/5 rounded-lg p-3">
<h4 className="font-medium text-danger mb-2"> </h4>
<p className="text-sm text-white/80">{currentAnalysis.stopLossReason}</p>
</div>
</div>
{/* 분석 시간 */}
<div className="flex items-center space-x-2 text-xs text-white/50 pt-4 border-t border-white/10">
<Clock className="w-4 h-4" />
<span> : {new Date(currentAnalysis.analysisDate).toLocaleString('ko-KR')}</span>
</div>
</div>
)}
</div>
)}
{/* 관련 뉴스 */}
<div className="glass rounded-2xl p-6">
<div className="flex items-center space-x-2 mb-4">
<Newspaper className="w-5 h-5 text-primary" />
<h3 className="text-lg font-bold text-white"> </h3>
</div>
<div className="space-y-3">
{[1, 2, 3].map((item) => (
<div key={item} className="bg-white/5 rounded-lg p-3 hover:bg-white/10 transition-colors duration-200 cursor-pointer">
<h4 className="font-medium text-white text-sm mb-1">
{selectedProduct.name} #{item}
</h4>
<p className="text-xs text-white/60">
...
</p>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-white/40">출처: 뉴스소스</span>
<span className="text-xs text-white/40">1 </span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
export default ProductDetail;

View File

@ -0,0 +1,207 @@
import React from 'react';
import { ArrowLeft, Settings as SettingsIcon, Sun, Moon, Globe, Bell } from 'lucide-react';
import { useAppStore } from '../../stores/useAppStore';
const Settings: React.FC = () => {
const { theme, setTheme, setCurrentView } = useAppStore();
const handleBack = () => {
setCurrentView('dashboard');
};
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="glass rounded-2xl p-6">
<div className="flex items-center space-x-4">
<button
onClick={handleBack}
className="p-2 rounded-lg hover:bg-white/10 transition-colors duration-200"
>
<ArrowLeft className="w-6 h-6 text-white/70" />
</button>
<div className="flex items-center space-x-3">
<SettingsIcon className="w-8 h-8 text-primary" />
<h1 className="text-2xl font-bold text-white"></h1>
</div>
</div>
</div>
{/* 설정 카테고리 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 테마 설정 */}
<div className="glass rounded-2xl p-6">
<div className="flex items-center space-x-3 mb-4">
<Sun className="w-6 h-6 text-warning" />
<h2 className="text-xl font-bold text-white"> </h2>
</div>
<div className="space-y-3">
<label className="flex items-center justify-between cursor-pointer">
<span className="text-white/80"> </span>
<div className="relative">
<input
type="checkbox"
checked={theme === 'dark'}
onChange={(e) => setTheme(e.target.checked ? 'dark' : 'light')}
className="sr-only"
/>
<div className={`w-12 h-6 rounded-full transition-colors duration-200 ${
theme === 'dark' ? 'bg-primary' : 'bg-white/20'
}`}>
<div className={`w-5 h-5 bg-white rounded-full shadow-md transform transition-transform duration-200 mt-0.5 ${
theme === 'dark' ? 'translate-x-6 ml-1' : 'translate-x-1'
}`} />
</div>
</div>
</label>
<div className="flex items-center space-x-2 text-sm text-white/60">
{theme === 'dark' ? (
<>
<Moon className="w-4 h-4" />
<span> </span>
</>
) : (
<>
<Sun className="w-4 h-4" />
<span> </span>
</>
)}
</div>
</div>
</div>
{/* 언어 설정 */}
<div className="glass rounded-2xl p-6">
<div className="flex items-center space-x-3 mb-4">
<Globe className="w-6 h-6 text-info" />
<h2 className="text-xl font-bold text-white"> </h2>
</div>
<div className="space-y-3">
<select className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary">
<option value="ko"></option>
<option value="en">English</option>
<option value="zh"> ()</option>
</select>
<p className="text-sm text-white/60">
</p>
</div>
</div>
{/* 알림 설정 */}
<div className="glass rounded-2xl p-6">
<div className="flex items-center space-x-3 mb-4">
<Bell className="w-6 h-6 text-success" />
<h2 className="text-xl font-bold text-white"> </h2>
</div>
<div className="space-y-4">
<label className="flex items-center justify-between cursor-pointer">
<span className="text-white/80"> </span>
<div className="relative">
<input
type="checkbox"
defaultChecked
className="sr-only"
/>
<div className="w-12 h-6 bg-primary rounded-full">
<div className="w-5 h-5 bg-white rounded-full shadow-md transform translate-x-6 ml-1 mt-0.5" />
</div>
</div>
</label>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-white/80">AI </span>
<div className="relative">
<input
type="checkbox"
defaultChecked
className="sr-only"
/>
<div className="w-12 h-6 bg-primary rounded-full">
<div className="w-5 h-5 bg-white rounded-full shadow-md transform translate-x-6 ml-1 mt-0.5" />
</div>
</div>
</label>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-white/80"> </span>
<div className="relative">
<input
type="checkbox"
className="sr-only"
/>
<div className="w-12 h-6 bg-white/20 rounded-full">
<div className="w-5 h-5 bg-white rounded-full shadow-md transform translate-x-1 mt-0.5" />
</div>
</div>
</label>
</div>
</div>
{/* 기본 설정 */}
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-bold text-white mb-4"> </h2>
<div className="space-y-4">
<div>
<label className="block text-white/80 mb-2"> </label>
<select className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary">
<option value="1000000">100</option>
<option value="5000000">500</option>
<option value="10000000" selected>1,000</option>
<option value="50000000">5,000</option>
<option value="100000000">1</option>
</select>
</div>
<div>
<label className="block text-white/80 mb-2"> </label>
<select className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary">
<option value="1d">1</option>
<option value="1w" selected>1</option>
<option value="1m">1</option>
<option value="3m">3</option>
<option value="1y">1</option>
</select>
</div>
</div>
</div>
</div>
{/* 정보 섹션 */}
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-bold text-white mb-4"> </h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<p className="text-2xl font-bold text-primary mb-2">v1.0.0</p>
<p className="text-white/60"> </p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-success mb-2">10</p>
<p className="text-white/60"> </p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-warning mb-2">24/7</p>
<p className="text-white/60"> </p>
</div>
</div>
<div className="mt-6 pt-6 border-t border-white/10">
<p className="text-center text-sm text-white/50">
© 2024 I'm AI . , .
</p>
</div>
</div>
</div>
);
};
export default Settings;

View File

@ -0,0 +1,363 @@
import React, { useState, useEffect } from 'react';
import { ArrowLeft, Calculator, DollarSign, TrendingUp, TrendingDown, AlertTriangle, Target, Star } from 'lucide-react';
import { useAppStore } from '../../stores/useAppStore';
import type { SimulationResult } from '../../types';
const Simulation: React.FC = () => {
const {
selectedProduct,
selectedAnalysis,
investmentAmount,
setInvestmentAmount,
setCurrentView,
setSimulationResult,
simulationResult
} = useAppStore();
const [customAmount, setCustomAmount] = useState('');
const [isCustom, setIsCustom] = useState(false);
// 프리셋 투자 금액
const presetAmounts = [
{ label: '100만원', value: 1000000 },
{ label: '500만원', value: 5000000 },
{ label: '1,000만원', value: 10000000 },
{ label: '5,000만원', value: 50000000 },
{ label: '1억원', value: 100000000 }
];
// 상품이나 분석이 없으면 대시보드로 리다이렉트
useEffect(() => {
if (!selectedProduct || !selectedAnalysis) {
setCurrentView('dashboard');
}
}, [selectedProduct, selectedAnalysis, setCurrentView]);
// 시뮬레이션 계산
useEffect(() => {
if (selectedProduct && selectedAnalysis && investmentAmount > 0) {
calculateSimulation();
}
}, [selectedProduct, selectedAnalysis, investmentAmount]);
const calculateSimulation = () => {
if (!selectedProduct || !selectedAnalysis) return;
const currentPrice = selectedProduct.currentPrice;
const shares = investmentAmount / currentPrice;
// 목표가 달성 시
const targetValue = shares * selectedAnalysis.targetPrice;
const targetProfitAmount = targetValue - investmentAmount;
const targetProfitRate = (targetProfitAmount / investmentAmount) * 100;
// 손절가 도달 시
const stopLossValue = shares * selectedAnalysis.stopLoss;
const stopLossAmount = stopLossValue - investmentAmount;
const stopLossRate = (stopLossAmount / investmentAmount) * 100;
// 위험도 계산
const downside = Math.abs(stopLossRate);
const upside = targetProfitRate;
let riskLevel: 'LOW' | 'MEDIUM' | 'HIGH';
if (downside <= 10) riskLevel = 'LOW';
else if (downside <= 25) riskLevel = 'MEDIUM';
else riskLevel = 'HIGH';
// 기대수익률 (점수 가중)
const scoreWeight = selectedAnalysis.investScore / 100;
const expectedReturn = (targetProfitRate * scoreWeight + stopLossRate * (1 - scoreWeight));
const result: SimulationResult = {
investmentAmount,
selectedAnalysis,
targetProfitRate,
targetProfitAmount,
stopLossRate,
stopLossAmount,
riskLevel,
expectedReturn
};
setSimulationResult(result);
};
if (!selectedProduct || !selectedAnalysis) {
return null;
}
// 숫자 포맷팅
const formatPrice = (price: number): string => {
return new Intl.NumberFormat('ko-KR').format(Math.round(price));
};
const formatPercent = (percent: number): string => {
return `${percent > 0 ? '+' : ''}${percent.toFixed(2)}%`;
};
// 뒤로가기
const handleBack = () => {
setCurrentView('detail');
};
// 투자금액 설정
const handleAmountChange = (amount: number) => {
setInvestmentAmount(amount);
setIsCustom(false);
setCustomAmount('');
};
const handleCustomAmountChange = (value: string) => {
setCustomAmount(value);
setIsCustom(true);
const numValue = parseInt(value.replace(/[^0-9]/g, ''));
if (!isNaN(numValue) && numValue > 0) {
setInvestmentAmount(numValue);
}
};
// 위험도 색상
const getRiskColor = (level: string) => {
switch (level) {
case 'LOW': return 'text-success';
case 'MEDIUM': return 'text-warning';
case 'HIGH': return 'text-danger';
default: return 'text-white';
}
};
const getRiskText = (level: string) => {
switch (level) {
case 'LOW': return '낮음';
case 'MEDIUM': return '보통';
case 'HIGH': return '높음';
default: return '알 수 없음';
}
};
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="glass rounded-2xl p-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={handleBack}
className="p-2 rounded-lg hover:bg-white/10 transition-colors duration-200"
>
<ArrowLeft className="w-6 h-6 text-white/70" />
</button>
<div className="flex items-center space-x-4">
<Calculator className="w-8 h-8 text-primary" />
<div>
<h1 className="text-2xl font-bold text-white">
</h1>
<p className="text-white/60">
{selectedProduct.name} ({selectedProduct.code})
</p>
</div>
</div>
</div>
<div className="text-right">
<p className="text-white/60 text-sm">AI </p>
<p className="text-2xl font-bold text-warning">
{selectedAnalysis.investScore}/100
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* 투자 설정 */}
<div className="xl:col-span-1">
<div className="glass rounded-2xl p-6 space-y-6">
<h2 className="text-xl font-bold text-white"> </h2>
{/* 현재 선택된 AI 분석 정보 */}
<div className="bg-white/5 rounded-lg p-4 space-y-3">
<h3 className="font-medium text-white mb-3"> AI </h3>
<div className="flex justify-between text-sm">
<span className="text-white/70"></span>
<span className="text-info">${formatPrice(selectedAnalysis.entryPrice)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-white/70"></span>
<span className="text-warning">${formatPrice(selectedAnalysis.targetPrice)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-white/70"></span>
<span className="text-danger">${formatPrice(selectedAnalysis.stopLoss)}</span>
</div>
<div className="flex justify-between text-sm pt-2 border-t border-white/10">
<span className="text-white/70"> </span>
<span className="text-white/60 text-xs">
{new Date(selectedAnalysis.analysisDate).toLocaleDateString('ko-KR')}
</span>
</div>
</div>
{/* 투자 금액 설정 */}
<div>
<h3 className="font-medium text-white mb-3"> </h3>
<div className="grid grid-cols-1 gap-2">
{presetAmounts.map((preset) => (
<button
key={preset.value}
onClick={() => handleAmountChange(preset.value)}
className={`p-3 rounded-lg text-left transition-colors duration-200 ${
investmentAmount === preset.value && !isCustom
? 'bg-primary text-white'
: 'bg-white/5 text-white/80 hover:bg-white/10'
}`}
>
{preset.label}
</button>
))}
</div>
{/* 직접 입력 */}
<div className="mt-3">
<input
type="text"
placeholder="직접 입력 (원)"
value={customAmount}
onChange={(e) => handleCustomAmountChange(e.target.value)}
className="w-full p-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="mt-3 p-3 bg-white/5 rounded-lg">
<div className="flex justify-between items-center">
<span className="text-white/70"> </span>
<span className="font-bold text-white text-lg">
{formatPrice(investmentAmount)}
</span>
</div>
</div>
</div>
</div>
</div>
{/* 시뮬레이션 결과 */}
<div className="xl:col-span-2 space-y-6">
{simulationResult && (
<>
{/* 수익률 요약 */}
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-bold text-white mb-6"> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 목표가 달성 시나리오 */}
<div className="bg-success/10 border border-success/20 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-3">
<TrendingUp className="w-5 h-5 text-success" />
<h3 className="font-bold text-success"> </h3>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-white/70"></span>
<span className="text-white">{formatPrice(investmentAmount)}</span>
</div>
<div className="flex justify-between">
<span className="text-white/70"> </span>
<span className="text-success font-bold">
+{formatPrice(simulationResult.targetProfitAmount)}
</span>
</div>
<div className="flex justify-between text-lg">
<span className="text-white"></span>
<span className="text-success font-bold">
{formatPercent(simulationResult.targetProfitRate)}
</span>
</div>
</div>
</div>
{/* 손절가 도달 시나리오 */}
<div className="bg-danger/10 border border-danger/20 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-3">
<TrendingDown className="w-5 h-5 text-danger" />
<h3 className="font-bold text-danger"> </h3>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-white/70"></span>
<span className="text-white">{formatPrice(investmentAmount)}</span>
</div>
<div className="flex justify-between">
<span className="text-white/70"> </span>
<span className="text-danger font-bold">
{formatPrice(simulationResult.stopLossAmount)}
</span>
</div>
<div className="flex justify-between text-lg">
<span className="text-white"></span>
<span className="text-danger font-bold">
{formatPercent(simulationResult.stopLossRate)}
</span>
</div>
</div>
</div>
</div>
</div>
{/* 위험도 분석 */}
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-bold text-white mb-6"> </h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<div className="flex items-center justify-center space-x-2 mb-2">
<AlertTriangle className={`w-6 h-6 ${getRiskColor(simulationResult.riskLevel)}`} />
<span className="text-white/70"></span>
</div>
<p className={`text-2xl font-bold ${getRiskColor(simulationResult.riskLevel)}`}>
{getRiskText(simulationResult.riskLevel)}
</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-2 mb-2">
<Target className="w-6 h-6 text-info" />
<span className="text-white/70"></span>
</div>
<p className={`text-2xl font-bold ${simulationResult.expectedReturn >= 0 ? 'text-success' : 'text-danger'}`}>
{formatPercent(simulationResult.expectedReturn)}
</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-2 mb-2">
<Star className="w-6 h-6 text-warning" />
<span className="text-white/70">AI </span>
</div>
<p className="text-2xl font-bold text-warning">
{selectedAnalysis.investScore}/100
</p>
</div>
</div>
{/* 투자 가이드라인 */}
<div className="mt-6 p-4 bg-white/5 rounded-lg">
<h3 className="font-medium text-white mb-3"> </h3>
<ul className="space-y-2 text-sm text-white/80">
<li> AI , </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</>
)}
</div>
</div>
</div>
);
};
export default Simulation;

View File

@ -1,68 +1,206 @@
:root { @import "tailwindcss";
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark; @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none; * {
text-rendering: optimizeLegibility; box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
} }
body { body {
margin: 0; margin: 0;
display: flex; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
place-items: center; -webkit-font-smoothing: antialiased;
min-width: 320px; -moz-osx-font-smoothing: grayscale;
min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Dark Theme */
body.dark {
background: linear-gradient(135deg, #0f1419 0%, #1a1f2e 100%);
color: #ffffff;
}
/* Light Theme */
body.light {
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
color: #334155;
}
#root {
min-height: 100vh; min-height: 100vh;
} }
h1 { /* Glass Effect Utility */
font-size: 3.2em; .glass {
line-height: 1.1; backdrop-filter: blur(16px);
transition: all 0.3s ease;
} }
button { /* Dark Theme Glass */
border-radius: 8px; body.dark .glass {
border: 1px solid transparent; background: rgba(255, 255, 255, 0.05);
padding: 0.6em 1.2em; border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
} }
@media (prefers-color-scheme: light) { /* Light Theme Glass */
:root { body.light .glass {
color: #213547; background: rgba(255, 255, 255, 0.85);
background-color: #ffffff; border: 1px solid rgba(0, 0, 0, 0.08);
} box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
a:hover { }
color: #747bff;
} /* Advertisement Areas */
button { .ad-banner {
background-color: #f9f9f9; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
text-align: center;
opacity: 0.8;
transition: opacity 0.3s ease;
}
.ad-banner:hover {
opacity: 1;
}
.ad-side-banner {
position: fixed;
top: 50%;
transform: translateY(-50%);
width: 120px;
height: 600px;
z-index: 40;
}
.ad-side-left {
left: 20px;
}
.ad-side-right {
right: 20px;
}
/* Responsive ad hiding */
@media (max-width: 1400px) {
.ad-side-banner {
display: none;
} }
} }
/* Animation Classes */
.animate-pulse-slow {
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Theme-aware text colors */
body.dark .text-themed {
color: #ffffff;
}
body.light .text-themed {
color: #334155;
}
body.dark .text-themed-muted {
color: rgba(255, 255, 255, 0.6);
}
body.light .text-themed-muted {
color: #64748b;
}
/* Override white text in light mode */
body.light .text-white {
color: #334155 !important;
}
body.light .text-white\/60 {
color: #64748b !important;
}
body.light .text-white\/70 {
color: #475569 !important;
}
body.light .text-white\/80 {
color: #334155 !important;
}
body.light .text-white\/40 {
color: #94a3b8 !important;
}
body.light .text-white\/50 {
color: #64748b !important;
}
/* Input and form styling for light theme */
body.light input,
body.light select,
body.light button {
color: #334155;
}
body.light input::placeholder {
color: #94a3b8 !important;
}
/* Better contrast for light theme buttons */
body.light .bg-white\/5 {
background-color: rgba(0, 0, 0, 0.03) !important;
}
body.light .bg-white\/10 {
background-color: rgba(0, 0, 0, 0.06) !important;
}
body.light .hover\:bg-white\/10:hover {
background-color: rgba(0, 0, 0, 0.08) !important;
}
/* Border colors for light theme */
body.light .border-white\/10 {
border-color: rgba(0, 0, 0, 0.1) !important;
}
body.light .border-white\/20 {
border-color: rgba(0, 0, 0, 0.15) !important;
}

View File

@ -0,0 +1,112 @@
import { create } from "zustand";
import { devtools } from "zustand/middleware";
import type {
AppState,
InvestProduct,
AIAnalysis,
NewsArticle,
SimulationResult,
} from "../types";
interface AppStore extends AppState {
// Actions
setProducts: (products: InvestProduct[]) => void;
setSelectedProduct: (product: InvestProduct | null) => void;
setAnalyses: (investCode: string, analyses: AIAnalysis[]) => void;
setSelectedAnalysis: (analysis: AIAnalysis | null) => void;
setNews: (news: NewsArticle[]) => void;
setCurrentView: (
view: "dashboard" | "detail" | "simulation" | "settings"
) => void;
setTheme: (theme: "dark" | "light") => void;
setLoading: (loading: boolean) => void;
setSimulationResult: (result: SimulationResult | null) => void;
setInvestmentAmount: (amount: number) => void;
// Computed values
getProductByCode: (code: string) => InvestProduct | undefined;
getAnalysesByProduct: (code: string) => AIAnalysis[];
getLatestAnalysis: (code: string) => AIAnalysis | undefined;
}
export const useAppStore = create<AppStore>()(
devtools(
(set, get) => ({
// Initial state
products: [],
selectedProduct: null,
analyses: {},
selectedAnalysis: null,
news: [],
isLoading: false,
currentView: "dashboard",
theme: "dark",
simulationResult: null,
investmentAmount: 10000000, // 기본값 1천만원
// Actions
setProducts: (products) => set({ products }),
setSelectedProduct: (product) => set({ selectedProduct: product }),
setAnalyses: (investCode, analyses) =>
set((state) => ({
analyses: {
...state.analyses,
[investCode]: analyses,
},
})),
setSelectedAnalysis: (analysis) => set({ selectedAnalysis: analysis }),
setNews: (news) => set({ news }),
setCurrentView: (view) => set({ currentView: view }),
setTheme: (theme) => {
// 로컬 스토리지에 테마 저장
localStorage.setItem("theme", theme);
set({ theme });
},
setLoading: (loading) => set({ isLoading: loading }),
setSimulationResult: (result) => set({ simulationResult: result }),
setInvestmentAmount: (amount) => set({ investmentAmount: amount }),
// Computed values
getProductByCode: (code) => {
const { products } = get();
return products.find((product) => product.code === code);
},
getAnalysesByProduct: (code) => {
const { analyses } = get();
return analyses[code] || [];
},
getLatestAnalysis: (code) => {
const { analyses } = get();
const productAnalyses = analyses[code] || [];
if (productAnalyses.length === 0) return undefined;
// 최신 분석 결과 반환 (분석 날짜 기준)
return productAnalyses.sort(
(a, b) =>
new Date(b.analysisDate).getTime() -
new Date(a.analysisDate).getTime()
)[0];
},
}),
{
name: "app-store", // devtools에서 표시될 이름
}
)
);
// 테마 초기화 (로컬 스토리지에서 불러오기)
const savedTheme = localStorage.getItem("theme") as "dark" | "light" | null;
if (savedTheme) {
useAppStore.getState().setTheme(savedTheme);
}

View File

@ -0,0 +1,121 @@
// 투자 상품 타입 정의
export interface InvestProduct {
code: string; // BTC, ETH, AAPL, etc.
name: string; // 상품명
type: 'crypto' | 'stock'; // 타입
currentPrice: number; // 현재가
changeRate: number; // 등락률 (%)
changeAmount: number; // 등락액
volume: number; // 거래량
marketCap?: number; // 시가총액 (주식용)
}
// AI 분석 결과 타입
export interface AIAnalysis {
requestId: number;
investCode: string;
analysisDate: string;
// 현금 보유자용 데이터
entryPrice: number; // 진입가
entryPriceReason: string; // 진입가 이유
entryNewsIds: number[]; // 근거 뉴스 ID 리스트
// 투자상품 보유자용 데이터
targetPrice: number; // 목표가
targetPriceReason: string; // 목표가 이유
targetNewsIds: number[]; // 근거 뉴스 ID 리스트
stopLoss: number; // 손절가
stopLossReason: string; // 손절가 이유
stopLossNewsIds: number[]; // 근거 뉴스 ID 리스트
investScore: number; // 매력도 점수 (0-100)
}
// 뉴스 데이터 타입
export interface NewsArticle {
newsId: number;
investCode: string;
source: string; // 뉴스 출처
author?: string; // 작성자
publishedDate: string; // 발행 일시
url?: string; // 기사 URL
title: string; // 제목
summary: string; // 요약
}
// 캔들 데이터 타입
export interface CandleData {
investCode: string;
interval: 'MI' | 'HO' | 'DA' | 'WE' | 'MO'; // 분/시간/일/주/월
timestamp: string;
open: number; // 시가
high: number; // 고가
low: number; // 저가
close: number; // 종가
volume: number; // 거래량
quoteVolume?: number; // 거래대금
}
// 수익률 시뮬레이션 타입
export interface SimulationResult {
investmentAmount: number; // 투자 금액
selectedAnalysis: AIAnalysis; // 선택된 AI 분석
// 수익률 계산
targetProfitRate: number; // 목표가 달성시 수익률
targetProfitAmount: number; // 목표가 달성시 수익액
stopLossRate: number; // 손절가 도달시 손실률
stopLossAmount: number; // 손절가 도달시 손실액
// 위험도 평가
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH';
expectedReturn: number; // 기대 수익률
}
// API 응답 타입들
export interface MainPageResponse {
products: InvestProduct[];
latestAnalysis: Record<string, AIAnalysis>; // 투자상품별 최신 AI 분석
marketSummary: {
totalMarketCap: number;
gainersCount: number;
losersCount: number;
};
}
export interface ProductDetailResponse {
product: InvestProduct;
analyses: AIAnalysis[]; // 여러 AI 분석 결과
candleData: CandleData[]; // 차트용 캔들 데이터
relatedNews: NewsArticle[]; // 관련 뉴스
}
// 상태 관리용 타입들
export interface AppState {
// 투자 상품 관련
products: InvestProduct[];
selectedProduct: InvestProduct | null;
// AI 분석 관련
analyses: Record<string, AIAnalysis[]>; // 투자상품별 AI 분석 리스트
selectedAnalysis: AIAnalysis | null;
// 뉴스 관련
news: NewsArticle[];
// UI 상태
isLoading: boolean;
currentView: 'dashboard' | 'detail' | 'simulation' | 'settings';
theme: 'dark' | 'light';
// 시뮬레이션 관련
simulationResult: SimulationResult | null;
investmentAmount: number;
}
// 유틸리티 타입들
export type ProductType = 'crypto' | 'stock' | 'all';
export type SortBy = 'name' | 'price' | 'change' | 'score';
export type SortOrder = 'asc' | 'desc';

View File

@ -0,0 +1,682 @@
#!/bin/bash
# -*- coding: utf-8 -*-
# =============================================================================
# I'm AI 투자매니저 - AWS Lambda 배포 스크립트 (Rocky Linux 10용)
# =============================================================================
# 이 스크립트는 React 프론트엔드를 AWS Lambda로 자동 배포합니다.
# 아키텍처: CloudFront -> Lambda SSR + S3 Static Assets
# =============================================================================
set -e # 에러 발생 시 스크립트 중단
# =============================================================================
# 설정 변수
# =============================================================================
PROJECT_NAME="imai-invest-manager-ai"
AWS_REGION="ap-northeast-2" # 서울 리전
S3_BUCKET_NAME="${PROJECT_NAME}-static-assets"
LAMBDA_FUNCTION_NAME="${PROJECT_NAME}-ssr"
API_GATEWAY_NAME="${PROJECT_NAME}-api"
CLOUDFRONT_DISTRIBUTION_ID="" # 첫 배포 후 수동 설정 필요
# 버전 관리 설정
VERSION_DIR="dist_version"
BUILD_TIMESTAMP=$(date +"%y%m%d%H%M%S")
VERSIONED_DIST="${BUILD_TIMESTAMP}_dist"
# AWS 자격증명 설정 (우선순위: 환경변수 > 파라미터 > aws configure)
AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID}"
AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY}"
# Lambda 설정
LAMBDA_RUNTIME="nodejs20.x"
LAMBDA_HANDLER="index.handler"
LAMBDA_MEMORY=512
LAMBDA_TIMEOUT=30
# 색상 코드
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# =============================================================================
# 유틸리티 함수
# =============================================================================
log_info() {
echo -e "${BLUE}[정보]${NC} $1"
}
log_success() {
echo -e "${GREEN}[성공]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[경고]${NC} $1"
}
log_error() {
echo -e "${RED}[오류]${NC} $1"
}
load_env_file() {
ENV_FILE=".env"
if [ -f "$ENV_FILE" ]; then
log_info ".env 파일을 로드하고 있습니다..."
# .env 파일에서 AWS 키 추출
AWS_ACCESS_KEY_ID=$(grep "^AWS_YOUR_ACCESS_KEY=" "$ENV_FILE" | cut -d '=' -f2 | tr -d ' "'"'"'')
AWS_SECRET_ACCESS_KEY=$(grep "^AWS_YOUR_SECRET_KEY=" "$ENV_FILE" | cut -d '=' -f2 | tr -d ' "'"'"'')
if [ -n "$AWS_ACCESS_KEY_ID" ] && [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
export AWS_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY
log_success ".env 파일에서 AWS 키를 로드했습니다."
return 0
else
log_warning ".env 파일에 AWS 키가 올바르게 설정되지 않았습니다."
return 1
fi
else
log_warning ".env 파일이 존재하지 않습니다."
return 1
fi
}
setup_aws_credentials() {
log_info "AWS 자격증명 설정 중..."
# 1순위: .env 파일에서 로드
if load_env_file; then
log_info ".env 파일로부터 AWS 키를 사용합니다."
# 2순위: 스크립트 파라미터로 키가 전달된 경우
elif [ "$#" -ge 2 ]; then
export AWS_ACCESS_KEY_ID="$1"
export AWS_SECRET_ACCESS_KEY="$2"
log_info "스크립트 파라미터로부터 AWS 키를 설정했습니다."
# 3순위: 환경변수가 설정된 경우
elif [ -n "$AWS_ACCESS_KEY_ID" ] && [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
log_info "환경변수로부터 AWS 키를 사용합니다."
# 4순위: 대화형으로 키 입력받기
elif [ -z "$AWS_ACCESS_KEY_ID" ] || [ -z "$AWS_SECRET_ACCESS_KEY" ]; then
log_warning "AWS 자격증명이 설정되지 않았습니다."
echo -n "AWS Access Key ID를 입력하세요: "
read -r AWS_ACCESS_KEY_ID
echo -n "AWS Secret Access Key를 입력하세요: "
read -rs AWS_SECRET_ACCESS_KEY
echo "" # 줄바꿈
export AWS_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY
log_info "대화형으로 AWS 키를 설정했습니다."
fi
# AWS 자격증명 검증
if ! aws sts get-caller-identity &> /dev/null; then
log_error "AWS 자격증명 검증에 실패했습니다. .env 파일의 키를 확인해주세요."
log_error "현재 .env 파일 위치: $(pwd)/.env"
exit 1
fi
AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
log_success "AWS 계정 인증 완료: $AWS_ACCOUNT_ID"
}
check_dependencies() {
log_info "의존성 확인 중..."
# AWS CLI 확인
if ! command -v aws &> /dev/null; then
log_error "AWS CLI가 설치되어 있지 않습니다. 먼저 설치해주세요."
log_info "설치: curl \"https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip\" -o \"awscliv2.zip\" && unzip awscliv2.zip && sudo ./aws/install"
exit 1
fi
# Node.js 확인
if ! command -v node &> /dev/null; then
log_error "Node.js가 설치되어 있지 않습니다. 먼저 설치해주세요."
log_info "설치: sudo dnf module install nodejs:20/common"
exit 1
fi
# npm 확인
if ! command -v npm &> /dev/null; then
log_error "npm이 설치되어 있지 않습니다. 먼저 설치해주세요."
exit 1
fi
# zip 확인
if ! command -v zip &> /dev/null; then
log_error "zip이 설치되어 있지 않습니다. 먼저 설치해주세요."
log_info "설치: sudo dnf install -y zip unzip"
exit 1
fi
log_success "모든 의존성이 확인되었습니다."
}
# =============================================================================
# 빌드 함수
# =============================================================================
build_frontend() {
log_info "프론트엔드 빌드 시작..."
# package.json 확인
if [ ! -f "package.json" ]; then
log_error "package.json 파일을 찾을 수 없습니다. 올바른 디렉토리에서 실행하세요."
exit 1
fi
# 의존성 설치
log_info "NPM 패키지 설치 중..."
npm ci
# 빌드 실행
log_info "Vite 빌드 실행 중..."
npm run build
# dist 폴더 확인
if [ ! -d "dist" ]; then
log_error "빌드 실패: dist 폴더가 생성되지 않았습니다."
exit 1
fi
log_success "프론트엔드 빌드가 완료되었습니다."
# 버전별 dist 디렉토리 생성 및 백업
create_version_backup
}
create_version_backup() {
log_info "빌드 버전 백업 생성 중..."
# dist_version 디렉토리 생성
mkdir -p "$VERSION_DIR"
# 기존 dist를 타임스탬프명으로 복사
if [ -d "dist" ]; then
cp -r dist "$VERSION_DIR/$VERSIONED_DIST"
log_success "빌드 버전이 백업되었습니다: $VERSION_DIR/$VERSIONED_DIST"
# 버전 정보 파일 생성
cat > "$VERSION_DIR/$VERSIONED_DIST/VERSION_INFO.txt" << EOF
빌드 시간: $(date '+%Y-%m-%d %H:%M:%S')
빌드 버전: $VERSIONED_DIST
프로젝트: $PROJECT_NAME
AWS 리전: $AWS_REGION
빌드 명령: npm run build
EOF
log_info "버전 정보가 저장되었습니다: $VERSION_DIR/$VERSIONED_DIST/VERSION_INFO.txt"
# 기존 버전들 확인
VERSION_COUNT=$(ls -1 "$VERSION_DIR" | wc -l)
log_info "$VERSION_COUNT 개의 빌드 버전이 관리되고 있습니다."
# 10개 이상의 버전이 있으면 오래된 것 삭제
if [ "$VERSION_COUNT" -gt 10 ]; then
OLD_VERSIONS=$(ls -1t "$VERSION_DIR" | tail -n +11)
for old_ver in $OLD_VERSIONS; do
rm -rf "$VERSION_DIR/$old_ver"
log_warning "오래된 버전을 삭제했습니다: $old_ver"
done
fi
else
log_error "dist 폴더가 존재하지 않습니다."
exit 1
fi
}
create_lambda_package() {
log_info "Lambda 패키지 생성 중..."
# 임시 디렉토리 생성
TEMP_DIR="temp_lambda"
rm -rf $TEMP_DIR
mkdir -p $TEMP_DIR
# Lambda 핸들러 생성
cat > $TEMP_DIR/index.js << 'EOF'
const fs = require('fs');
const path = require('path');
// HTML 템플릿 로드
const htmlTemplate = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
exports.handler = async (event) => {
try {
// CloudFront에서 오는 요청 처리
const request = event.Records ? event.Records[0].cf.request : event;
// 기본 HTML 응답
const response = {
status: '200',
statusDescription: 'OK',
headers: {
'content-type': [{
key: 'Content-Type',
value: 'text/html; charset=utf-8'
}],
'cache-control': [{
key: 'Cache-Control',
value: 'public, max-age=300'
}],
'x-content-type-options': [{
key: 'X-Content-Type-Options',
value: 'nosniff'
}],
'x-frame-options': [{
key: 'X-Frame-Options',
value: 'DENY'
}],
'x-xss-protection': [{
key: 'X-XSS-Protection',
value: '1; mode=block'
}]
},
body: htmlTemplate
};
return response;
} catch (error) {
console.error('Lambda Error:', error);
return {
status: '500',
statusDescription: 'Internal Server Error',
headers: {
'content-type': [{
key: 'Content-Type',
value: 'text/plain'
}]
},
body: 'Internal Server Error'
};
}
};
EOF
# package.json 생성
cat > $TEMP_DIR/package.json << EOF
{
"name": "lambda-ssr",
"version": "1.0.0",
"main": "index.js",
"dependencies": {}
}
EOF
# HTML 파일 복사 (정적 에셋 경로 수정)
cp dist/index.html $TEMP_DIR/
# S3 경로로 정적 에셋 경로 수정
sed -i "s|/assets/|/static-assets/assets/|g" $TEMP_DIR/index.html
sed -i "s|href=\"/|href=\"/static-assets/|g" $TEMP_DIR/index.html
sed -i "s|src=\"/|src=\"/static-assets/|g" $TEMP_DIR/index.html
# Lambda 패키지 생성
cd $TEMP_DIR
zip -r ../lambda-function.zip .
cd ..
# 임시 폴더 정리
rm -rf $TEMP_DIR
log_success "Lambda 패키지가 생성되었습니다."
}
# =============================================================================
# AWS 리소스 생성/업데이트 함수
# =============================================================================
create_s3_bucket() {
log_info "S3 버킷 생성 중..."
# 버킷 존재 확인
if aws s3api head-bucket --bucket $S3_BUCKET_NAME 2>/dev/null; then
log_warning "S3 버킷이 이미 존재합니다: $S3_BUCKET_NAME"
else
# 버킷 생성
aws s3api create-bucket \
--bucket $S3_BUCKET_NAME \
--region $AWS_REGION \
--create-bucket-configuration LocationConstraint=$AWS_REGION
# 퍼블릭 액세스 차단 해제 (정적 에셋용)
aws s3api delete-public-access-block --bucket $S3_BUCKET_NAME
# 버킷 정책 설정
cat > bucket-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::$S3_BUCKET_NAME/*"
}
]
}
EOF
aws s3api put-bucket-policy --bucket $S3_BUCKET_NAME --policy file://bucket-policy.json
rm bucket-policy.json
log_success "S3 버킷이 생성되었습니다: $S3_BUCKET_NAME"
fi
}
upload_static_assets() {
log_info "정적 에셋 S3 업로드 중..."
# 정적 에셋 업로드 (cache-control 헤더 설정)
aws s3 sync dist/ s3://$S3_BUCKET_NAME/static-assets/ \
--cache-control "public, max-age=31536000" \
--exclude "index.html"
# 특별 파일들 (짧은 캐시)
if [ -f "dist/vite.svg" ]; then
aws s3 cp dist/vite.svg s3://$S3_BUCKET_NAME/static-assets/ \
--cache-control "public, max-age=86400"
fi
log_success "정적 에셋이 S3에 업로드되었습니다."
}
create_lambda_role() {
log_info "Lambda IAM 역할 확인/생성 중..."
ROLE_NAME="${PROJECT_NAME}-lambda-role"
# 역할 존재 확인
if aws iam get-role --role-name $ROLE_NAME 2>/dev/null; then
log_warning "IAM 역할이 이미 존재합니다: $ROLE_NAME"
echo "arn:aws:iam::$(aws sts get-caller-identity --query Account --output text):role/$ROLE_NAME"
return
fi
# 신뢰 정책 생성
cat > trust-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": [
"lambda.amazonaws.com",
"edgelambda.amazonaws.com"
]
},
"Action": "sts:AssumeRole"
}
]
}
EOF
# 역할 생성
aws iam create-role \
--role-name $ROLE_NAME \
--assume-role-policy-document file://trust-policy.json
# 권한 정책 연결
aws iam attach-role-policy \
--role-name $ROLE_NAME \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
# S3 읽기 권한 추가
cat > s3-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject"
],
"Resource": "arn:aws:s3:::$S3_BUCKET_NAME/*"
}
]
}
EOF
aws iam put-role-policy \
--role-name $ROLE_NAME \
--policy-name S3ReadPolicy \
--policy-document file://s3-policy.json
rm trust-policy.json s3-policy.json
# 역할이 생성될 때까지 대기
sleep 10
ROLE_ARN="arn:aws:iam::$(aws sts get-caller-identity --query Account --output text):role/$ROLE_NAME"
log_success "IAM 역할이 생성되었습니다: $ROLE_ARN"
echo $ROLE_ARN
}
deploy_lambda() {
log_info "Lambda 함수 배포 중..."
ROLE_ARN=$(create_lambda_role)
# 함수 존재 확인
if aws lambda get-function --function-name $LAMBDA_FUNCTION_NAME 2>/dev/null; then
log_info "기존 Lambda 함수 업데이트 중..."
# 함수 코드 업데이트
aws lambda update-function-code \
--function-name $LAMBDA_FUNCTION_NAME \
--zip-file fileb://lambda-function.zip
# 함수 구성 업데이트
aws lambda update-function-configuration \
--function-name $LAMBDA_FUNCTION_NAME \
--runtime $LAMBDA_RUNTIME \
--handler $LAMBDA_HANDLER \
--memory-size $LAMBDA_MEMORY \
--timeout $LAMBDA_TIMEOUT
else
log_info "새 Lambda 함수 생성 중..."
# 함수 생성
aws lambda create-function \
--function-name $LAMBDA_FUNCTION_NAME \
--runtime $LAMBDA_RUNTIME \
--role $ROLE_ARN \
--handler $LAMBDA_HANDLER \
--zip-file fileb://lambda-function.zip \
--memory-size $LAMBDA_MEMORY \
--timeout $LAMBDA_TIMEOUT \
--description "I'm AI Invest Manager Frontend SSR"
fi
# Lambda 함수가 활성화될 때까지 대기
aws lambda wait function-active --function-name $LAMBDA_FUNCTION_NAME
log_success "Lambda 함수가 배포되었습니다."
}
create_api_gateway() {
log_info "API Gateway 생성 중..."
# API Gateway 존재 확인
API_ID=$(aws apigateway get-rest-apis --query "items[?name=='$API_GATEWAY_NAME'].id" --output text)
if [ -n "$API_ID" ] && [ "$API_ID" != "None" ]; then
log_warning "API Gateway가 이미 존재합니다: $API_ID"
else
# API Gateway 생성
API_ID=$(aws apigateway create-rest-api \
--name $API_GATEWAY_NAME \
--description "I'm AI Invest Manager API" \
--query 'id' --output text)
log_info "API Gateway ID: $API_ID"
# 루트 리소스 가져오기
ROOT_RESOURCE_ID=$(aws apigateway get-resources \
--rest-api-id $API_ID \
--query 'items[?path==`/`].id' --output text)
# Proxy 리소스 생성
RESOURCE_ID=$(aws apigateway create-resource \
--rest-api-id $API_ID \
--parent-id $ROOT_RESOURCE_ID \
--path-part '{proxy+}' \
--query 'id' --output text)
# Lambda 통합을 위한 ARN
LAMBDA_ARN="arn:aws:lambda:$AWS_REGION:$(aws sts get-caller-identity --query Account --output text):function:$LAMBDA_FUNCTION_NAME"
# ANY 메서드 생성
aws apigateway put-method \
--rest-api-id $API_ID \
--resource-id $RESOURCE_ID \
--http-method ANY \
--authorization-type NONE
# Lambda 통합 설정
aws apigateway put-integration \
--rest-api-id $API_ID \
--resource-id $RESOURCE_ID \
--http-method ANY \
--type AWS_PROXY \
--integration-http-method POST \
--uri "arn:aws:apigateway:$AWS_REGION:lambda:path/2015-03-31/functions/$LAMBDA_ARN/invocations"
# Lambda 권한 추가
aws lambda add-permission \
--function-name $LAMBDA_FUNCTION_NAME \
--statement-id apigateway-invoke \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:$AWS_REGION:$(aws sts get-caller-identity --query Account --output text):$API_ID/*/*/*" \
2>/dev/null || true
# 배포
aws apigateway create-deployment \
--rest-api-id $API_ID \
--stage-name prod
log_success "API Gateway가 생성되었습니다: $API_ID"
fi
API_URL="https://$API_ID.execute-api.$AWS_REGION.amazonaws.com/prod"
log_info "API Gateway URL: $API_URL"
}
invalidate_cloudfront() {
if [ -n "$CLOUDFRONT_DISTRIBUTION_ID" ]; then
log_info "CloudFront 캐시 무효화 중..."
INVALIDATION_ID=$(aws cloudfront create-invalidation \
--distribution-id $CLOUDFRONT_DISTRIBUTION_ID \
--paths "/*" \
--query 'Invalidation.Id' --output text)
log_success "CloudFront 무효화 요청이 생성되었습니다: $INVALIDATION_ID"
else
log_warning "CLOUDFRONT_DISTRIBUTION_ID가 설정되지 않았습니다. 수동으로 CloudFront를 설정해주세요."
fi
}
cleanup() {
log_info "임시 파일 정리 중..."
rm -f lambda-function.zip
log_success "정리가 완료되었습니다."
}
# =============================================================================
# 메인 실행 함수
# =============================================================================
show_usage() {
echo "사용법: $0 [AWS_ACCESS_KEY_ID] [AWS_SECRET_ACCESS_KEY]"
echo ""
echo "AWS 자격증명 제공 방법 (우선순위 순):"
echo "1. .env 파일: 프로젝트 루트의 .env 파일에서 자동 로드"
echo "2. 스크립트 파라미터: ./start_dist.sh ACCESS_KEY SECRET_KEY"
echo "3. 환경변수: export AWS_ACCESS_KEY_ID=... && export AWS_SECRET_ACCESS_KEY=..."
echo "4. 대화형 입력: 스크립트 실행 중 키 입력"
echo "5. AWS CLI 프로필: aws configure로 미리 설정"
echo ""
echo ".env 파일 형식:"
echo " [AWS KEY INFO]"
echo " AWS_YOUR_ACCESS_KEY=AKIA1234567890EXAMPLE"
echo " AWS_YOUR_SECRET_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
echo ""
echo "예제:"
echo " # .env 파일 사용 (권장)"
echo " ./start_dist.sh"
echo ""
echo " # 파라미터 직접 전달"
echo " ./start_dist.sh AKIA... wJalr..."
echo ""
}
main() {
# 도움말 표시
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
show_usage
exit 0
fi
log_info "=== I'm AI 투자매니저 AWS Lambda 배포 시작 ==="
log_info "프로젝트: $PROJECT_NAME"
log_info "AWS 리전: $AWS_REGION"
log_info "현재 디렉토리: $(pwd)"
log_info "빌드 버전: $VERSIONED_DIST"
echo ""
# 의존성 확인
check_dependencies
# AWS 자격증명 설정
setup_aws_credentials "$@"
# 빌드 및 패키징
build_frontend
create_lambda_package
# AWS 리소스 생성/업데이트
create_s3_bucket
upload_static_assets
deploy_lambda
create_api_gateway
# 캐시 무효화
invalidate_cloudfront
# 정리
cleanup
echo ""
log_success "=== 배포가 완료되었습니다! ==="
log_info "빌드 버전: $VERSIONED_DIST"
log_info "백업 위치: $VERSION_DIR/$VERSIONED_DIST"
log_info "S3 버킷: https://s3.console.aws.amazon.com/s3/buckets/$S3_BUCKET_NAME"
log_info "Lambda 함수: https://$AWS_REGION.console.aws.amazon.com/lambda/home?region=$AWS_REGION#/functions/$LAMBDA_FUNCTION_NAME"
if [ -n "$API_ID" ]; then
log_info "API Gateway: https://$API_ID.execute-api.$AWS_REGION.amazonaws.com/prod"
fi
echo ""
log_warning "CloudFront 설정이 필요합니다:"
log_info "1. AWS Console에서 CloudFront Distribution 생성"
log_info "2. Origin: API Gateway (SSR) + S3 (Static Assets)"
log_info "3. CLOUDFRONT_DISTRIBUTION_ID 변수에 Distribution ID 설정"
echo ""
log_success "배포가 성공적으로 완료되었습니다."
}
# 스크립트 실행
main "$@"

View File

@ -0,0 +1,43 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
background: {
DEFAULT: '#0f1419',
secondary: '#1a1f2e',
},
primary: {
DEFAULT: '#667eea',
dark: '#764ba2',
},
success: '#22c55e',
danger: '#ef4444',
info: '#3b82f6',
warning: '#f59e0b',
// Light theme colors
light: {
bg: '#f1f5f9',
secondary: '#e2e8f0',
text: '#334155',
muted: '#64748b',
},
},
backgroundImage: {
'gradient-primary': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'gradient-background': 'linear-gradient(135deg, #0f1419 0%, #1a1f2e 100%)',
},
boxShadow: {
'glass': '0 8px 32px rgba(0, 0, 0, 0.3)',
},
backdropBlur: {
'glass': '16px',
},
},
},
plugins: [],
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -3,20 +3,18 @@
## 📁 파일 구조 ## 📁 파일 구조
### 🌟 메인 프로토타입 파일들 ### 🌟 메인 프로토타입 파일들
- **`index.html`** - 완전한 통합 프로토타입 (로딩 + 페이지 전환 + 광고) - **`index.html`** - 완전한 통합 프로토타입 (로딩 + 페이지 전환 + 광고)
- **`dashboard.html`** - 메인 대시보드 (깔끔한 단일 페이지) - **`dashboard.html`** - 메인 대시보드 (깔끔한 단일 페이지)
- **`detail.html`** - 투자상품 상세 분석 페이지 - **`detail.html`** - 투자상품 상세 분석 페이지
- **`simulation.html`** - 수익률 시뮬레이션 페이지 - **`simulation.html`** - 수익률 시뮬레이션 페이지
### 📱 기존 앱 설계 파일들
- `app_01.html` ~ `app_04.html` - 모바일 앱 프로토타입
- `web_01.html`, `web_03.html`, `web_04.html` - 웹 버전 초기 설계
--- ---
## 🎯 메인 파일 설명 ## 🎯 메인 파일 설명
### 1. **index.html** - 완전한 프로토타입 ### 1. **index.html** - 완전한 프로토타입
- ✨ 로딩 애니메이션 포함 - ✨ 로딩 애니메이션 포함
- 🔄 페이지 전환 기능 (SPA 방식) - 🔄 페이지 전환 기능 (SPA 방식)
- 📱 완전 반응형 디자인 - 📱 완전 반응형 디자인
@ -24,18 +22,21 @@
- ⚡ 실시간 가격 업데이트 시뮬레이션 - ⚡ 실시간 가격 업데이트 시뮬레이션
### 2. **dashboard.html** - 메인 대시보드 ### 2. **dashboard.html** - 메인 대시보드
- 🤖 AI 추천 투자상품 그리드 - 🤖 AI 추천 투자상품 그리드
- 📊 실시간 가격 및 변동률 - 📊 실시간 가격 및 변동률
- 🎯 AI 분석 점수 및 투자 지표 - 🎯 AI 분석 점수 및 투자 지표
- 📈 시장 개요 통계 - 📈 시장 개요 통계
### 3. **detail.html** - 상세 분석 페이지 ### 3. **detail.html** - 상세 분석 페이지
- 📈 차트 영역 (TradingView 연동 준비) - 📈 차트 영역 (TradingView 연동 준비)
- 🧠 AI 분석 리포트 (기술적/펀더멘털/위험) - 🧠 AI 분석 리포트 (기술적/펀더멘털/위험)
- 📰 관련 뉴스 섹션 - 📰 관련 뉴스 섹션
- 💡 투자 지표 상세 정보 - 💡 투자 지표 상세 정보
### 4. **simulation.html** - 수익률 시뮬레이션 ### 4. **simulation.html** - 수익률 시뮬레이션
- 💰 투자 금액 설정 (프리셋 버튼) - 💰 투자 금액 설정 (프리셋 버튼)
- ✅ 다중 투자상품 선택 - ✅ 다중 투자상품 선택
- 📊 실시간 수익률 계산 - 📊 실시간 수익률 계산
@ -46,6 +47,7 @@
## 🎨 디자인 특징 ## 🎨 디자인 특징
### 색상 체계 ### 색상 체계
- **배경**: `#0f1419``#1a1f2e` 그라디언트 - **배경**: `#0f1419``#1a1f2e` 그라디언트
- **브랜드**: `#667eea``#764ba2` 그라디언트 - **브랜드**: `#667eea``#764ba2` 그라디언트
- **성공**: `#22c55e` (상승/수익) - **성공**: `#22c55e` (상승/수익)
@ -54,6 +56,7 @@
- **경고**: `#f59e0b` (목표가) - **경고**: `#f59e0b` (목표가)
### UI 특징 ### UI 특징
- **Glassmorphism**: 반투명 배경 + 블러 효과 - **Glassmorphism**: 반투명 배경 + 블러 효과
- **둥근 모서리**: 16px 기본, 8-12px 세부 요소 - **둥근 모서리**: 16px 기본, 8-12px 세부 요소
- **그림자**: `0 8px 32px rgba(0,0,0,0.3)` 깊이감 - **그림자**: `0 8px 32px rgba(0,0,0,0.3)` 깊이감
@ -64,11 +67,13 @@
## 💰 광고 영역 배치 ## 💰 광고 영역 배치
### 프리미엄 광고 영역 ### 프리미엄 광고 영역
1. **좌우 사이드 배너** (120x600px) - 고정 위치 1. **좌우 사이드 배너** (120x600px) - 고정 위치
2. **헤더 상단 배너** - 페이지 최상단 2. **헤더 상단 배너** - 페이지 최상단
3. **메인 상단 배너** (1400x100px) - 콘텐츠 시작 3. **메인 상단 배너** (1400x100px) - 콘텐츠 시작
### 콘텐츠 통합 광고 ### 콘텐츠 통합 광고
4. **사이드바 광고** (320x200px) - 2개 영역 4. **사이드바 광고** (320x200px) - 2개 영역
5. **인라인 광고** - 콘텐츠 중간 삽입 5. **인라인 광고** - 콘텐츠 중간 삽입
6. **하단 대형 배너** (1400x200px) - CTA 포함 6. **하단 대형 배너** (1400x200px) - CTA 포함
@ -78,21 +83,25 @@
## 📱 반응형 브레이크포인트 ## 📱 반응형 브레이크포인트
### 데스크톱 (1400px+) ### 데스크톱 (1400px+)
- 모든 광고 영역 표시 - 모든 광고 영역 표시
- 3열 그리드 레이아웃 - 3열 그리드 레이아웃
- 사이드 배너 고정 - 사이드 배너 고정
### 태블릿 (1200px~1399px) ### 태블릿 (1200px~1399px)
- 사이드 배너 숨김 - 사이드 배너 숨김
- 2열 그리드로 변경 - 2열 그리드로 변경
- 사이드바가 상단으로 이동 - 사이드바가 상단으로 이동
### 모바일 (768px~1199px) ### 모바일 (768px~1199px)
- 1열 그리드 레이아웃 - 1열 그리드 레이아웃
- 헤더 세로 정렬 - 헤더 세로 정렬
- 컴팩트한 카드 디자인 - 컴팩트한 카드 디자인
### 소형 모바일 (480px 이하) ### 소형 모바일 (480px 이하)
- 최소 레이아웃 - 최소 레이아웃
- 버튼 세로 배치 - 버튼 세로 배치
- 간소화된 정보 표시 - 간소화된 정보 표시
@ -102,18 +111,21 @@
## 🚀 기술적 특징 ## 🚀 기술적 특징
### 성능 최적화 ### 성능 최적화
- **CSS Grid & Flexbox** 활용 - **CSS Grid & Flexbox** 활용
- **Transform 애니메이션** GPU 가속 - **Transform 애니메이션** GPU 가속
- **Lazy Loading** 준비 - **Lazy Loading** 준비
- **Code Splitting** 구조 - **Code Splitting** 구조
### 접근성 ### 접근성
- **시맨틱 HTML** 구조 - **시맨틱 HTML** 구조
- **키보드 네비게이션** 지원 - **키보드 네비게이션** 지원
- **스크린 리더** 호환 - **스크린 리더** 호환
- **고대비** 색상 대비 - **고대비** 색상 대비
### 확장성 ### 확장성
- **컴포넌트 기반** CSS 구조 - **컴포넌트 기반** CSS 구조
- **모듈화된** JavaScript - **모듈화된** JavaScript
- **API 연동** 준비 완료 - **API 연동** 준비 완료
@ -124,6 +136,7 @@
## 🔧 사용 방법 ## 🔧 사용 방법
### 1. 로컬 테스트 ### 1. 로컬 테스트
```bash ```bash
# 간단한 HTTP 서버로 실행 # 간단한 HTTP 서버로 실행
python -m http.server 8000 python -m http.server 8000
@ -132,12 +145,14 @@ npx serve .
``` ```
### 2. 브라우저에서 확인 ### 2. 브라우저에서 확인
- **메인 프로토타입**: `http://localhost:8000/index.html` - **메인 프로토타입**: `http://localhost:8000/index.html`
- **대시보드**: `http://localhost:8000/dashboard.html` - **대시보드**: `http://localhost:8000/dashboard.html`
- **상세 페이지**: `http://localhost:8000/detail.html` - **상세 페이지**: `http://localhost:8000/detail.html`
- **시뮬레이션**: `http://localhost:8000/simulation.html` - **시뮬레이션**: `http://localhost:8000/simulation.html`
### 3. 실제 구현 시 ### 3. 실제 구현 시
1. React + TypeScript로 포팅 1. React + TypeScript로 포팅
2. API 엔드포인트 연동 2. API 엔드포인트 연동
3. WebSocket 실시간 데이터 3. WebSocket 실시간 데이터
@ -148,18 +163,21 @@ npx serve .
## 📋 TODO (실제 구현 시) ## 📋 TODO (실제 구현 시)
### 프론트엔드 ### 프론트엔드
- [ ] React 컴포넌트로 변환 - [ ] React 컴포넌트로 변환
- [ ] Zustand 상태관리 적용 - [ ] Zustand 상태관리 적용
- [ ] TradingView 위젯 연동 - [ ] TradingView 위젯 연동
- [ ] WebSocket 실시간 데이터 - [ ] WebSocket 실시간 데이터
### 백엔드 연동 ### 백엔드 연동
- [ ] AWS Lambda API 연결 - [ ] AWS Lambda API 연결
- [ ] S3 데이터 페칭 - [ ] S3 데이터 페칭
- [ ] 실시간 가격 업데이트 - [ ] 실시간 가격 업데이트
- [ ] AI 분석 결과 표시 - [ ] AI 분석 결과 표시
### 추가 기능 ### 추가 기능
- [ ] 다크/라이트 테마 토글 - [ ] 다크/라이트 테마 토글
- [ ] 사용자 설정 저장 - [ ] 사용자 설정 저장
- [ ] 소셜 공유 기능 - [ ] 소셜 공유 기능
@ -170,6 +188,7 @@ npx serve .
## 🎉 완성도 ## 🎉 완성도
이 프로토타입은 **실제 서비스 수준**의 완성도를 자랑합니다: 이 프로토타입은 **실제 서비스 수준**의 완성도를 자랑합니다:
- ✅ 한글 인코딩 완벽 지원 - ✅ 한글 인코딩 완벽 지원
- ✅ 전략적 광고 영역 배치 - ✅ 전략적 광고 영역 배치
- ✅ 완전 반응형 디자인 - ✅ 완전 반응형 디자인

View File

@ -1,906 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>I'm AI App Design - Mobile Investment Guide Service</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Arial', sans-serif;
background-color: #f5f5f5;
padding: 20px;
}
/* 디바이스 컨테이너 - 3개 앱 화면을 나란히 배치 */
.device-container {
display: flex;
justify-content: center;
gap: 30px;
flex-wrap: wrap;
}
/* 모바일 디바이스 프레임 */
.phone {
width: 375px; /* iPhone 기준 너비 */
height: 812px; /* iPhone 기준 높이 */
background: #000;
border-radius: 40px;
padding: 10px;
box-shadow: 0 0 30px rgba(0,0,0,0.3);
position: relative;
}
/* 디바이스 스크린 영역 */
.screen {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
border-radius: 30px;
overflow: hidden;
position: relative;
}
/* 상태바 영역 (시간, 배터리 등) */
.status-bar {
height: 44px;
background: transparent;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
color: #fff;
font-size: 14px;
font-weight: 600;
}
/* 상단 네비게이션 바 */
.nav-bar {
height: 60px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
font-weight: bold;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
}
/* 메인 콘텐츠 영역 */
.content {
height: calc(100% - 104px - 80px); /* 전체 높이에서 상단바와 하단바 제외 */
padding: 15px;
overflow-y: auto;
}
/* 하단 탭 네비게이션 */
.bottom-nav {
height: 80px;
background: #1a1a1a;
display: flex;
justify-content: space-around;
align-items: center;
border-top: 1px solid #333;
}
/* 하단 네비게이션 아이템 */
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
color: #999;
font-size: 12px;
cursor: pointer;
transition: color 0.3s;
}
.nav-item.active {
color: #667eea;
}
/* 네비게이션 아이콘 */
.nav-icon {
width: 24px;
height: 24px;
margin-bottom: 4px;
background: currentColor;
border-radius: 4px;
}
/* 투자상품 리스트 */
.coin-list {
display: flex;
flex-direction: column;
gap: 15px;
}
/* 개별 투자상품 카드 */
.coin-item {
background: linear-gradient(145deg, #1a1a1a, #2d2d2d);
border-radius: 15px;
padding: 20px;
border: 1px solid #333;
}
/* 투자상품 헤더 (이름, 심볼, 가격) */
.coin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
/* 투자상품 정보 (아이콘, 이름) */
.coin-info {
display: flex;
align-items: center;
gap: 12px;
}
/* 투자상품 아이콘 */
.coin-icon {
width: 40px;
height: 40px;
background: #667eea;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 14px;
}
.coin-details h3 {
color: #fff;
font-size: 16px;
margin-bottom: 4px;
}
.coin-symbol {
color: #999;
font-size: 12px;
}
/* 가격 정보 영역 */
.coin-price {
text-align: right;
}
.current-price {
color: #4ade80;
font-size: 18px;
font-weight: bold;
margin-bottom: 4px;
}
.price-change {
font-size: 12px;
padding: 4px 8px;
border-radius: 8px;
}
/* 가격 상승/하락 색상 구분 */
.price-up {
background: #22c55e;
color: white;
}
.price-down {
background: #ef4444;
color: white;
}
/* AI 분석 결과 표시 */
.ai-analysis {
background: rgba(102, 126, 234, 0.1);
border: 1px solid #667eea;
border-radius: 10px;
padding: 12px;
margin: 15px 0;
}
.analysis-title {
color: #667eea;
font-size: 12px;
font-weight: bold;
margin-bottom: 6px;
}
.analysis-text {
color: #ccc;
font-size: 12px;
line-height: 1.4;
}
/* 투자 목표가 표시 영역 */
.price-targets {
display: flex;
justify-content: space-between;
gap: 10px;
margin-top: 15px;
}
.target-item {
flex: 1;
text-align: center;
padding: 8px;
background: rgba(255,255,255,0.05);
border-radius: 8px;
}
.target-label {
color: #999;
font-size: 10px;
margin-bottom: 4px;
}
.target-value {
font-weight: bold;
font-size: 12px;
}
/* 목표가별 색상 구분 */
.entry-price { color: #4ade80; }
.target-price { color: #fbbf24; }
.stop-loss { color: #ef4444; }
/* AI 투자 점수 표시 */
.ai-score {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(45deg, #667eea, #764ba2);
border-radius: 10px;
padding: 12px;
margin-top: 15px;
}
.score-label {
color: white;
font-size: 12px;
}
.score-value {
color: white;
font-size: 16px;
font-weight: bold;
}
/* 페이지별 스타일 */
.page-2 .content {
padding: 20px;
}
/* 상세 분석 카드 */
.detail-card {
background: linear-gradient(145deg, #1a1a1a, #2d2d2d);
border-radius: 15px;
padding: 25px;
border: 1px solid #333;
margin-bottom: 20px;
}
/* 상세 분석 헤더 */
.detail-header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 20px;
}
/* 큰 투자상품 아이콘 */
.large-coin-icon {
width: 60px;
height: 60px;
background: #667eea;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 20px;
}
/* 큰 가격 표시 */
.large-price {
font-size: 28px;
color: #4ade80;
font-weight: bold;
margin-bottom: 10px;
}
/* 상세 AI 분석 영역 */
.detail-analysis {
background: rgba(102, 126, 234, 0.1);
border: 1px solid #667eea;
border-radius: 12px;
padding: 20px;
margin: 20px 0;
}
/* 분석 섹션 */
.analysis-section {
margin-bottom: 15px;
}
.analysis-section h4 {
color: #667eea;
font-size: 14px;
margin-bottom: 8px;
}
.analysis-section p {
color: #ccc;
font-size: 13px;
line-height: 1.5;
}
/* 페이지 3 설정 리스트 */
.page-3 .settings-list {
display: flex;
flex-direction: column;
gap: 15px;
}
/* 설정 카드 */
.setting-card {
background: linear-gradient(145deg, #1a1a1a, #2d2d2d);
border-radius: 12px;
padding: 20px;
border: 1px solid #333;
}
/* 설정 제목 */
.setting-title {
color: #667eea;
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
}
/* 설정 아이템 */
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #333;
}
.setting-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
/* 설정 라벨 */
.setting-label {
color: #ccc;
font-size: 14px;
}
/* 설정 값 */
.setting-value {
color: #fff;
font-size: 14px;
padding: 6px 12px;
background: #333;
border-radius: 6px;
border: none;
}
/* 토글 스위치 */
.switch {
width: 50px;
height: 24px;
background: #333;
border-radius: 12px;
position: relative;
cursor: pointer;
}
.switch.active {
background: #667eea;
}
.switch::after {
content: '';
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
transition: transform 0.3s;
}
.switch.active::after {
transform: translateX(26px);
}
/* 모바일 광고 배너 */
.ad-banner-mobile {
background: linear-gradient(45deg, #333, #555);
border-radius: 10px;
padding: 15px;
text-align: center;
margin: 15px 0;
border: 1px dashed #666;
}
.ad-banner-mobile h4 {
color: #999;
font-size: 12px;
margin-bottom: 5px;
}
.ad-banner-mobile p {
color: #777;
font-size: 10px;
}
/* 설명 텍스트 스타일 */
.description-text {
background: #333;
color: #ccc;
padding: 6px 10px;
border-radius: 6px;
font-size: 11px;
border: 1px solid #555;
margin: 5px 0;
}
</style>
</head>
<body>
<div class="device-container">
<!--
페이지 1: 메인 투자 현황 화면
- 하단 탭 네비게이션의 "홈" 탭
- 5개 투자상품의 실시간 정보 표시
- 각 카드에 AI 분석 결과와 투자 점수 포함
-->
<div class="phone">
<div class="screen">
<!-- iOS 스타일 상태바 -->
<div class="status-bar">
<span>[STATUS] 9:41</span>
<span>[BATTERY] 100%</span>
</div>
<!-- 상단 네비게이션 바 -->
<div class="nav-bar">
[NAV TITLE] I'm AI - Investment Status
</div>
<!-- 메인 콘텐츠 영역 -->
<div class="content">
<div class="coin-list">
<!--
투자상품 #1: Bitcoin
- 현재가, 변동률 표시
- AI 분석 요약
- 목표가 정보 (진입/목표/손절)
- AI 투자 점수
-->
<div class="coin-item">
<div class="coin-header">
<div class="coin-info">
<div class="coin-icon">[ICON] BTC</div>
<div class="coin-details">
<h3>[COIN NAME] Bitcoin</h3>
<div class="coin-symbol">[SYMBOL] BTC</div>
</div>
</div>
<div class="coin-price">
<div class="current-price">[PRICE] $43,250</div>
<div class="price-change price-up">[CHANGE] +2.35%</div>
</div>
</div>
<!-- AI 분석 요약 -->
<div class="ai-analysis">
<div class="analysis-title">[AI TITLE] AI Market Analysis</div>
<div class="analysis-text description-text">[AI SUMMARY] Institutional investment inflow sustains upward momentum. Technical bullish signals detected.</div>
</div>
<!-- 투자 목표가 표시 -->
<div class="price-targets">
<div class="target-item">
<div class="target-label">[LABEL] Entry</div>
<div class="target-value entry-price">[ENTRY] $42,800</div>
</div>
<div class="target-item">
<div class="target-label">[LABEL] Target</div>
<div class="target-value target-price">[TARGET] $46,500</div>
</div>
<div class="target-item">
<div class="target-label">[LABEL] Stop</div>
<div class="target-value stop-loss">[STOP] $40,200</div>
</div>
</div>
<!-- AI 투자 점수 -->
<div class="ai-score">
<span class="score-label">[SCORE LABEL] AI Investment Score</span>
<span class="score-value">[SCORE] 8.5/10</span>
</div>
</div>
<!--
투자상품 #2: Ethereum
동일한 구조로 이더리움 정보 표시
-->
<div class="coin-item">
<div class="coin-header">
<div class="coin-info">
<div class="coin-icon">[ICON] ETH</div>
<div class="coin-details">
<h3>[COIN NAME] Ethereum</h3>
<div class="coin-symbol">[SYMBOL] ETH</div>
</div>
</div>
<div class="coin-price">
<div class="current-price">[PRICE] $2,580</div>
<div class="price-change price-down">[CHANGE] -1.20%</div>
</div>
</div>
<div class="ai-analysis">
<div class="analysis-title">[AI TITLE] AI Market Analysis</div>
<div class="analysis-text description-text">[AI SUMMARY] Short-term correction phase but long-term upward outlook. Positive DeFi growth.</div>
</div>
<div class="price-targets">
<div class="target-item">
<div class="target-label">[LABEL] Entry</div>
<div class="target-value entry-price">[ENTRY] $2,520</div>
</div>
<div class="target-item">
<div class="target-label">[LABEL] Target</div>
<div class="target-value target-price">[TARGET] $2,850</div>
</div>
<div class="target-item">
<div class="target-label">[LABEL] Stop</div>
<div class="target-value stop-loss">[STOP] $2,350</div>
</div>
</div>
<div class="ai-score">
<span class="score-label">[SCORE LABEL] AI Investment Score</span>
<span class="score-value">[SCORE] 7.2/10</span>
</div>
</div>
<!--
광고 영역: 모바일 배너
- 투자상품 목록 중간에 삽입
- 사용자 경험을 해치지 않는 선에서 배치
-->
<div class="ad-banner-mobile">
<h4>[AD TITLE] Advertisement</h4>
<p class="description-text">[AD DESCRIPTION] Mobile Banner Advertisement Area<br>Revenue Generation Through Ad Display</p>
</div>
<!--
투자상품 #3: Ripple
높은 AI 점수를 받은 추천 종목
-->
<div class="coin-item">
<div class="coin-header">
<div class="coin-info">
<div class="coin-icon">[ICON] XRP</div>
<div class="coin-details">
<h3>[COIN NAME] Ripple</h3>
<div class="coin-symbol">[SYMBOL] XRP</div>
</div>
</div>
<div class="coin-price">
<div class="current-price">[PRICE] $0.625</div>
<div class="price-change price-up">[CHANGE] +5.80%</div>
</div>
</div>
<div class="ai-score">
<span class="score-label">[SCORE LABEL] AI Investment Score</span>
<span class="score-value">[SCORE] 9.1/10</span>
</div>
</div>
</div>
</div>
<!-- 하단 탭 네비게이션 -->
<div class="bottom-nav">
<div class="nav-item active">
<div class="nav-icon">[ICON]</div>
<span>[TAB] Home</span>
</div>
<div class="nav-item">
<div class="nav-icon">[ICON]</div>
<span>[TAB] Analysis</span>
</div>
<div class="nav-item">
<div class="nav-icon">[ICON]</div>
<span>[TAB] Settings</span>
</div>
</div>
</div>
</div>
<!--
페이지 2: AI 분석 상세 화면
- 하단 탭 네비게이션의 "분석" 탭
- 선택한 투자상품의 상세 AI 분석 결과
- 기술적 분석, 뉴스 분석, 투자 전략 제공
-->
<div class="phone page-2">
<div class="screen">
<div class="status-bar">
<span>[STATUS] 9:41</span>
<span>[BATTERY] 100%</span>
</div>
<div class="nav-bar">
[NAV TITLE] AI Analysis Detail
</div>
<div class="content">
<!--
상세 분석 카드
- 투자상품 기본 정보 (큰 아이콘, 이름, 가격)
- 상세 AI 분석 (기술적/뉴스/전략 분석)
- 종합 AI 투자 점수
-->
<div class="detail-card">
<div class="detail-header">
<div class="large-coin-icon">[BIG ICON] BTC</div>
<div>
<h2 style="color: white; margin-bottom: 5px;">[COIN NAME] Bitcoin</h2>
<div class="large-price">[LARGE PRICE] $43,250.00</div>
<div class="price-change price-up">[PRICE CHANGE] +2.35% (+$992.50)</div>
</div>
</div>
<!-- 상세 AI 분석 영역 -->
<div class="detail-analysis">
<!-- 기술적 분석 섹션 -->
<div class="analysis-section">
<h4>[SECTION TITLE] Technical Analysis</h4>
<p class="description-text">[TECHNICAL ANALYSIS] RSI indicator at 65 level shows bullish signals. Moving average breakout strengthens upward momentum.</p>
</div>
<!-- 뉴스 분석 섹션 -->
<div class="analysis-section">
<h4>[SECTION TITLE] News Analysis</h4>
<p class="description-text">[NEWS ANALYSIS] Major institutional Bitcoin ETF approvals by BlackRock and others drive significant capital inflow.</p>
</div>
<!-- 투자 전략 섹션 -->
<div class="analysis-section">
<h4>[SECTION TITLE] Investment Strategy</h4>
<p class="description-text">[STRATEGY] Recommend entry at $42,800, hold until target $46,500. Strict adherence to stop-loss at $40,200 required.</p>
</div>
</div>
<!-- 종합 AI 점수 -->
<div class="ai-score">
<span class="score-label">[TOTAL SCORE LABEL] Comprehensive AI Investment Score</span>
<span class="score-value">[TOTAL SCORE] 8.5/10</span>
</div>
</div>
<!--
광고 영역: 전면 또는 동영상 광고
- 상세 분석 하단에 배치
- 더 높은 광고 단가 기대
-->
<div class="ad-banner-mobile">
<h4>[PREMIUM AD] Premium Advertisement</h4>
<p class="description-text">[AD DESCRIPTION] Full-screen or Video Advertisement Area<br>Higher Revenue Premium Ad Space</p>
</div>
</div>
<div class="bottom-nav">
<div class="nav-item">
<div class="nav-icon">[ICON]</div>
<span>[TAB] Home</span>
</div>
<div class="nav-item active">
<div class="nav-icon">[ICON]</div>
<span>[TAB] Analysis</span>
</div>
<div class="nav-item">
<div class="nav-icon">[ICON]</div>
<span>[TAB] Settings</span>
</div>
</div>
</div>
</div>
<!--
페이지 3: 개인 맞춤 설정 화면
- 하단 탭 네비게이션의 "설정" 탭
- 투자 성향 설정
- 알림 설정
- 앱 설정
-->
<div class="phone page-3">
<div class="screen">
<div class="status-bar">
<span>[STATUS] 9:41</span>
<span>[BATTERY] 100%</span>
</div>
<div class="nav-bar">
[NAV TITLE] Personal Settings
</div>
<div class="content">
<div class="settings-list">
<!--
투자 성향 설정 카드
- 투자 유형 선택 (보수적/균형/적극적/공격적)
- 투자 기간 설정
- 위험 허용도 조절
-->
<div class="setting-card">
<div class="setting-title">[CARD TITLE] Investment Preferences</div>
<div class="setting-item">
<span class="setting-label">[LABEL] Investment Type</span>
<select class="setting-value">
<option>[OPTION] Conservative</option>
<option>[OPTION] Balanced</option>
<option>[OPTION] Aggressive</option>
<option>[OPTION] High Risk</option>
</select>
</div>
<div class="setting-item">
<span class="setting-label">[LABEL] Investment Period</span>
<select class="setting-value">
<option>[OPTION] Short Term (1-3 months)</option>
<option>[OPTION] Medium Term (3-12 months)</option>
<option>[OPTION] Long Term (1+ years)</option>
</select>
</div>
<div class="setting-item">
<span class="setting-label">[LABEL] Risk Tolerance</span>
<span class="setting-value">[VALUE] Medium (5/10)</span>
</div>
</div>
<!--
알림 설정 카드
- 목표가 도달 알림 on/off
- 손절가 도달 알림 on/off
- 진입가 도달 알림 on/off
- AI 추천 알림 on/off
-->
<div class="setting-card">
<div class="setting-title">[CARD TITLE] Notification Settings</div>
<div class="setting-item">
<span class="setting-label">[ALERT LABEL] Target Price Alert</span>
<div class="switch active">[SWITCH] ON</div>
</div>
<div class="setting-item">
<span class="setting-label">[ALERT LABEL] Stop Loss Alert</span>
<div class="switch active">[SWITCH] ON</div>
</div>
<div class="setting-item">
<span class="setting-label">[ALERT LABEL] Entry Price Alert</span>
<div class="switch active">[SWITCH] ON</div>
</div>
<div class="setting-item">
<span class="setting-label">[ALERT LABEL] AI Recommendation Alert</span>
<div class="switch active">[SWITCH] ON</div>
</div>
</div>
<!--
광고 영역: 설정 페이지 광고
- 설정 카드들 사이에 자연스럽게 삽입
- 사용자가 설정을 변경하는 동안 노출
-->
<div class="ad-banner-mobile">
<h4>[SETTINGS AD] Settings Page Ad</h4>
<p class="description-text">[AD DESCRIPTION] Advertisement in Settings Area<br>Targeted Ad Based on User Preferences</p>
</div>
<!--
앱 설정 카드
- 다크 모드 on/off
- 자동 업데이트 on/off
- 데이터 절약 모드 on/off
-->
<div class="setting-card">
<div class="setting-title">[CARD TITLE] App Settings</div>
<div class="setting-item">
<span class="setting-label">[SETTING LABEL] Dark Mode</span>
<div class="switch active">[SWITCH] ON</div>
</div>
<div class="setting-item">
<span class="setting-label">[SETTING LABEL] Auto Update</span>
<div class="switch active">[SWITCH] ON</div>
</div>
<div class="setting-item">
<span class="setting-label">[SETTING LABEL] Data Saving Mode</span>
<div class="switch">[SWITCH] OFF</div>
</div>
</div>
</div>
</div>
<div class="bottom-nav">
<div class="nav-item">
<div class="nav-icon">[ICON]</div>
<span>[TAB] Home</span>
</div>
<div class="nav-item">
<div class="nav-icon">[ICON]</div>
<span>[TAB] Analysis</span>
</div>
<div class="nav-item active">
<div class="nav-icon">[ICON]</div>
<span>[TAB] Settings</span>
</div>
</div>
</div>
</div>
</div>
<script>
// 토글 스위치 기능
// 실제 구현시 사용자 설정을 서버에 저장하고 동기화
document.querySelectorAll('.switch').forEach(switchEl => {
switchEl.addEventListener('click', function() {
this.classList.toggle('active');
// 실제 구현시 여기서 서버로 설정값 전송
console.log('Setting changed:', this.previousElementSibling.textContent);
});
});
// 실시간 가격 업데이트 시뮬레이션 (모바일용)
// 실제로는 WebSocket 연결을 통해 실시간 데이터 수신
function updateMobilePrices() {
const priceElements = document.querySelectorAll('.current-price');
priceElements.forEach(element => {
// 실제로는 API에서 실시간 데이터를 받아와 업데이트
const currentText = element.textContent;
console.log('Mobile price update simulation for:', currentText);
// 푸시 알림 조건 확인
// 실제 구현시 목표가/손절가 도달시 알림 발송
});
}
// 30초마다 가격 업데이트 (데모용)
setInterval(updateMobilePrices, 30000);
// 페이지 전환 애니메이션
// 실제 앱에서는 React Native Navigation 또는 Flutter Navigator 사용
function navigateToPage(pageIndex) {
console.log('Navigate to page:', pageIndex);
}
</script>
</body>
</html>

View File

@ -1,687 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>I'm AI Web Design - Investment Guide Service</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Arial', sans-serif;
background-color: #0f0f0f;
color: #ffffff;
position: relative;
}
/* 헤더 영역 스타일 */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
text-align: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
}
.logo {
font-size: 2.5rem;
font-weight: bold;
margin-bottom: 10px;
}
.subtitle {
font-size: 1.1rem;
opacity: 0.9;
}
/* 메인 컨테이너 - 2열 그리드 레이아웃 */
/* 좌측 메인 콘텐츠 영역 */
.main-content {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 투자상품 카드 그리드 */
.coin-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 20px;
}
/* 개별 투자상품 카드 */
.coin-card {
background: linear-gradient(145deg, #1a1a1a, #2d2d2d);
border-radius: 15px;
padding: 25px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
border: 1px solid #333;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.coin-card:hover {
transform: translateY(-5px);
box-shadow: 0 15px 40px rgba(0,0,0,0.5);
}
/* 투자상품 헤더 (이름, 심볼) */
.coin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.coin-name {
font-size: 1.3rem;
font-weight: bold;
color: #667eea;
}
.coin-symbol {
background: #667eea;
color: white;
padding: 5px 10px;
border-radius: 20px;
font-size: 0.9rem;
}
/* 가격 정보 영역 */
.price-info {
margin-bottom: 20px;
}
.current-price {
font-size: 2rem;
font-weight: bold;
color: #4ade80;
margin-bottom: 10px;
}
.price-change {
font-size: 1rem;
padding: 5px 10px;
border-radius: 10px;
}
.price-up {
background: #22c55e;
color: white;
}
.price-down {
background: #ef4444;
color: white;
}
/* AI 분석 결과 표시 영역 */
.ai-analysis {
background: rgba(102, 126, 234, 0.1);
border: 1px solid #667eea;
border-radius: 10px;
padding: 15px;
margin-bottom: 20px;
}
.analysis-title {
color: #667eea;
font-weight: bold;
margin-bottom: 10px;
}
/* 투자 가격 목표 표시 */
.price-targets {
display: flex;
justify-content: space-between;
gap: 10px;
}
.target-item {
text-align: center;
flex: 1;
}
.target-label {
font-size: 0.9rem;
color: #999;
margin-bottom: 5px;
}
.target-price {
font-weight: bold;
font-size: 1.1rem;
}
/* 목표가별 색상 구분 */
.entry-price { color: #4ade80; }
.target-price-val { color: #fbbf24; }
.stop-loss { color: #ef4444; }
/* AI 투자 점수 표시 */
.ai-score {
background: linear-gradient(45deg, #667eea, #764ba2);
border-radius: 10px;
padding: 15px;
text-align: center;
margin-top: 15px;
}
.score-label {
font-size: 0.9rem;
margin-bottom: 5px;
}
.score-value {
font-size: 2rem;
font-weight: bold;
}
/* 우측 사이드바 */
.sidebar {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 광고 배너 영역 */
.ad-banner {
background: linear-gradient(45deg, #333, #555);
border-radius: 10px;
padding: 20px;
text-align: center;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed #666;
font-size: 14px;
color: #ccc;
}
/* 개인 설정 패널 */
.settings-panel {
background: linear-gradient(145deg, #1a1a1a, #2d2d2d);
border-radius: 15px;
padding: 25px;
border: 1px solid #333;
}
.panel-title {
font-size: 1.2rem;
font-weight: bold;
color: #667eea;
margin-bottom: 20px;
}
.setting-item {
margin-bottom: 15px;
}
.setting-label {
display: block;
margin-bottom: 5px;
color: #ccc;
}
select, input[type="range"] {
width: 100%;
padding: 8px;
background: #333;
border: 1px solid #555;
border-radius: 5px;
color: #fff;
}
/* 알림 설정 영역 */
.notification-settings {
background: rgba(102, 126, 234, 0.1);
border: 1px solid #667eea;
border-radius: 10px;
padding: 15px;
margin-top: 20px;
}
.notification-title {
color: #667eea;
font-weight: bold;
margin-bottom: 10px;
}
.checkbox-item {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.checkbox-item input {
margin-right: 10px;
}
/* 설명 텍스트 스타일 */
.description-text {
background: #333;
color: #ccc;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
border: 1px solid #555;
}
/* 세로 광고 배너 영역 */
.left-banner, .right-banner {
position: fixed;
top: 50%;
transform: translateY(-50%);
width: 120px;
height: 600px;
background: linear-gradient(45deg, #333, #555);
border: 2px dashed #666;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 12px;
color: #ccc;
z-index: 1000;
}
.left-banner {
left: 10px;
}
.right-banner {
right: 10px;
}
/* 메인 컨테이너 여백 조정 */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px 150px; /* 좌우 여백 증가 */
display: grid;
grid-template-columns: 2fr 1fr;
gap: 20px;
}
/* 모바일 반응형 */
@media (max-width: 768px) {
.container {
grid-template-columns: 1fr;
padding: 10px;
}
.coin-grid {
grid-template-columns: 1fr;
}
.price-targets {
flex-direction: column;
gap: 15px;
}
.left-banner, .right-banner {
display: none;
}
}
</style>
</head>
<body>
<!-- 좌측 세로 광고 배너 -->
<div class="left-banner">
<div>
<div style="font-size: 14px; margin-bottom: 10px; transform: rotate(-90deg);">[LEFT BANNER]</div>
<div style="font-size: 10px;">Vertical Advertisement<br>120x600px</div>
</div>
</div>
<!-- 우측 세로 광고 배너 -->
<div class="right-banner">
<div>
<div style="font-size: 14px; margin-bottom: 10px; transform: rotate(-90deg);">[RIGHT BANNER]</div>
<div style="font-size: 10px;">Vertical Advertisement<br>120x600px</div>
</div>
</div>
<!-- 웹사이트 헤더: 로고와 서비스 설명 -->
<header class="header">
<div class="logo">I'm AI</div>
<div class="subtitle">[HEADER] AI Investment Guide Service Logo & Title Area</div>
</header>
<div class="container">
<!-- 좌측 메인 콘텐츠 영역: 투자상품 정보 표시 -->
<main class="main-content">
<div class="coin-grid">
<!--
투자상품 카드 #1: Bitcoin
- 실시간 가격 정보
- AI 분석 결과
- 투자 목표가 (진입/목표/손절)
- AI 투자 점수
-->
<div class="coin-card">
<div class="coin-header">
<div class="coin-name">[COIN NAME] Bitcoin</div>
<div class="coin-symbol">[SYMBOL] BTC</div>
</div>
<div class="price-info">
<!-- 실시간 현재가 표시 -->
<div class="current-price">[CURRENT PRICE] $43,250.00</div>
<!-- 가격 변동률 및 변동액 표시 -->
<div class="price-change price-up">[PRICE CHANGE] +2.35% (+$992.50)</div>
</div>
<!-- AI 분석 결과 표시 영역 -->
<div class="ai-analysis">
<div class="analysis-title">[AI ANALYSIS TITLE] AI Market Analysis</div>
<div class="description-text">[AI ANALYSIS] Recent institutional investment inflow sustains upward momentum. Technical analysis shows bullish signals detected.</div>
</div>
<!-- 투자 목표가 설정 표시 -->
<div class="price-targets">
<div class="target-item">
<div class="target-label">[ENTRY PRICE LABEL] Entry</div>
<div class="target-price entry-price">[ENTRY PRICE] $42,800</div>
</div>
<div class="target-item">
<div class="target-label">[TARGET PRICE LABEL] Target</div>
<div class="target-price target-price-val">[TARGET PRICE] $46,500</div>
</div>
<div class="target-item">
<div class="target-label">[STOP LOSS LABEL] Stop Loss</div>
<div class="target-price stop-loss">[STOP LOSS] $40,200</div>
</div>
</div>
<!-- AI 투자 점수 표시 -->
<div class="ai-score">
<div class="score-label">[SCORE LABEL] AI Investment Score</div>
<div class="score-value">[AI SCORE] 8.5/10</div>
</div>
</div>
<!--
투자상품 카드 #2: Ethereum
동일한 구조로 이더리움 정보 표시
-->
<div class="coin-card">
<div class="coin-header">
<div class="coin-name">[COIN NAME] Ethereum</div>
<div class="coin-symbol">[SYMBOL] ETH</div>
</div>
<div class="price-info">
<div class="current-price">[CURRENT PRICE] $2,580.00</div>
<div class="price-change price-down">[PRICE CHANGE] -1.20% (-$31.20)</div>
</div>
<div class="ai-analysis">
<div class="analysis-title">[AI ANALYSIS TITLE] AI Market Analysis</div>
<div class="description-text">[AI ANALYSIS] Short-term correction phase but long-term upward outlook. Positive DeFi ecosystem growth.</div>
</div>
<div class="price-targets">
<div class="target-item">
<div class="target-label">[ENTRY PRICE LABEL] Entry</div>
<div class="target-price entry-price">[ENTRY PRICE] $2,520</div>
</div>
<div class="target-item">
<div class="target-label">[TARGET PRICE LABEL] Target</div>
<div class="target-price target-price-val">[TARGET PRICE] $2,850</div>
</div>
<div class="target-item">
<div class="target-label">[STOP LOSS LABEL] Stop Loss</div>
<div class="target-price stop-loss">[STOP LOSS] $2,350</div>
</div>
</div>
<div class="ai-score">
<div class="score-label">[SCORE LABEL] AI Investment Score</div>
<div class="score-value">[AI SCORE] 7.2/10</div>
</div>
</div>
<!--
투자상품 카드 #3: Ripple
높은 AI 점수를 받은 추천 종목 예시
-->
<div class="coin-card">
<div class="coin-header">
<div class="coin-name">[COIN NAME] Ripple</div>
<div class="coin-symbol">[SYMBOL] XRP</div>
</div>
<div class="price-info">
<div class="current-price">[CURRENT PRICE] $0.625</div>
<div class="price-change price-up">[PRICE CHANGE] +5.80% (+$0.034)</div>
</div>
<div class="ai-analysis">
<div class="analysis-title">[AI ANALYSIS TITLE] AI Market Analysis</div>
<div class="description-text">[AI ANALYSIS] Surge due to SEC lawsuit resolution expectations. Technical breakthrough pattern forming.</div>
</div>
<div class="price-targets">
<div class="target-item">
<div class="target-label">[ENTRY PRICE LABEL] Entry</div>
<div class="target-price entry-price">[ENTRY PRICE] $0.610</div>
</div>
<div class="target-item">
<div class="target-label">[TARGET PRICE LABEL] Target</div>
<div class="target-price target-price-val">[TARGET PRICE] $0.720</div>
</div>
<div class="target-item">
<div class="target-label">[STOP LOSS LABEL] Stop Loss</div>
<div class="target-price stop-loss">[STOP LOSS] $0.550</div>
</div>
</div>
<div class="ai-score">
<div class="score-label">[SCORE LABEL] AI Investment Score</div>
<div class="score-value">[AI SCORE] 9.1/10</div>
</div>
</div>
<!--
투자상품 카드 #4: Cardano
중간 점수 종목 예시
-->
<div class="coin-card">
<div class="coin-header">
<div class="coin-name">[COIN NAME] Cardano</div>
<div class="coin-symbol">[SYMBOL] ADA</div>
</div>
<div class="price-info">
<div class="current-price">[CURRENT PRICE] $0.485</div>
<div class="price-change price-up">[PRICE CHANGE] +3.20% (+$0.015)</div>
</div>
<div class="ai-analysis">
<div class="analysis-title">[AI ANALYSIS TITLE] AI Market Analysis</div>
<div class="description-text">[AI ANALYSIS] Increased developer activity after smart contract upgrade. Medium-term growth expected.</div>
</div>
<div class="price-targets">
<div class="target-item">
<div class="target-label">[ENTRY PRICE LABEL] Entry</div>
<div class="target-price entry-price">[ENTRY PRICE] $0.470</div>
</div>
<div class="target-item">
<div class="target-label">[TARGET PRICE LABEL] Target</div>
<div class="target-price target-price-val">[TARGET PRICE] $0.550</div>
</div>
<div class="target-item">
<div class="target-label">[STOP LOSS LABEL] Stop Loss</div>
<div class="target-price stop-loss">[STOP LOSS] $0.420</div>
</div>
</div>
<div class="ai-score">
<div class="score-label">[SCORE LABEL] AI Investment Score</div>
<div class="score-value">[AI SCORE] 6.8/10</div>
</div>
</div>
<!--
투자상품 카드 #5: Solana
고성장 가능성 종목 예시
-->
<div class="coin-card">
<div class="coin-header">
<div class="coin-name">[COIN NAME] Solana</div>
<div class="coin-symbol">[SYMBOL] SOL</div>
</div>
<div class="price-info">
<div class="current-price">[CURRENT PRICE] $102.30</div>
<div class="price-change price-up">[PRICE CHANGE] +4.75% (+$4.64)</div>
</div>
<div class="ai-analysis">
<div class="analysis-title">[AI ANALYSIS TITLE] AI Market Analysis</div>
<div class="description-text">[AI ANALYSIS] Network usage surge due to NFT and DeFi ecosystem activation. Technical strength continues.</div>
</div>
<div class="price-targets">
<div class="target-item">
<div class="target-label">[ENTRY PRICE LABEL] Entry</div>
<div class="target-price entry-price">[ENTRY PRICE] $98.50</div>
</div>
<div class="target-item">
<div class="target-label">[TARGET PRICE LABEL] Target</div>
<div class="target-price target-price-val">[TARGET PRICE] $115.00</div>
</div>
<div class="target-item">
<div class="target-label">[STOP LOSS LABEL] Stop Loss</div>
<div class="target-price stop-loss">[STOP LOSS] $88.00</div>
</div>
</div>
<div class="ai-score">
<div class="score-label">[SCORE LABEL] AI Investment Score</div>
<div class="score-value">[AI SCORE] 8.3/10</div>
</div>
</div>
</div>
</main>
<!-- 우측 사이드바: 광고 및 개인설정 -->
<aside class="sidebar">
<!--
광고 영역 #1: 주요 광고 배너
- 크기: 320x200px 권장
- 수익 모델의 핵심 영역
-->
<div class="ad-banner">
<div>
<div style="font-size: 16px; margin-bottom: 10px;">[AD BANNER #1]</div>
<div class="description-text">Advertisement Banner Area<br>320x200px Recommended<br>Primary Revenue Source</div>
</div>
</div>
<!--
개인 맞춤 설정 패널
- 사용자 투자 성향 설정
- 알림 설정
- 위험 허용도 조절
-->
<div class="settings-panel">
<div class="panel-title">[SETTINGS TITLE] Personal Investment Settings</div>
<!-- 투자 성향 선택 -->
<div class="setting-item">
<label class="setting-label">[INVESTMENT TYPE LABEL] Investment Style</label>
<select>
<option>[OPTION] Conservative</option>
<option>[OPTION] Balanced</option>
<option>[OPTION] Aggressive</option>
<option>[OPTION] High Risk</option>
</select>
</div>
<!-- 투자 기간 선택 -->
<div class="setting-item">
<label class="setting-label">[INVESTMENT PERIOD LABEL] Investment Period</label>
<select>
<option>[OPTION] Short Term (1-3 months)</option>
<option>[OPTION] Medium Term (3-12 months)</option>
<option>[OPTION] Long Term (1+ years)</option>
</select>
</div>
<!-- 위험 허용도 슬라이더 -->
<div class="setting-item">
<label class="setting-label">[RISK TOLERANCE LABEL] Risk Tolerance: <span id="riskValue">5</span></label>
<input type="range" min="1" max="10" value="5" id="riskSlider">
</div>
<!-- 알림 설정 영역 -->
<div class="notification-settings">
<div class="notification-title">[NOTIFICATION TITLE] Alert Settings</div>
<div class="checkbox-item">
<input type="checkbox" id="targetAlert" checked>
<label for="targetAlert">[ALERT OPTION] Target Price Alert</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="stopLossAlert" checked>
<label for="stopLossAlert">[ALERT OPTION] Stop Loss Alert</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="entryAlert" checked>
<label for="entryAlert">[ALERT OPTION] Entry Price Alert</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="recommendAlert" checked>
<label for="recommendAlert">[ALERT OPTION] AI Recommendation Alert</label>
</div>
</div>
</div>
<!--
광고 영역 #2: 보조 광고 배너
- 크기: 320x150px 권장
- 추가 수익원
-->
<div class="ad-banner">
<div>
<div style="font-size: 16px; margin-bottom: 10px;">[AD BANNER #2]</div>
<div class="description-text">Secondary Advertisement Area<br>320x150px Recommended<br>Additional Revenue Stream</div>
</div>
</div>
</aside>
</div>
<script>
// 위험 허용도 슬라이더 기능
const riskSlider = document.getElementById('riskSlider');
const riskValue = document.getElementById('riskValue');
riskSlider.addEventListener('input', function() {
riskValue.textContent = this.value;
});
// 실시간 가격 업데이트 시뮬레이션
// 실제 구현시 WebSocket 또는 API 폴링 방식으로 교체
function updatePrices() {
const priceElements = document.querySelectorAll('.current-price');
const changeElements = document.querySelectorAll('.price-change');
priceElements.forEach((element, index) => {
// 실제로는 API에서 실시간 데이터를 받아와 업데이트
const currentText = element.textContent;
console.log('Price update simulation for:', currentText);
});
}
// 30초마다 가격 업데이트 (데모용)
setInterval(updatePrices, 30000);
</script>
</body>
</html>

View File

@ -0,0 +1,30 @@
import yfinance as yf
import pandas as pd
# 1. 애플 주식 종목 코드 (티커) 정의
ticker_symbol = "AAPL"
# 2. Ticker 객체 생성
apple = yf.Ticker(ticker_symbol)
# 3. 현재 가격 정보 가져오기
try:
current_price = apple.info['regularMarketPrice']
print(f"애플 주식 현재 가격: ${current_price:.2f}")
except KeyError:
print("현재 가격 정보를 가져오는 데 실패했습니다.")
# 4. 과거 캔들 데이터 가져오기
# 기간(period) 설정: 1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, 10y, ytd, max
# 간격(interval) 설정: 1m, 2m, 5m, 15m, 30m, 60m, 90m, 1h, 1d, 5d, 1wk, 1mo, 3mo
# 최근 30일치 일봉(1d) 데이터 가져오기 예제
hist = apple.history(period="30d", interval="1d")
# 5. 데이터 출력
if not hist.empty:
print("\n최근 30일치 애플 주식 캔들 데이터 (일봉):")
print(hist[['Open', 'High', 'Low', 'Close', 'Volume']].tail())
# DataFrame을 CSV 파일로 저장
# hist.to_csv('aapl_30days.csv')
else:
print("과거 데이터를 가져오는 데 실패했습니다. 티커를 확인하세요.")