배송 주체가 다르면(쿠팡 로켓 / 판매자로켓 / 네이버 판매자) 배송비 숫자만으로는 비교가
무의미하다. 그래서 **순위는 지금처럼 상품가**로 두되, 배송 정보는 사후 판단이 가능하도록 남긴다.
실측 조사(섬유유연제·생수 2L, 두 소스):
네이버 배송비무료 / 배송비3,000·3,900·4,500·5,000·8,800·9,000·10,000원 /
내일배송 8.6.(목) 도착 · 오늘출발 · 빠른배송 / **배송비포함 혜택가 N원**(가격비교 카드)
쿠팡 내일(목) 도착 보장 · 와우는 무료배송 ∙ 무료반품 ∙ 새벽도착 / 무료배송 ∙ 오늘출발 /
모레(금) 도착 예정. 뱃지=logo_rocket_filter(로켓)·logo_rocket_merchant(판매자로켓)
- NormalizedProduct.shipping_label: 화면 문구 원문. 같은 '무료배송'이어도 주체·조건
(와우회원·최소금액·새벽도착)이 다른데 숫자·분류로는 그게 사라진다
- 네이버: 배송비 문구 + 도착 정보를 잇는다. **_prices() 안에서 뽑는다** — 가격 노드에서
배송비를 decompose 하기 전에 읽어야 해서(나중에 추가했다가 가격비교 카드에서 라벨이 통째로 빔)
- 쿠팡: 배송 문구가 유틸리티 클래스(fw-text-[14px])에 담겨 셀렉터로 못 집는다 → 텍스트 패턴으로
조각을 모으고, 구분자 없이 붙은 상위 컨테이너("내일(목) 도착무료배송")는 조각 2개 이상을
품은 것으로 판별해 버린다. selectolax 의 node.css("*") 가 자기 자신을 포함해
'자손 매칭' 방식은 못 쓴다(실측)
- price_history.final_shipping_fee/type/label 추가(+마이그레이션). fee 는 0=무료,
NULL=미확인(로켓 조건부) — 둘은 다른 뜻이라 기본값을 두지 않았다
교차 검증(파싱값 vs 카드 원문, 6개 규칙): 네이버 40건·쿠팡 40건 **불일치 0**.
테스트 3건 추가, 전체 231 passed.
203 lines
13 KiB
Python
203 lines
13 KiB
Python
from sqlalchemy import Boolean, Column, Index, Integer, Numeric, SmallInteger, String, Text, DateTime
|
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
from sqlalchemy.orm import declarative_base
|
|
from sqlalchemy.sql import text
|
|
|
|
from common.enums import DBType
|
|
|
|
# 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다.
|
|
MAIN_BASE = declarative_base()
|
|
|
|
|
|
class job(MAIN_BASE):
|
|
"""작업 큐. PostgreSQL 을 '제대로' 큐로 쓴다 — 원자적 CAS claim + lease 소유권 + dead-letter.
|
|
코드값(status/type)은 SMALLINT 정수 코드(common.enums 매핑), 시각은 전 구간 TIMESTAMPTZ, 무 FK.
|
|
"""
|
|
|
|
@staticmethod
|
|
def DBType():
|
|
return DBType.MAIN.value
|
|
|
|
__tablename__ = "job"
|
|
|
|
job_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
|
|
job_type = Column(SmallInteger, nullable=False) # JobType
|
|
status = Column(SmallInteger, nullable=False, server_default=text("1")) # JobStatus (1=PENDING)
|
|
priority = Column(SmallInteger, nullable=False, server_default=text("100")) # 낮을수록 우선
|
|
payload = Column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) # 잡 입력
|
|
result = Column(JSONB, nullable=True) # 잡 출력(완료 시)
|
|
dedupe_key = Column(String(200), nullable=True) # 활성 중복 방지 키(부분 유니크)
|
|
attempts = Column(SmallInteger, nullable=False, server_default=text("0")) # 시도 횟수(claim 시 +1)
|
|
max_attempts = Column(SmallInteger, nullable=False, server_default=text("3"))
|
|
run_after = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) # 이 시각 이후에만 claim(백오프)
|
|
lease_until = Column(DateTime(timezone=True), nullable=True) # 소유권 임대 만료(reaper 회수 기준)
|
|
worker_id = Column(String(80), nullable=True) # 현재 점유 워커
|
|
run_started_at = Column(DateTime(timezone=True), nullable=True) # RUNNING 진입 시각(할당시각과 분리)
|
|
last_error = Column(Text, nullable=True)
|
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))
|
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"), onupdate=text("now()"))
|
|
|
|
__table_args__ = (
|
|
# claim 정렬/필터용: PENDING 중 run_after 지난 것을 priority·생성순으로
|
|
Index("ix_job_claim", "status", "run_after", "priority", "created_at"),
|
|
# reaper: 만료된 RUNNING lease 회수용
|
|
Index("ix_job_lease", "status", "lease_until"),
|
|
# 활성 중복 방지: 같은 dedupe_key 는 PENDING/RUNNING 중 하나만 존재 가능
|
|
Index(
|
|
"uq_job_dedupe_active",
|
|
"dedupe_key",
|
|
unique=True,
|
|
postgresql_where=text("status IN (1, 2) AND dedupe_key IS NOT NULL"),
|
|
),
|
|
)
|
|
|
|
|
|
class search_negative(MAIN_BASE):
|
|
"""네거티브 캐시 — '검색해도 없더라'를 TTL 동안 기억해 재검색 낭비를 막는다.
|
|
until 이 지나면 자동 무효(재도전 허용 — 나중에 입고될 수 있으므로)."""
|
|
|
|
@staticmethod
|
|
def DBType():
|
|
return DBType.MAIN.value
|
|
|
|
__tablename__ = "search_negative"
|
|
|
|
key = Column(String(300), primary_key=True) # 보통 product_code(없으면 query)
|
|
until = Column(DateTime(timezone=True), nullable=False) # 이 시각까지 not_found 로 간주
|
|
reason = Column(String(200), nullable=True) # 종료 사유 메모(관측)
|
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))
|
|
|
|
|
|
class price_history(MAIN_BASE):
|
|
"""상품별 최저가 스냅샷(트리거 기반). 네이버/쿠팡/최종 최저가를 검색 시점마다 적재해
|
|
시계열 그래프(X=triggered_at, Y=가격, 3개 선)로 본다. 배치 아님 — 조회된 상품만 기록."""
|
|
|
|
@staticmethod
|
|
def DBType():
|
|
return DBType.MAIN.value
|
|
|
|
__tablename__ = "price_history"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
|
|
product_code = Column(String(100), nullable=False) # 상품 식별(조회 키)
|
|
job_id = Column(UUID(as_uuid=True), nullable=True) # 검색 잡 연결(추적)
|
|
triggered_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) # X축(검색 실행 시각)
|
|
outcome = Column(String(20), nullable=False) # found / not_found
|
|
matched_count = Column(Integer, nullable=True) # AI 매칭 건수
|
|
|
|
naver_lowest = Column(Integer, nullable=True) # 네이버 최저가(같은 상품)
|
|
naver_name = Column(String(300), nullable=True)
|
|
naver_url = Column(Text, nullable=True)
|
|
coupang_lowest = Column(Integer, nullable=True) # 쿠팡 최저가(같은 상품)
|
|
coupang_name = Column(String(300), nullable=True)
|
|
coupang_url = Column(Text, nullable=True)
|
|
final_lowest = Column(Integer, nullable=True) # 전체 최저가(Y축 핵심)
|
|
final_source = Column(String(20), nullable=True) # 최종 최저가 소스
|
|
# 최저가 오퍼의 신뢰 신호 — '이 가격에 실제로 살 수 있나'를 사후 판단·분석하기 위함.
|
|
# 둘 다 NULL = 리뷰·평점이 없는 오퍼(재고 없는 미끼가격일 수 있음). 0 과 NULL 은 다른 뜻이다.
|
|
final_rating = Column(Numeric(3, 2), nullable=True) # 평점(5점 만점)
|
|
final_review_count = Column(Integer, nullable=True) # 리뷰 수
|
|
# 최저가 오퍼의 배송 정보. 순위는 상품가로 매기지만(배송 주체가 다르면 금액 비교가 무의미),
|
|
# **기록은 남긴다** — 나중에 '배송비까지 더하면 순위가 뒤집히나'를 데이터로 물을 수 있어야 한다.
|
|
final_shipping_fee = Column(Integer, nullable=True) # 0=무료, NULL=미확인(조건부)
|
|
final_shipping_type = Column(String(20), nullable=True) # free/paid/rocket/rocket_merchant
|
|
final_shipping_label = Column(String(120), nullable=True) # 화면 문구 원문
|
|
# 몰별 최저가 스냅샷(열린 스키마) — [{mall, source, price, shipping_fee, shipping_type, url}, ...].
|
|
# 몰이 늘어도 컬럼 추가/마이그레이션 없이 담는다(G마켓·옥션·11번가 등). naver/coupang 3선은 위 컬럼 유지.
|
|
by_mall = Column(JSONB, nullable=True)
|
|
|
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))
|
|
|
|
__table_args__ = (
|
|
# 특정 상품 시계열 조회 최적화
|
|
Index("ix_price_history_product", "product_code", "triggered_at"),
|
|
)
|
|
|
|
|
|
class ip_session(MAIN_BASE):
|
|
"""IP(프록시 포트) 세션 종료 이력 — '이 IP 로 몇 번 요청하고 어떻게 끝났나'를 매 세션 기록.
|
|
bot_detection 은 차단된 세션만 남지만 여기엔 무사 종료도 남아, 요청 예산(LPS_IP_REQUEST_BUDGET)
|
|
상한 튜닝의 원천 데이터가 된다. (예: end_reason='block' 의 requests 분포 → 안전 상한 산출)"""
|
|
|
|
@staticmethod
|
|
def DBType():
|
|
return DBType.MAIN.value
|
|
|
|
__tablename__ = "ip_session"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
|
|
source = Column(String(20), nullable=False) # coupang 등
|
|
proxy_port = Column(Integer, nullable=True) # 사용 포트(=IP 세션), 프록시 미사용이면 NULL
|
|
requests = Column(Integer, nullable=False) # 이 IP 로 보낸 요청 수
|
|
ok_count = Column(Integer, nullable=False, server_default=text("0")) # 성공 검색 수
|
|
blocked_count = Column(Integer, nullable=False, server_default=text("0")) # 차단 감지 수
|
|
elapsed_sec = Column(Integer, nullable=True) # 세션 지속 시간(초)
|
|
end_reason = Column(String(20), nullable=False) # budget/block/proxy_error/window/idle/shutdown/rotate
|
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) # 세션 종료 시각
|
|
|
|
__table_args__ = (
|
|
# 상한 튜닝 쿼리(소스·기간별 종료 사유 분포) 최적화
|
|
Index("ix_ip_session_source", "source", "created_at"),
|
|
)
|
|
|
|
|
|
class bot_detection(MAIN_BASE):
|
|
"""봇 감지 이력 — '이 IP로 몇 번째 요청에서, 어떤 방식으로 차단됐나'를 축적해 패턴 분석.
|
|
(예: SELECT avg(ip_request_no) → IP당 평균 몇 요청 만에 감지되는지)"""
|
|
|
|
@staticmethod
|
|
def DBType():
|
|
return DBType.MAIN.value
|
|
|
|
__tablename__ = "bot_detection"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
|
|
source = Column(String(20), nullable=False) # coupang 등
|
|
query = Column(String(300), nullable=True) # 감지 당시 검색어
|
|
ip_request_no = Column(Integer, nullable=True) # 현재 IP(브라우저)로 몇 번째 요청이었나
|
|
proxy_port = Column(Integer, nullable=True) # 사용 중이던 프록시 포트(=IP 세션)
|
|
elapsed_sec = Column(Integer, nullable=True) # 브라우저 실행 후 경과(초)
|
|
marker = Column(String(120), nullable=True) # 감지 근거(차단 페이지 마커)
|
|
headless = Column(Boolean, nullable=True)
|
|
html_len = Column(Integer, nullable=True) # 응답 길이(차단 페이지는 작음)
|
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))
|
|
|
|
|
|
class proxy_port(MAIN_BASE):
|
|
"""프록시 포트(=IP 세션) 임대 장부 — **프로세스 간 공유 상태**.
|
|
|
|
한 DECODO 계정을 여러 프로세스(워커 컨테이너·PROCESS_COUNT)가 나눠 쓰기 때문에
|
|
임대·쿨다운·휴식을 프로세스 메모리에 두면 서로의 상태를 모른다. 같은 IP 를 동시에
|
|
잡거나, 한쪽이 태운 IP 를 다른 쪽이 곧바로 집는다. 그래서 DB 를 단일 진실로 둔다.
|
|
|
|
행 1개 = 게이트웨이의 포트 1개(=sticky IP 세션 1개). 상태는 세 시각으로만 표현한다:
|
|
leased_until 임대 중(만료되면 자동 해제 — 프로세스가 죽어도 IP 가 영구히 묶이지 않는다)
|
|
rest_until 선제 회전으로 쉬는 중(탄 게 아님)
|
|
cooldown_until 차단당해 격리 중
|
|
셋 다 지났으면 가용. 회전은 last_used_at 오래된 순(LRU)이라 프로세스가 늘어도
|
|
전체가 자연스럽게 한 바퀴씩 돈다.
|
|
"""
|
|
|
|
@staticmethod
|
|
def DBType():
|
|
return DBType.MAIN.value
|
|
|
|
__tablename__ = "proxy_port"
|
|
|
|
host = Column(String(80), primary_key=True) # 게이트웨이(gate/kr — 같은 번호라도 IP 가 다름)
|
|
port = Column(Integer, primary_key=True)
|
|
owner = Column(String(80), nullable=True) # 현재 임대자(worker/소스 식별)
|
|
leased_until = Column(DateTime(timezone=True), nullable=True) # 임대 만료(=sticky 수명)
|
|
rest_until = Column(DateTime(timezone=True), nullable=True) # 휴식 만료(선제 회전)
|
|
cooldown_until = Column(DateTime(timezone=True), nullable=True) # 쿨다운 만료(차단)
|
|
last_used_at = Column(DateTime(timezone=True), nullable=True) # 마지막 임대 시각(LRU 회전 기준)
|
|
last_reason = Column(String(40), nullable=True) # 마지막 상태 변경 사유(block/budget/window…)
|
|
use_count = Column(Integer, nullable=False, server_default=text("0")) # 누적 임대 횟수(관측)
|
|
burn_count = Column(Integer, nullable=False, server_default=text("0")) # 누적 차단 횟수(불량 IP 슬롯 식별)
|
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"), onupdate=text("now()"))
|
|
|
|
__table_args__ = (
|
|
# acquire 정렬/필터용 — 가용 판정(3개 시각)과 LRU 정렬을 한 인덱스로 태운다.
|
|
Index("ix_proxy_port_pick", "host", "last_used_at"),
|
|
)
|