refactor(lps): 환경설정 TOML 단일화 — .env 제거, 시크릿도 config.local.toml 로 통합
설정이 .env(시크릿)+toml(설정)로 갈려 있던 것을 config.local.toml 하나로 통합. DB 비번이 이미 toml 에 있어 분리 기준이 임의적이었고, backend(하우스 패턴)도 toml 단일이라 일관성 확보. 환경별로 바뀌는 값(DB_HOST 등)만 env override 유지. - config_models: NaverConfig(keys 로테이션)·OpenAIConfig·DecodoConfig 추가 - server_configs: 3개 로드, load_dotenv 제거(python-dotenv 의존성도 제거) - proxy/naver/similarity/keyword/worker_main: os.environ → config 객체 참조 - config.local.toml.example: [NaverConfig]/[OpenAIConfig]/[DecodoConfig] 섹션 - .env/.env.example 삭제, README/주석 갱신 (배포는 toml 마운트 or env override) - tests: DecodoConfig 기반으로 갱신 → 전체 44/44 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2481c79e3f
commit
7911ba1745
@ -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 회전
|
||||
@ -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`
|
||||
|
||||
## 테스트
|
||||
|
||||
@ -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 = "<NAVER_CLIENT_ID>"
|
||||
secret = "<NAVER_CLIENT_SECRET>"
|
||||
# 추가 키는 아래처럼 블록을 더 넣으면 됨:
|
||||
# [[NaverConfig.keys]]
|
||||
# id = "..."
|
||||
# secret = "..."
|
||||
|
||||
# AI 유사도 판정/검색어 생성 (OpenAI)
|
||||
[OpenAIConfig]
|
||||
api_key = "<OPENAI_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 지속시간(분)과 일치
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 그대로.
|
||||
|
||||
@ -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 사용
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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 리스트 반환."""
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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(키 없음)'}")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user