diff --git a/lps/.env.example b/lps/.env.example deleted file mode 100644 index f7b28bc..0000000 --- a/lps/.env.example +++ /dev/null @@ -1,25 +0,0 @@ -# LPS 환경변수 (시크릿). 복사해서 .env 로 만들고 값을 채우세요: -# cp .env.example .env -# .env 는 커밋되지 않습니다(.gitignore). 값은 큰따옴표 안에 넣으세요. -# 앱/워커 기동 시 config/server_configs 가 이 파일을 환경변수로 로드합니다. - -# ── 네이버 쇼핑 검색 오픈 API ───────────────────────────────────────── -# https://developers.naver.com/apps → 애플리케이션 등록 → 사용 API '검색' -NAVER_CLIENT_ID="" -NAVER_CLIENT_SECRET="" -# 로테이션(선택): 429/403 쿼터 회피용 추가 키는 _2, _3 ... 로 늘리면 자동 포함 -# NAVER_CLIENT_ID_2="" -# NAVER_CLIENT_SECRET_2="" - -# ── AI (상품 유사도 판정 / 검색어 생성) — OpenAI ────────────────────── -OPENAI_API_KEY="" - -# ── DECODO residential 프록시 (쿠팡 전용, 포트 기반 sticky+주기 회전) ── -# DECODO 대시보드 Residential > Proxy setup 값. HOST/USER/PASS + 포트범위 다 채우면 활성. -# IP 회전 = 포트 순환(각 포트가 sticky 세션). SESSION_MINUTES 는 대시보드 Sticky 지속시간과 맞춘다. -DECODO_HOST="" # 예: gate.decodo.com -DECODO_USERNAME="" # 대시보드 USERNAME (예: sppd6a3ze3) -DECODO_PASSWORD="" # 대시보드 PASSWORD -DECODO_PORT_START="10001" # 엔드포인트 포트 시작 -DECODO_PORT_END="10010" # 엔드포인트 포트 끝 -DECODO_SESSION_MINUTES="10" # 대시보드 Sticky 지속시간(분)과 일치 → 이 주기로 포트/ IP 회전 diff --git a/lps/README.md b/lps/README.md index 2e510f9..8d6da00 100644 --- a/lps/README.md +++ b/lps/README.md @@ -37,11 +37,11 @@ lps/ ## 로컬 실행 ```bash -cp config/config.local.toml.example config/config.local.toml # DB 등 비-시크릿 설정 -cp .env.example .env # 시크릿(API 키) — NAVER/OPENAI 등 +cp config/config.local.toml.example config/config.local.toml # 설정+시크릿 전부(포트/DB/API 키) ./run_local_server.sh # → http://localhost:9600/docs ``` -> `config.local.toml` = 비밀 아닌 설정(포트/DB), `.env` = 시크릿(API 키). 둘 다 git 미추적. +> `config.local.toml` 한 파일에 설정과 시크릿(API 키)을 통합 관리(git 미추적). +> 배포는 이 파일을 마운트하거나, 환경별로 바뀌는 값만 env override(DB_HOST 등). > 워커는 별도 프로세스: `python worker_main.py` ## 테스트 diff --git a/lps/config/config.local.toml.example b/lps/config/config.local.toml.example index d185ee7..4be22df 100644 --- a/lps/config/config.local.toml.example +++ b/lps/config/config.local.toml.example @@ -32,3 +32,29 @@ show_log = false pool_size = 10 max_overflow = 20 sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"verify-full" + +# ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). 배포는 이 파일 마운트 권장. ── + +# 네이버 쇼핑 오픈API (https://developers.naver.com/apps). 여러 개면 429/403 로테이션 자동 포함. +[NaverConfig] +[[NaverConfig.keys]] +id = "" +secret = "" +# 추가 키는 아래처럼 블록을 더 넣으면 됨: +# [[NaverConfig.keys]] +# id = "..." +# secret = "..." + +# AI 유사도 판정/검색어 생성 (OpenAI) +[OpenAIConfig] +api_key = "" +model = "gpt-4o-mini" + +# DECODO residential 프록시 (쿠팡 전용, 포트기반 sticky). 값 다 채우면 활성(비면 프록시 미사용). +[DecodoConfig] +host = "" # 예: gate.decodo.com +username = "" # 대시보드 USERNAME (예: sppd6a3ze3) +password = "" # 대시보드 PASSWORD +port_start = 0 # 예: 10001 +port_end = 0 # 예: 10010 +session_minutes = 10 # 대시보드 Sticky 지속시간(분)과 일치 diff --git a/lps/config/config_models.py b/lps/config/config_models.py index 5826522..e7cf59a 100644 --- a/lps/config/config_models.py +++ b/lps/config/config_models.py @@ -1,3 +1,5 @@ +from pydantic import BaseModel + from config.config_loader import ConfigModel @@ -36,3 +38,36 @@ class MainDBConfig(ConfigModel): max_overflow: int = 20 # SSL/TLS 모드: ""/"disable"=미사용(로컬), "require"/"verify-ca"/"verify-full"=관리형 DB(RDS/Aurora/Azure). sslmode: str = "" + + +# ── 시크릿(API 키 등)도 TOML 로 통합 관리. config.local.toml 은 미커밋(*.toml). ── +# 배포는 이 파일을 마운트하거나(권장), 환경별로 바뀌는 값만 env override 한다(DB_HOST 등). + + +class NaverKey(BaseModel): + id: str = "" + secret: str = "" + + +class NaverConfig(ConfigModel): + """네이버 쇼핑 오픈API 키. 여러 개면 429/403 로테이션에 자동 포함.""" + + keys: list[NaverKey] = [] + + +class OpenAIConfig(ConfigModel): + """AI 유사도 판정/검색어 생성용 OpenAI.""" + + api_key: str = "" + model: str = "gpt-4o-mini" + + +class DecodoConfig(ConfigModel): + """DECODO residential 프록시(쿠팡 전용, 포트기반 sticky). 값이 다 차야 활성.""" + + host: str = "" + username: str = "" + password: str = "" + port_start: int = 0 + port_end: int = 0 + session_minutes: int = 10 diff --git a/lps/config/server_configs.py b/lps/config/server_configs.py index 30d4e82..e93b65b 100644 --- a/lps/config/server_configs.py +++ b/lps/config/server_configs.py @@ -1,13 +1,9 @@ import os -from dotenv import load_dotenv - from config.config_loader import Configs -from config.config_models import WebServerConfig, LogConfig, MainDBConfig - -# 시크릿(.env: API 키 등)을 환경변수로 로드. lps 루트의 .env 를 읽는다(없으면 조용히 무시). -# TOML(비밀 아님) 과 env(시크릿) 를 분리 — 키는 코드/커밋에 넣지 않는다. -load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) +from config.config_models import ( + WebServerConfig, LogConfig, MainDBConfig, NaverConfig, OpenAIConfig, DecodoConfig, +) # 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경. APP_ENV = os.environ.get("APP_ENV", "local") @@ -24,6 +20,10 @@ configs = Configs(_config_file) web_server_config: WebServerConfig = configs.get(WebServerConfig) log_config: LogConfig = configs.get(LogConfig) main_db_config: MainDBConfig = configs.get(MainDBConfig) +# 시크릿 포함 설정도 TOML 로 통합. 섹션이 없으면 기본값(빈/비활성). +naver_config: NaverConfig = configs.get(NaverConfig) or NaverConfig() +openai_config: OpenAIConfig = configs.get(OpenAIConfig) or OpenAIConfig() +decodo_config: DecodoConfig = configs.get(DecodoConfig) or DecodoConfig() # DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로. diff --git a/lps/requirements.txt b/lps/requirements.txt index 4bb610e..ea624a8 100644 --- a/lps/requirements.txt +++ b/lps/requirements.txt @@ -5,7 +5,6 @@ greenlet # SQLAlchemy async 의 sync/async 브리지에 필수 (일부 환경 asyncpg orjson pydantic>=2.0 -python-dotenv # .env(시크릿: API 키) 로드 — server_configs 에서 환경변수로 주입 httpx # 외부 사이트/오픈API(최저가 조회) 호출용 async HTTP 클라이언트 # --- 크롤링(LPS) — 안티봇 대응은 어댑터 안에 격리. 트레드밀 대비 버전 pin. --- @@ -14,4 +13,4 @@ selectolax==0.4.10 # 빠른 C 파서(쿠팡 HTML). bs4 대비 최대 30배 patchright # 스텔스 Playwright 포크. 쿠팡 Akamai JS 챌린지 통과(실제 Chrome, channel=chrome) # ※ nodriver 는 Python 3.14 소스인코딩 버그로 미채택 → Patchright 로 대체 # ※ 실행엔 시스템 Google Chrome 필요(로컬) / 배포 이미지엔 chromium 설치 필요 -openai # AI 유사도 판정(같은 상품 매칭) — OPENAI_API_KEY(.env). structured output 사용 +openai # AI 유사도 판정(같은 상품 매칭) — [OpenAIConfig].api_key(config.local.toml). structured output 사용 diff --git a/lps/services/ai/keyword.py b/lps/services/ai/keyword.py index b1e8e35..f39eb4d 100644 --- a/lps/services/ai/keyword.py +++ b/lps/services/ai/keyword.py @@ -6,12 +6,11 @@ 레퍼런스 keyword_maker 의 규칙(모델명 우선, 일반 카테고리어 제거)을 structured output 으로 이식. """ -import os - from openai import AsyncOpenAI from pydantic import BaseModel, Field from common.logger import LOG +from config.server_configs import openai_config _SYSTEM = ( "너는 커머스 검색어 생성기다. 상품 정보를 받아 쇼핑몰 검색창에 넣을 한국어 검색어 2개를 만든다.\n" @@ -28,9 +27,9 @@ class Keywords(BaseModel): class KeywordGenerator: - def __init__(self, model: str = "gpt-4o-mini", api_key: str | None = None): - self._model = model - self._client = AsyncOpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY")) + def __init__(self, model: str | None = None, api_key: str | None = None): + self._model = model or openai_config.model + self._client = AsyncOpenAI(api_key=api_key or openai_config.api_key) async def generate(self, target: dict) -> Keywords: user = ( diff --git a/lps/services/ai/similarity.py b/lps/services/ai/similarity.py index 3867a03..70af0ff 100644 --- a/lps/services/ai/similarity.py +++ b/lps/services/ai/similarity.py @@ -5,12 +5,11 @@ LLM 판단으로 대체 — 액세서리/호환부품/다른 상품/명백히 structured output(Pydantic)으로 정규식 파싱 없이 안정적으로 결과를 받는다. """ -import os - from openai import AsyncOpenAI from pydantic import BaseModel, Field from common.logger import LOG +from config.server_configs import openai_config from services.search.contract import NormalizedProduct _SYSTEM = ( @@ -35,9 +34,9 @@ class JudgmentList(BaseModel): class SimilarityJudge: - def __init__(self, model: str = "gpt-4o-mini", api_key: str | None = None): - self._model = model - self._client = AsyncOpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY")) + def __init__(self, model: str | None = None, api_key: str | None = None): + self._model = model or openai_config.model + self._client = AsyncOpenAI(api_key=api_key or openai_config.api_key) async def judge(self, target: dict, candidates: list[NormalizedProduct]) -> list[Judgment]: """후보별 동일상품 여부 판정. candidates 와 같은 순서/길이로 Judgment 리스트 반환.""" diff --git a/lps/services/search/naver/adapter.py b/lps/services/search/naver/adapter.py index 2c61ddf..691f8e6 100644 --- a/lps/services/search/naver/adapter.py +++ b/lps/services/search/naver/adapter.py @@ -2,14 +2,13 @@ 공식 오픈 API(https://openapi.naver.com/v1/search/shop.json)라 크롤링/브라우저 불필요. 레퍼런스의 핵심 자산인 **키 로테이션**을 이식: 429/403(쿼터/차단) 시 다음 키로 순환 재시도. -키는 .env(NAVER_CLIENT_ID/SECRET, +_2.._10)에서 로드 → server_configs 가 load_dotenv 로 주입. +키는 config.local.toml [NaverConfig].keys 에서 로드(여러 개면 로테이션). """ -import os - import httpx from common.logger import LOG +from config.server_configs import naver_config from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth from services.search.rate_limiter import RateLimiter from services.search.naver.transform import transform_items @@ -20,16 +19,8 @@ _MAX_DISPLAY = 100 def load_naver_keys() -> list[tuple[str, str]]: - """(client_id, client_secret) 쌍 목록. 기본(무접미) + _2.._10 로테이션 키.""" - keys: list[tuple[str, str]] = [] - cid, csec = os.environ.get("NAVER_CLIENT_ID"), os.environ.get("NAVER_CLIENT_SECRET") - if cid and csec: - keys.append((cid, csec)) - for i in range(2, 11): - cid, csec = os.environ.get(f"NAVER_CLIENT_ID_{i}"), os.environ.get(f"NAVER_CLIENT_SECRET_{i}") - if cid and csec: - keys.append((cid, csec)) - return keys + """(client_id, client_secret) 쌍 목록. TOML [NaverConfig].keys 에서 로드(여러 개면 로테이션).""" + return [(k.id, k.secret) for k in naver_config.keys if k.id and k.secret] class NaverAdapter(SearchAdapter): @@ -52,7 +43,7 @@ class NaverAdapter(SearchAdapter): async def search(self, query: str, limit: int = 40) -> list[NormalizedProduct]: if not self._keys: - raise AdapterError("네이버 API 키 없음(.env NAVER_CLIENT_ID/SECRET)", source=self.source) + raise AdapterError("네이버 API 키 없음(config.local.toml [NaverConfig].keys)", source=self.source) collected: list[dict] = [] async with httpx.AsyncClient(timeout=self._timeout) as client: diff --git a/lps/services/search/proxy.py b/lps/services/search/proxy.py index ff80678..dad3f83 100644 --- a/lps/services/search/proxy.py +++ b/lps/services/search/proxy.py @@ -6,31 +6,23 @@ Decodo residential 은 **포트 기반 sticky** 모델이다: 매 요청 IP 변경은 Akamai 가 '쿠키-IP 불일치'로 재챌린지하므로 금물. → 시간창(now // window)으로 포트를 고른다: 창 안에선 같은 포트=같은 IP, 창이 지나면 다음 포트=새 IP. -자격증명/엔드포인트는 .env(DECODO_*) 에서 로드(시크릿). +자격증명/엔드포인트는 config.local.toml [DecodoConfig] 에서 로드(시크릿). """ -import os import time -_UNSET = object() # 미지정(→env 사용) vs 명시적 빈값("" 등, 그대로 사용) 구분 - - -def _int(v, default=None): - try: - return int(v) - except (TypeError, ValueError): - return default +from config.server_configs import decodo_config class DecodoProxy: - def __init__(self, host=_UNSET, username=_UNSET, password=_UNSET, port_start=_UNSET, port_end=_UNSET, session_minutes=_UNSET): - env = os.environ.get - self.host = env("DECODO_HOST") if host is _UNSET else host - self.username = env("DECODO_USERNAME") if username is _UNSET else username - self.password = env("DECODO_PASSWORD") if password is _UNSET else password - self.port_start = _int(env("DECODO_PORT_START") if port_start is _UNSET else port_start) - self.port_end = _int(env("DECODO_PORT_END") if port_end is _UNSET else port_end) - self.session_minutes = _int(env("DECODO_SESSION_MINUTES") if session_minutes is _UNSET else session_minutes, 10) + def __init__(self, cfg=None): + cfg = cfg if cfg is not None else decodo_config + self.host = cfg.host + self.username = cfg.username + self.password = cfg.password + self.port_start = cfg.port_start + self.port_end = cfg.port_end + self.session_minutes = cfg.session_minutes or 10 @property def enabled(self) -> bool: diff --git a/lps/tests/test_proxy.py b/lps/tests/test_proxy.py index 7295edc..c8c8b64 100644 --- a/lps/tests/test_proxy.py +++ b/lps/tests/test_proxy.py @@ -1,18 +1,19 @@ """DECODO 프록시 제공자 테스트 (포트 기반 sticky, 순수·네트워크 불필요).""" +from config.config_models import DecodoConfig from services.search.proxy import DecodoProxy def _p(**kw): base = dict(host="gate.decodo.com", username="user1", password="pw", port_start=10001, port_end=10010, session_minutes=10) base.update(kw) - return DecodoProxy(**base) + return DecodoProxy(DecodoConfig(**base)) def test_disabled_when_credentials_missing(): - assert DecodoProxy(host="", username="", password="", port_start=None, port_end=None).enabled is False + assert DecodoProxy(DecodoConfig()).enabled is False # 전부 비어있음 assert _p(password="").enabled is False - assert _p(port_end=None).enabled is False + assert _p(port_end=0).enabled is False # 포트 미설정 assert _p().enabled is True @@ -25,7 +26,7 @@ def test_playwright_proxy_shape(): def test_disabled_returns_none(): - assert DecodoProxy(host="", username="", password="", port_start=None, port_end=None).playwright_proxy() is None + assert DecodoProxy(DecodoConfig()).playwright_proxy() is None def test_port_selected_within_range_and_stable_in_window(): diff --git a/lps/worker_main.py b/lps/worker_main.py index f24522d..0033da5 100644 --- a/lps/worker_main.py +++ b/lps/worker_main.py @@ -9,7 +9,7 @@ import asyncio import os from common.logger import LOG -from config.server_configs import web_server_config +from config.server_configs import web_server_config, openai_config from crud.job_crud import JobQueue from crud.negative_cache import NegativeCache from services.search.proxy import DecodoProxy @@ -31,8 +31,8 @@ async def main(concurrency: int = 1): proxy = DecodoProxy() LOG.i(f"DECODO 프록시: {'ON(sticky ' + str(proxy.session_minutes) + '분 회전)' if proxy.enabled else 'OFF(.env 미설정)'}") adapters = {"coupang": CoupangAdapter(headless=False, proxy=proxy), "naver": NaverAdapter()} - # OPENAI_API_KEY 있으면 '같은 상품' AI 판정 + 재검색어 생성 활성화 - has_openai = bool(os.environ.get("OPENAI_API_KEY")) + # OpenAI 키 있으면 '같은 상품' AI 판정 + 재검색어 생성 활성화 + has_openai = bool(openai_config.api_key) judge = SimilarityJudge() if has_openai else None keyword_gen = KeywordGenerator() if has_openai else None LOG.i(f"AI(판정+검색어생성): {'ON' if has_openai else 'OFF(키 없음)'}")