한국석유공사(KNOC) 오피넷 오픈 API의 비공식 Python 클라이언트 라이브러리.
전국 약 1만 2천여 개 주유소·자동차충전소의 가격 정보를 조회하고, 좌표(KATEC ↔ WGS84) 변환과 응답 데이터의 Python 네이티브 타입 변환을 자동 처리합니다.
현재 저장소는
opinet-api.md명세를 바탕으로 공식 5개 엔드포인트를 구현한 초기 라이브러리입니다. PDF 가이드북의 추가 API는 아직 검증 전이므로opinet.experimental에 분리합니다.
- 공식 검증된 5개 엔드포인트: 한국석유공사 공식 사이트의 오픈 API 페이지에 등재된 5종을 1차 구현 대상으로 삼아 안정성을 확보합니다.
- Python 네이티브 타입으로 자동 변환: API가 모두 문자열로 주는 응답을
datetime.date,datetime.time,float,bool,StrEnum으로 변환해서 모델에 채웁니다. 사용자가 별도 캐스팅할 필요 없습니다. - 좌표계 자동 변환: 사용자 입력은 WGS84(위경도), API 호출은 KATEC. 응답의 KATEC 좌표는 자동으로 WGS84로 변환되어 모델에 채워집니다.
- 타입 안전한 모델:
dataclass(frozen, slots)기반의 응답 모델,StrEnum으로 제품/상표/정렬/업종 코드를 정의합니다. - 시도코드 ↔ 법정동코드 매핑: 다른 정부 데이터와 join할 수 있도록 행정안전부 표준 법정동코드와의 양방향 매핑을 제공합니다.
- 체계적인 예외 매핑: 인증 실패, 호출 한도, 네트워크 오류, 서버 오류, 파라미터 오류, 타입 변환 오류를 별도 예외로 구분합니다.
- 실제 응답 기반 fixture: 명세서 자체가 오피넷 공식 사이트에서 발췌한 실제 응답을 포함하므로, 키 없이도 단위테스트가 동작합니다.
- 디버그 UI/fixture 생성 지원: 공식 5개 API 카탈로그, 사람이 읽기 쉬운 데이터셋명, 서비스키 발급 링크, replay fixture 저장 헬퍼를 라이브러리에서 제공합니다.
- https://www.opinet.co.kr/user/custapi/openApiNew.do 에서 회원가입 후 신청.
- 자동 승인되며 즉시 키가 발급됩니다.
- 환경변수에 저장:
export OPINET_API_KEY="발급받은_키"
pip install python-opinet-api # PyPI 배포 후또는 소스에서:
git clone https://github.com/digitie/python-opinet-api.git
cd python-opinet-api
pip install -e .개발 환경:
pip install -e ".[dev]"
pytest실제 API 서버 테스트를 하려면 저장소에 커밋되지 않는 로컬 .env에 키를 넣습니다.
cp .env.example .env
# .env 안의 OPINET_API_KEY 값을 본인 키로 변경
pytest -m live --run-liveOpinetClient()는 명시 인자, 환경변수, 현재 작업 디렉터리 또는 부모 디렉터리의 .env 순서로 OPINET_API_KEY를 찾습니다. 서비스키를 복사/붙여넣기할 때 섞인 줄바꿈, 탭, 공백은 자동으로 제거됩니다.
from datetime import date, time
from opinet import OpinetClient, ProductCode, SortOrder
from opinet.codes import StationType, BrandCode
client = OpinetClient() # 환경변수에서 키 자동 로드
# 1) 전국 평균가 — 모든 필드는 Python 타입
for row in client.get_national_average_price():
assert isinstance(row.trade_date, date) # YYYYMMDD → date
assert isinstance(row.price, float) # "1919.44" → 1919.44
assert isinstance(row.diff, float) # "+0.39" → 0.39
assert isinstance(row.product_code, ProductCode) # "B027" → enum
print(f"{row.product_name}: {row.price:,.2f}원 ({row.diff:+.2f}) — {row.trade_date}")
# 2) 강남역 반경 3km 내 가장 싼 휘발유 5곳
stations = client.search_stations_around(
lon=127.0276,
lat=37.4979, # 강남역 위경도
radius_m=3000,
prodcd=ProductCode.GASOLINE,
sort=SortOrder.PRICE,
)
for s in stations[:5]:
print(f"{s.name}: {s.price:,.0f}원, {s.distance_m:.0f}m, "
f"({s.lon:.4f}, {s.lat:.4f})")
# 3) 주유소 ID로 상세 조회
detail = client.get_station_detail("A0010207")
assert isinstance(detail.station_type, StationType)
assert isinstance(detail.has_carwash, bool)
print(f"{detail.name} ({detail.brand.name})")
print(f" 주소: {detail.address_road}")
print(f" 업종: {detail.station_type.name}") # GAS_STATION / LPG_STATION / BOTH
print(f" 편의시설: 정비={detail.has_maintenance}, 세차={detail.has_carwash}")
for p in detail.prices:
# trade_date는 date, trade_time은 time
print(f" {p.product_code.name}: {p.price:,.0f}원 "
f"({p.trade_date.isoformat()} {p.trade_time.isoformat()})")
# 4) 전국 휘발유 최저가 Top 10
for s in client.get_lowest_price_top20(ProductCode.GASOLINE, cnt=10):
print(f"{s.price:,.0f}원 — {s.name}")
# 5) 시도 코드 목록 — code는 str (선행 0 보존)
for area in client.get_area_codes():
print(f"{area.code}: {area.name}") # "01: 서울"OpinetClient는 httpx.Client 기반의 동기 facade이고, OpinetClient.aio()는 python-krheritage-api와 같은 형태로 AsyncOpinetClient를 반환합니다. 두 클라이언트는 동일한 모델과 python-krtour-map 호환 normalized 계약을 유지합니다.
import asyncio
from opinet import OpinetClient, ProductCode
async def main() -> None:
async with OpinetClient.aio() as client:
rows = await client.get_lowest_price_top20(ProductCode.GASOLINE, cnt=5, area="01")
for row in rows:
print(row.provider_station_id, row.name, row.price)
asyncio.run(main())오피넷 API는 모든 값을 문자열로 반환합니다 ("PRICE": "1745", "TRADE_DT": "20250723" 등). 본 라이브러리는 이를 자동으로 Python 네이티브 타입으로 변환합니다.
| API 원본 | Python 타입 | 예시 |
|---|---|---|
날짜 "YYYYMMDD" |
datetime.date |
"20250723" → date(2025, 7, 23) |
시각 "HHMMSS" |
datetime.time |
"145618" → time(14, 56, 18) |
| 가격/거리/좌표 | float |
"1919.44" → 1919.44 |
| 부호 있는 등락값 | float |
"+0.39" → 0.39 |
| Y/N 플래그 | bool |
"Y" → True |
업종 (LPG_YN) |
StationType enum |
"N" → StationType.GAS_STATION |
상표 (POLL_DIV_CO) |
BrandCode enum |
"SKE" → BrandCode.SKE |
제품 (PRODCD) |
ProductCode enum |
"B027" → ProductCode.GASOLINE |
| 빈 문자열 / 공백 | None |
" " → None |
| 시군구 코드 | str (선행 0 보존) |
"0113" 그대로 |
타입 변환에 실패하면(예: 잘못된 날짜 포맷) OpinetServerError로 변환되어 raise됩니다.
| 메서드 | 엔드포인트 | 반환 |
|---|---|---|
get_national_average_price() |
avgAllPrice.do |
list[AvgPrice] |
get_lowest_price_top20() |
lowTop10.do |
list[Station] |
search_stations_around() |
aroundAll.do |
list[Station] (with distance_m) |
get_station_detail() |
detailById.do |
StationDetail (with prices: tuple[OilPrice, ...]) |
get_area_codes() |
areaCode.do |
list[AreaCode] |
상세 명세는 opinet-api.md 참조.
OpiNet OpenAPI에는 지역(시도/시군구)·전국 단위 주유소 목록(bulk) 엔드포인트가 없습니다. 공개 5종 중 station을 주는 것은
aroundAll(반경 ≤5km)·lowTop10(최저가 Top 20)·detailById(uni_id 단건)뿐이며,areaCode는 코드만,avg*는 가격 집계만 반환합니다. PDF 가이드북의 미검증 17종도 모두 가격 집계/이름검색이라 enumeration이 불가합니다.
그래서 iter_stations_in_bbox()는 bbox를 aroundAll 반경 원으로 빈틈없이 덮는 격자로
호출하고 uni_id 기준 중복 제거해 근사 enumeration을 제공합니다.
# 시군구 등 bounded 영역 권장 (좌표는 WGS84)
for station in client.iter_stations_in_bbox(
min_lon=127.00, min_lat=37.46, max_lon=127.14, max_lat=37.55, radius_m=5000
):
print(station.uni_id, station.name, station.lon, station.lat)| 메서드 | 기반 | 반환 |
|---|---|---|
iter_stations_in_bbox() / AsyncOpinetClient.iter_stations_in_bbox() |
aroundAll.do 격자 + dedup |
Iterator[Station] / AsyncIterator[Station] |
주의:
- 호출 수가 면적에 비례해 급증합니다. 전국(약 1000km×550km)을
radius_m=5000으로 덮으면 ~1만+ 회 호출 → OpiNet 일일 쿼터/ToS를 위협합니다. 시군구 등 bounded 영역에 쓰세요. rate-limit/쿼터는 호출 측 책임입니다. aroundAll은tel/station_type(lpg_yn)을 주지 않습니다. 이 필드가 필요하면uni_id로get_station_detail()을 별도 호출(N+1)하세요.
디버그 UI나 외부 도구가 API 목록을 하드코딩하지 않도록 공식 5개 API 카탈로그를 제공합니다. 각 항목에는 함수명, 엔드포인트, 데이터셋 식별자, 사람이 읽기 쉬운 데이터셋명, 파라미터 설명, 서비스키 발급 링크가 들어 있습니다.
from opinet import OpinetClient, get_api_catalog_options, get_api_catalog_item
options = get_api_catalog_options()
# "반경 내 주유소 가격 (aroundAll.do)"처럼 UI에 바로 표시 가능
item = get_api_catalog_item("search_stations_around")
print(item.dataset_name) # 반경 내 주유소 가격
print(item.service_key_url) # https://www.opinet.co.kr/user/custapi/openApiNew.do
client = OpinetClient() # .env 또는 환경변수의 OPINET_API_KEY 자동 로드
run = client.debug().get_area_codes()
print(run.dataset_name) # 오피넷 시도/시군구 코드
print(run.trace_payload) # Debug Trace 탭 표시용 payloadStreamlit 예제는 examples/streamlit_debug_app.py에 있습니다. 라이브러리 본체는 Streamlit에 의존하지 않으며, 예제 앱은 API 선택 시 카탈로그 항목과 서비스키 발급 링크를 함께 표시합니다.
pip install streamlit
streamlit run examples/streamlit_debug_app.pyfixture 생성/replay 설계는 docs/debug-fixture-workflow.md에 정리되어 있습니다.
from opinet.codes import ProductCode, BrandCode, SortOrder, StationType, is_alddle
ProductCode.GASOLINE # "B027" 휘발유
ProductCode.GASOLINE_PREMIUM # "B034" 고급휘발유
ProductCode.DIESEL # "D047" 자동차용경유
ProductCode.KEROSENE # "C004" 실내등유
ProductCode.LPG # "K015" 자동차용부탄
BrandCode.SKE # SK에너지
BrandCode.GSC # GS칼텍스
BrandCode.HDO # 현대오일뱅크
BrandCode.SOL # S-OIL
BrandCode.RTO # 알뜰 계열(실서버 응답 확인)
BrandCode.RTE # 자영알뜰
BrandCode.RTX # 고속도로알뜰
BrandCode.NHO # 농협알뜰
SortOrder.PRICE # "1" 가격순
SortOrder.DISTANCE # "2" 거리순
StationType.GAS_STATION # "N" 주유소
StationType.LPG_STATION # "Y" 자동차충전소
StationType.BOTH # "C" 겸업
is_alddle(BrandCode.RTE) # True (RTO/RTE/RTX/NHO이면 알뜰)
is_alddle(BrandCode.SKE) # False오피넷 API는 모든 좌표를 KATEC (오피넷 자체 TM 좌표계, m 단위)로 주고받습니다. 일반적인 위경도(WGS84)와 다릅니다.
본 라이브러리는 WGS84 lon/lat 또는 KATEC katec_x/katec_y 원시 좌표 쌍을 받습니다. WGS84 입력은 내부에서 KATEC으로 변환해 aroundAll.do에 전달합니다.
stations = client.search_stations_around(
lon=127.0276,
lat=37.4979,
...
)
# 응답 모델: WGS84와 KATEC 원본 float를 함께 제공
station = stations[0]
station.lon, station.lat # WGS84 float
station.katec_x, station.katec_y # KATEC float
station.lon_lat # (lon, lat)
station.katec_xy # (x, y)직접 변환이 필요하면 opinet.coords를 사용합니다:
from opinet.coords import katec_to_wgs84, wgs84_to_katec
x, y = wgs84_to_katec(127.0276, 37.4979)
lon, lat = katec_to_wgs84(314871.80, 544012.00) # SK서광주유소+proj=tmerc +lat_0=38 +lon_0=128 +k=0.9999 +x_0=400000 +y_0=600000
+ellps=bessel +units=m
+towgs84=-115.80,474.99,674.11,1.16,-2.31,-1.63,6.43 +no_defs
오피넷 공식 응답의 실측 좌표와 ±10m 이내로 일치함을 검증.
오피넷의 시도 코드는 행정안전부 표준 법정동 코드와 완전히 다른 별도 체계입니다. 다른 정부 데이터(부동산, 인구통계, 주민등록 등)와 join하려면 매핑이 필요합니다.
from opinet.codes import opinet_sido_to_bjd, bjd_sido_to_opinet
# 오피넷 → 법정동
opinet_sido_to_bjd("01") # → "11" (서울특별시)
opinet_sido_to_bjd("11") # → "50" (제주특별자치도)
opinet_sido_to_bjd("19") # → "36" (세종특별자치시)
# 법정동 → 오피넷
bjd_sido_to_opinet("11") # → "01"
bjd_sido_to_opinet("26") # → "10" (부산)
bjd_sido_to_opinet("51") # → "03" (강원특별자치도 신코드 → 오피넷 강원)
bjd_sido_to_opinet("52") # → "06" (전북특별자치도 신코드 → 오피넷 전북)매핑 테이블 전체:
| 시도 | 오피넷 (AREA_CD) |
법정동 (앞 2자리) |
|---|---|---|
| 서울특별시 | 01 |
11 |
| 부산광역시 | 10 |
26 |
| 대구광역시 | 14 |
27 |
| 인천광역시 | 15 |
28 |
| 광주광역시 | 16 |
29 |
| 대전광역시 | 17 |
30 |
| 울산광역시 | 18 |
31 |
| 세종특별자치시 | 19 |
36 |
| 경기도 | 02 |
41 |
| 강원특별자치도 | 03 |
42 (또는 51) |
| 충청북도 | 04 |
43 |
| 충청남도 | 05 |
44 |
| 전북특별자치도 | 06 |
45 (또는 52) |
| 전라남도 | 07 |
46 |
| 경상북도 | 08 |
47 |
| 경상남도 | 09 |
48 |
| 제주특별자치도 | 11 |
50 |
⚠️ 두 코드 체계 사이에 산술 규칙은 없습니다 — 매핑 테이블만 가능. 오피넷은 단순 일련번호 방식이고 법정동은 광역시(11/2631)·도(36/4150)·세종(36) 별도 체계.
⚠️ 강원특별자치도(2023-06-11)와 전북특별자치도(2024-01-18) 출범으로 법정동 시도코드가51/52로 변경된 케이스도 있습니다.bjd_sido_to_opinet은 양쪽 모두 받아들입니다.
⚠️ 시군구 4자리 코드는 자동 변환 불가능합니다. 필요하면 응답의address_jibun/address_road를 행정안전부 도로명주소 API로 역지오코딩하세요.
from opinet.exceptions import (
OpinetError, # 공통 베이스
OpinetAuthError, # 인증 실패 (Invalid Key, 401, 403)
OpinetRateLimitError, # 호출 한도 초과 (429)
OpinetInvalidParameterError, # 파라미터 오류 (호출 전)
OpinetNoDataError, # 결과 비어있음 (옵션)
OpinetServerError, # 5xx, 응답 파싱 실패, 타입 변환 실패
OpinetNetworkError, # 네트워크 레벨 오류
)
try:
stations = client.search_stations_around(
lon=127.0,
lat=37.5,
radius_m=10000,
)
except OpinetInvalidParameterError as e:
# radius_m > 5000 → 호출 전에 검증 실패
print(f"파라미터 오류: {e}")
except OpinetAuthError:
print("인증키를 확인하세요")
except OpinetRateLimitError:
print("일일 호출 한도(1,500회)를 초과했습니다")
except (OpinetServerError, OpinetNetworkError) as e:
print(f"일시적 오류, 재시도 권장: {e}")5xx와 네트워크 오류는 라이브러리 내부에서 exponential backoff로 자동 재시도합니다 (기본 2회). 401/403/429는 즉시 실패시킵니다.
오피넷의 시도 코드는 일반적으로 알려진 행정구역 코드와 다릅니다. 비공식 블로그/MCP 구현체에서 흔히 보이는 매핑(01=서울, 02=경기, 03=인천, 04=강원, 05=부산...)은 틀린 정보입니다.
오피넷 공식 areaCode.do 응답값:
| 코드 | 시도 | 코드 | 시도 |
|---|---|---|---|
01 |
서울 | 10 |
부산 |
02 |
경기 | 11 |
제주 |
03 |
강원 | 14 |
대구 |
04 |
충북 | 15 |
인천 |
05 |
충남 | 16 |
광주 |
06 |
전북 | 17 |
대전 |
07 |
전남 | 18 |
울산 |
08 |
경북 | 19 |
세종 |
09 |
경남 |
(12, 13 결번)
운영 환경에서는 get_area_codes()로 런타임에 코드를 가져와 캐싱하는 것을 권장합니다.
detailById.do의 일부 필드는 이름과 의미가 일치하지 않습니다.
| 값 | 의미 |
|---|---|
N |
주유소 (휘발유/경유) |
Y |
자동차충전소 (LPG) |
C |
주유소/충전소 겸업 |
본 라이브러리는 이를 StationType enum과 station_type 필드로 매핑합니다.
KPETRO_YN=Y는 한국석유관리원의 품질인증프로그램 협약 업체를 뜻합니다. 알뜰주유소와 무관합니다. 알뜰 여부는 상표 코드로 판정하세요:
from opinet.codes import is_alddle
is_alddle(detail.brand) # brand가 RTO/RTE/RTX/NHO이면 True
detail.is_kpetro # KPETRO_YN을 매핑한 별도 boolean (품질인증 여부)이 저장소에서 한 번 확인한 함정은 다음 규칙으로 고정합니다.
- fixture의 숫자/날짜/시간 값은 실제 API처럼 문자열로 둡니다. JSON number로 바꾸면 변환 테스트가 약해집니다.
RESULT.OIL은 list뿐 아니라 단일 dict로 올 수 있으므로 항상 정규화합니다.OIL_PRICE도 단일 dict 또는 list 모두 처리합니다.StationDetail전화번호 필드명은tel입니다.phone으로 새 필드를 만들지 않습니다.OilPrice에는PRODNM이 오지 않으므로product_name을 만들지 않습니다.POLL_DIV_CO를 우선하고, 없을 때만 문서 표기의POLL_DIV_CD를 fallback으로 봅니다.GPOLL_DIV_CO=" "같은 공백은None으로 정규화합니다.- 좌표 범위 테스트는 주소 권역 확인용입니다. 실제 변환값의 소수점 하한을 임의로 좁히지 않습니다.
- HTTP transport는
httpx를 사용하며, sync client와 async client가 같은 파서/모델 계약을 공유합니다.
한국석유공사가 배포한 2025 공공데이터 활용 가이드북 PDF에는 무료 API가 22종으로 명시되어 있지만, 공식 사이트 오픈 API 페이지에는 5종만 등재되어 있습니다.
이는 두 가지 다른 게이트웨이로 추정됩니다:
| 공식 오픈 API | PDF 가이드북 무료 API | |
|---|---|---|
| 메뉴 위치 | 푸터 → 오픈 API | 유가관련정보 → 유가정보 API |
| URL 패턴 | openApiInfo.do |
custApiInfo.do |
| 인증 파라미터 | certkey |
code (추정) |
| 엔드포인트 수 | 5 | 22 |
| 명세 페이지 | 있음 | 없음 |
본 라이브러리는 공식 5종을 OpinetClient에 안정적으로 구현하고, PDF의 추가 17종은 opinet.experimental.OpinetExperimentalClient로 분리해 "검증되지 않음(Unverified)" 경고와 함께 제공합니다.
# 공식 5종 (안정)
from opinet import OpinetClient
# PDF 추가 17종 (실험적)
from opinet.experimental import OpinetExperimentalClient
exp = OpinetExperimentalClient()
weekly = exp.get_weekly_average_price(ProductCode.GASOLINE) # avgWeekPrice.do실험 모듈의 메서드는 실제 호출이 동작하는지 보장하지 않습니다. PR을 통한 검증 보고를 환영합니다.
본 저장소는 Claude Code 등 AI 에이전트로 라이브러리를 자동 생성/유지보수할 수 있도록 SKILL.md를 포함합니다.
# Claude Code 글로벌 skill로 등록
mkdir -p ~/.claude/skills/python-opinet-apithon-builder
cp SKILL.md ~/.claude/skills/python-opinet-apithon-builder/
# 또는 프로젝트 단위 skill
mkdir -p .claude/skills/python-opinet-apithon-builder
cp SKILL.md .claude/skills/python-opinet-apithon-builder/이후 Claude Code 세션에서:
python-opinet-apithon-builder skill을 써서 처음부터 라이브러리 만들어줘. opinet-api.md를 먼저 읽고 시작해.
skill 파일은 다음을 정의합니다:
- 패키지 구조와 모듈 책임
- Python 네이티브 타입 변환 정책
- 시도코드 ↔ 법정동코드 매핑
- 예외 계층과 매핑 규칙
- KATEC 변환 구현 방식
- docstring 작성 원칙
- 테스트 전략과 fixture 처리
- 흔한 함정 (LPG_YN, KPETRO_YN, K015 vs K105 등)
다른 AI 에이전트(Cursor, Aider, Cline 등)에서도 opinet-api.md + SKILL.md를 컨텍스트로 제공하면 동일한 결과를 얻을 수 있습니다.
런타임:
httpx≥ 0.27pydantic≥ 2.0pyproj≥ 3.5
선택 기능:
python-vworld-api≥ 0.1.0 (pip install python-opinet-api[vworld], VWorld로 시군구 법정동코드 매핑을 할 때)
개발:
pytest≥ 7.0respx≥ 0.21 (httpx HTTP mocking)pytest-covmypy(선택)
Python 3.11 이상 (StrEnum, slots=True 사용).
python -m compileall src/opinet tests
python -m pytest
python -m pytest --cov=opinet --cov-fail-under=90
python -m mypy src/opinet기본 테스트는 네트워크를 사용하지 않고 respx로 httpx 응답을 재생합니다. 실제 API 호출 테스트를 추가할 때는 @pytest.mark.live로 분리하고 --run-live와 OPINET_API_KEY를 요구하세요.
라이브 테스트는 .env 또는 환경변수의 OPINET_API_KEY를 읽지만, 키는 .gitignore로 보호되는 로컬 파일에만 둡니다.
키가 공식 open API 게이트웨이에 아직 provision되지 않은 경우 서버가 HTTP 200과 빈 RESULT.OIL을 줄 수 있으며, 이때 live 파싱 smoke는 skip됩니다.
구현 상태와 유지보수 체크리스트는 docs/implementation-status.md에 따로 정리되어 있습니다.
- 문서에서 파일 위치를 적을 때는 프로젝트 루트 기준 상대 경로를 사용합니다. 예:
src/opinet/client.py,tests/fixtures/avg_all_price.json. - 로컬 절대 경로는 저장소 문서에 남기지 않습니다.
- Python 내부 문서(docstring과 유지보수용 주석)는 한글로 작성합니다.
- API 필드명, 엔드포인트, enum 값처럼 원문 자체가 의미 있는 값은 그대로 둡니다.
- 이 Windows/PowerShell 환경에서
rg가 실행 권한 문제로 실패하면 반복 시도하지 말고git ls-files,Get-ChildItem -Recurse -File,Select-String으로 우회합니다. - 한글 문서/소스 파일은
Get-Content -Encoding utf8또는Get-Content -Raw -Encoding utf8로 확인합니다. PowerShell 기본 출력에서 한글이 깨져 보이면 UTF-8로 다시 읽습니다.
.
├── AGENTS.md # 에이전트 작업 지침
├── README.md # 이 파일
├── opinet-api.md # API 명세서 (라이브러리 구현 레퍼런스)
├── SKILL.md # Claude Code용 자동 구현 skill
├── pyproject.toml # 패키지/테스트 설정
├── docs/
│ ├── debug-fixture-workflow.md
│ └── implementation-status.md
├── examples/
│ └── streamlit_debug_app.py
├── src/
│ └── opinet/ # 라이브러리 소스, import 이름은 opinet
│ ├── __init__.py
│ ├── catalog.py # 공식 API 카탈로그
│ ├── client.py # OpinetClient
│ ├── debug.py # DebugRun, fixture 저장/replay 헬퍼
│ ├── _http.py
│ ├── _convert.py # 타입 변환 헬퍼
│ ├── exceptions.py
│ ├── codes.py # enum + 시도매핑
│ ├── models.py
│ └── experimental/ # PDF 22종 중 미검증 17종
└── tests/
├── conftest.py
├── fixtures/ # 실제 API 응답 JSON
└── test_*.py
PDF 가이드북 기준 1,500 calls / 일. 라이브러리는 옵션으로 사용량 카운터(OpinetClient(track_usage=True))를 제공하여 잔여 호출 추정을 도울 수 있습니다.
응답 받은 데이터는 다음과 같은 시점까지 캐싱하면 호출 절감에 좋습니다:
| 데이터 | 갱신 주기 | 권장 캐시 TTL |
|---|---|---|
| 시도/시군구 코드 | 거의 변하지 않음 | 며칠 ~ 영구 |
| 주유소 상세 (위치, 편의시설) | 변경 드뭄 | 며칠 |
| 가격 정보 | 일 1~2회 갱신 | 1시간 ~ 6시간 |
PR과 이슈를 환영합니다. 특히:
opinet.experimental의 미검증 엔드포인트를 실제 키로 호출해 본 결과 (응답 fixture, query 결과)- KATEC 좌표 변환 정확도 개선 데이터 (실제 GPS 측정값과의 비교)
- 시군구 코드 매핑 (예:
0113이 강남구라면, 다른 4자리 코드도 매핑) - 강원·전북 특별자치도 코드 변경 추적 (오피넷
AREA_CD가51/52로 바뀐 시점)
테스트 추가:
pytest # 단위 + 통합
pytest -m live # 실제 API 호출 (OPINET_API_KEY 필요)
pytest --cov=opinet --cov-fail-under=90라이브러리 코드: MIT (또는 사용자 지정).
데이터: 한국석유공사 오피넷 이용 약관 준수.
본 프로젝트는 비공식이며 한국석유공사와 무관합니다.
- 오피넷 메인: https://www.opinet.co.kr
- 오픈 API 안내: https://www.opinet.co.kr/user/custapi/openApiIntro.do
- 오픈 API 목록: https://www.opinet.co.kr/user/custapi/openApiInfo.do
- 인증키 발급: https://www.opinet.co.kr/user/custapi/openApiNew.do
- 데이터 문의: (052) 216-2514, price@knoc.co.kr
- KNOC 공공데이터 가이드북: https://www.knoc.co.kr (공공데이터 메뉴)
- 행정안전부 행정표준코드관리시스템 (법정동): https://www.code.go.kr
| 일자 | 내용 |
|---|---|
| 2026-05-15 (rev10) | 서비스키 공백 제거 및 .env 기본 로드, 공식 API 카탈로그, Streamlit Debug Trace 표시용 DebugRun, fixture replay 문서와 예제 앱 추가. |
| 2026-05-09 (rev9) | Windows/PowerShell 환경에서 rg 실행 권한 실패 시 우회 명령을 사용하고, 한글 파일은 UTF-8 인코딩을 명시해 읽는 규칙 추가. |
| 2026-05-09 (rev8) | 문서의 파일 위치 표기를 프로젝트 기준 상대 경로로 고정하고, Python 내부 문서를 한글로 작성한다는 규칙을 추가. |
| 2026-04-30 (rev3) | 공식 5개 엔드포인트 구현, fixture 기반 네트워크-free 테스트 115개, mypy/coverage 검증, 반복 실수 방지 체크리스트 추가. |
| 2026-04-30 (rev2) | 응답 데이터 Python 네이티브 타입 변환(date/time/float/bool/enum) 명시. 시도코드 ↔ 법정동코드 매핑 추가. |
| 2026-04-30 (rev1) | 초기 명세서 작성. 공식 사이트 기준 5개 API 검증. 시도코드/필드 의미 정정. |
python-opinet-api는 OpiNet 원본 응답을 Python 타입으로 변환한 기존 모델을 유지하면서, 여러 프로젝트에서 공통으로 재사용하기 좋은 normalized 필드도 함께 제공합니다. 이 계층은 OpiNet 자체의 도메인 해석만 담당합니다. DB 저장 방식, ETL cache, 서비스별 enum 정책, raw/serving table 설계는 각 애플리케이션에서 결정하면 됩니다.
ProductCode는 OpiNet provider code를 보존하고, FuelType은 앱에서 안정적으로 쓰기 좋은 문자열 값을 제공합니다.
from opinet import FuelType, ProductCode, fuel_type_to_product_code, product_code_to_fuel_type
product_code_to_fuel_type(ProductCode.GASOLINE) # FuelType.GASOLINE
product_code_to_fuel_type(ProductCode.GASOLINE_PREMIUM) # FuelType.PREMIUM_GASOLINE
product_code_to_fuel_type(ProductCode.DIESEL) # FuelType.DIESEL
product_code_to_fuel_type(ProductCode.LPG) # FuelType.LPG
product_code_to_fuel_type(ProductCode.KEROSENE) # FuelType.KEROSENE
FuelType.GASOLINE.value # "gasoline"
FuelType.PREMIUM_GASOLINE.value # "premium_gasoline"
FuelType.DIESEL.value # "diesel"
FuelType.LPG.value # "lpg"
FuelType.KEROSENE.value # "kerosene"
fuel_type_to_product_code(FuelType.DIESEL) # ProductCode.DIESEL알 수 없는 provider code나 FuelType.UNKNOWN을 역변환하려는 경우에는 OpinetInvalidParameterError가 발생합니다.
lowTop10.do와 aroundAll.do의 Station 응답은 OpiNet row에 PRODCD가 없는 경우가 많습니다. 이때 python-opinet-api는 요청에 사용한 prodcd를 Station.product_code에 채웁니다. 응답 row에 PRODCD가 실제로 있으면 응답 값을 우선합니다.
from opinet import OpinetClient, ProductCode
client = OpinetClient()
stations = client.get_lowest_price_top20(ProductCode.GASOLINE, cnt=10)
station = stations[0]
station.product_code # ProductCode.GASOLINE, 요청 context에서 보존
station.product_name # 응답 PRODNM이 없으면 None
station.provider_product_code # "B027"
station.provider_product_name # 응답 PRODNM 또는 None
station.fuel_type # FuelType.GASOLINE
station.provider_station_id # OpiNet UNI_ID
station.brand_code # OpiNet POLL_DIV_CO/POLL_DIV_CD 원문 code최저가/주변검색 응답 row에 TRADE_DT 또는 TRADE_TM이 실제로 포함되면 Station.trade_date와 Station.trade_time에 각각 datetime.date, datetime.time으로 노출됩니다. 필드가 없으면 None입니다.
station.katec_x, station.katec_y, station.lon, station.lat는 그대로 유지됩니다. 좌표 객체 의존성 없이 tuple helper만 제공합니다.
station.lon, station.lat # WGS84 (lon, lat), degrees
station.katec_x, station.katec_y # KATEC (x, y), meters
station.lon_lat # (lon, lat)
station.katec_xy # (x, y)AreaCode는 OpiNet code level과 BJD 시도 prefix를 명시적으로 제공합니다. 시도는 2자리, 시군구는 4자리입니다. 시군구 4자리 OpiNet code 자체는 법정동코드와 일치하지 않으므로 python-opinet-api는 산술 변환을 추정하지 않습니다.
area = client.get_area_codes("01")[0]
area.code_level # "sigungu"
area.parent_sido_code # "01"
area.bjd_sido_prefix # "11"잘못된 길이의 code나 미확인 OpiNet 시도 code는 OpinetInvalidParameterError로 실패합니다.
VWorld 행정구역 검색을 함께 쓰면 오피넷 시군구 코드를 5자리 법정동 시군구 코드로 명시 매핑할 수 있습니다.
from vworld import VworldClient
from opinet.vworld import resolve_sigungu_bjd_code
vworld = VworldClient.from_env(domain="")
mapping = resolve_sigungu_bjd_code(
"0113",
opinet_client=client,
vworld_client=vworld,
)
mapping.opinet_sigungu_name # "강남구"
mapping.bjd_sigungu_code # "11680"
mapping.bjd_sido_code # "11"
mapping.bjd_sido_name # "서울특별시"
mapping.bjd_sigungu_name # "강남구"
mapping.vworld_title # "서울특별시 강남구"동일한 동작은 client.resolve_sigungu_bjd_code("0113", vworld_client=vworld)로도 호출할 수 있습니다.
AvgPrice, Station, StationDetail, OilPrice, AreaCode는 마지막 dataclass 필드로 raw를 가집니다. raw에는 타입 변환 전 원본 row payload가 보존됩니다. 숫자, 날짜, 시간도 OpiNet 응답처럼 문자열입니다.
avg = client.get_national_average_price()[0]
avg.price # 1919.44, float
avg.trade_date # datetime.date(2025, 7, 23)
avg.raw["PRICE"] # "1919.44", provider 원문 문자열
avg.raw["TRADE_DT"] # "20250723"raw는 읽기 전용 mapping입니다. StationDetail.raw["OIL_PRICE"]처럼 nested OIL_PRICE가 있으면 가능한 한 원형 row를 보존하되, 내부 mapping도 읽기 전용으로 제공합니다. certkey, api_key, authorization 같은 인증 관련 key는 raw에 남기지 않습니다.
아래 예시는 앱별 adapter를 최소화하고, python-opinet-api의 공용 해석 결과를 그대로 저장 계층에 넘기는 형태입니다.
def to_station_record(station):
return {
"provider": "opinet",
"provider_station_id": station.provider_station_id,
"provider_product_code": station.provider_product_code,
"provider_product_name": station.provider_product_name,
"fuel_type": station.fuel_type.value,
"brand_code": station.brand_code,
"price": station.price,
"trade_date": station.trade_date,
"trade_time": station.trade_time,
"katec_x": station.katec_coordinate.x,
"katec_y": station.katec_coordinate.y,
"lon": station.coordinate.lon,
"lat": station.coordinate.lat,
"raw": dict(station.raw),
}raw를 저장할지, 별도 raw table에 둘지, serving table에 normalized 값만 둘지는 애플리케이션 정책으로 결정하세요. python-opinet-api는 OpiNet domain parsing과 canonical helper만 제공합니다.
| 날짜 | 내용 |
|---|---|
| 2026-05-06 (rev4) | 공용 normalized layer 추가. FuelType, ProductCode 양방향 mapping, Station product/trade context, coordinate value object, AreaCode helper, read-only raw payload 보존을 문서화. |
opinet.normalized 모듈은 앱 저장 계층에 바로 넘기기 쉬운 Pydantic DTO record를 제공합니다. 기존 AvgPrice, Station, AreaCode 모델은 그대로 유지되고, 필요할 때 to_normalized()로 변환합니다. DTO는 Pydantic v2 BaseModel 기반이며 frozen=True, extra="forbid" 설정으로 불변 record처럼 동작합니다.
from opinet import OpinetClient, ProductCode
from opinet.normalized import (
NormalizedFuelAverage,
NormalizedFuelStation,
NormalizedFuelStationDetail,
NormalizedFuelStationDetailPrice,
NormalizedFuelRegionCode,
to_json_safe_raw,
)
client = OpinetClient()
avg = client.get_national_average_price()[0]
avg_record = avg.to_normalized(endpoint="avgAllPrice.do")
assert isinstance(avg_record, NormalizedFuelAverage)
assert avg_record.provider == "opinet"
assert avg_record.provider_product_code == "B034"
assert avg_record.fuel_type.value == "premium_gasoline"
assert avg_record.price_datetime().tzinfo is not None # Asia/Seoul midnight by default
assert avg_record.price_timestamp() == avg.price_timestamp()
station = client.get_lowest_price_top20(ProductCode.GASOLINE)[0]
station_record = station.to_normalized(endpoint="lowTop10.do")
assert isinstance(station_record, NormalizedFuelStation)
assert station_record.provider_station_id == station.provider_station_id
assert station_record.provider_product_code == "B027" # request context is preserved
assert station_record.provider_product_name is None # PRODNM is absent in many station rows
assert station_record.trade_datetime() is None # unless TRADE_DT and TRADE_TM both exist
detail = client.get_station_detail("A0010207")
detail_record = detail.to_normalized(endpoint="detailById.do")
assert isinstance(detail_record, NormalizedFuelStationDetail)
assert detail_record.provider_station_id == detail.provider_station_id
assert detail_record.brand_code == "SKE"
assert detail_record.sub_brand_code is None
assert detail_record.station_type.value == "N" # LPG_YN 업종구분
assert detail_record.sigun_code == "0113"
assert detail_record.has_carwash is True
assert detail_record.is_kpetro is False # 품질인증 여부
assert isinstance(detail_record.prices[0], NormalizedFuelStationDetailPrice)
assert detail_record.prices[0].provider_station_id == "A0010207"
assert detail_record.prices[0].provider_product_code == "B027"
assert detail_record.prices[0].fuel_type.value == "gasoline"
area = client.get_area_codes("01")[0]
area_record = area.to_normalized()
assert isinstance(area_record, NormalizedFuelRegionCode)
assert area_record.code_level == "sigungu"
assert area_record.parent_sido_code == "01"
assert area_record.bjd_sido_prefix == "11"
plain_raw = to_json_safe_raw(station.raw) # plain dict/list, safe for json.dumps
payload = station_record.model_dump(mode="json") # Pydantic JSON modeNormalizedFuelAverage.price_datetime()는 평균가처럼 날짜만 있는 record를 KST 자정의 timezone-aware datetime으로 반환합니다. NormalizedFuelStation.trade_datetime()는 trade_date와 trade_time이 모두 있을 때만 KST timezone-aware datetime을 반환하고, 둘 중 하나라도 없으면 None을 반환합니다.
NormalizedFuelStationDetail은 detailById.do의 주유소 단위 정보를 보존합니다. station id/name, brand/sub-brand code, StationType, sigun code, 주소, 전화번호, KATEC/WGS84 좌표, 편의시설 flag, is_kpetro, nested NormalizedFuelStationDetailPrice 가격 목록, JSON-safe raw payload를 제공합니다.
NormalizedFuelRegionCode는 OpiNet 지역 코드의 level, parent sido, BJD sido prefix까지만 제공합니다. OpiNet 시군구 4자리 code를 법정동 10자리 code로 자동 변환하지 않습니다.
python-opinet-api는 PEP 561 typed package입니다. 소스에는 src/opinet/py.typed marker가 있고, 배포 산출물에는 import 패키지 경로인 opinet/py.typed로 포함됩니다. downstream 프로젝트의 mypy가 opinet과 opinet.normalized 타입 정보를 직접 읽을 수 있습니다.
패키징 테스트는 wheel과 sdist를 각각 임시 venv에 설치한 뒤 import opinet, import opinet.normalized, downstream mypy smoke를 확인합니다.
| 날짜 | 내용 |
|---|---|
| 2026-05-06 (rev5) | opinet.normalized Pydantic DTO layer 추가. NormalizedFuelAverage, NormalizedFuelStation, NormalizedFuelRegionCode, KST datetime helper, JSON-safe raw 변환 helper, 모델별 to_normalized() 문서화. |
| 2026-05-06 (rev6) | PEP 561 py.typed marker와 package data 설정 추가. wheel/sdist 설치 후 import와 downstream mypy smoke 테스트 추가. |
| 2026-05-07 (rev7) | StationDetail.to_normalized()와 NormalizedFuelStationDetail, NormalizedFuelStationDetailPrice DTO 추가. |