SKILL.md
Full skill instructions
Delivery Tracking
What this skill does
CJ๋ํํต์ด๊ณผ ์ฐ์ฒด๊ตญ ๊ณต์ ์กฐํ ํ๋ฉด์ ์ฌ์ฉํด ์ก์ฅ ๋ฒํธ๋ก ํ์ฌ ๋ฐฐ์ก ์ํ๋ฅผ ์กฐํํ๋ค.
- CJ๋ํํต์ด: ๊ณต์ ๋ฐฐ์ก์กฐํ ํ์ด์ง๊ฐ ๋ ธ์ถํ๋ JSON endpoint ์ฌ์ฉ
- ์ฐ์ฒด๊ตญ: ๊ณต์ ๋ฐฐ์ก์กฐํ ํ์ด์ง๊ฐ ์ฌ์ฉํ๋ HTML endpoint ์ฌ์ฉ
- ๊ฒฐ๊ณผ๋ ๊ณตํต ํฌ๋งท(ํ๋ฐฐ์ฌ / ์ก์ฅ๋ฒํธ / ํ์ฌ ์ํ / ์ต๊ทผ ์ด๋ฒคํธ๋ค)์ผ๋ก ์งง๊ฒ ์ ๋ฆฌ
When to use
- "CJ๋ํํต์ด ์ก์ฅ ์กฐํํด์ค"
- "์ฐ์ฒด๊ตญ ํ๋ฐฐ ์ง๊ธ ์ด๋์ผ"
- "์ด ์ก์ฅ๋ฒํธ ๋ฐฐ์ก์๋ฃ์ธ์ง ํ์ธํด์ค"
- "ํ๋ฐฐ์ฌ๋ณ ์กฐํ ๋ก์ง์ ๋์ค์ ๋ ๋ถ์ผ ์ ์๊ฒ ์ ๋ฆฌํด์ค"
When not to use
- ์ฃผ๋ฌธ๋ฒํธ๋ง ์๊ณ ์ก์ฅ๋ฒํธ๊ฐ ์๋ ๊ฒฝ์ฐ
- ํ๋ฐฐ ์์ฝ/๋ฐํ ์ ์๊น์ง ๋ฐ๋ก ํด์ผ ํ๋ ๊ฒฝ์ฐ
- ๋น๊ณต์ ํตํฉ ๋ฐฐ์ก์กฐํ ์๋น์ค๋ก ์ฐํํ๊ณ ์ถ์ ๊ฒฝ์ฐ
Prerequisites
- ์ธํฐ๋ท ์ฐ๊ฒฐ
python3curl- ์ ํ ์ฌํญ:
jq
Inputs
- ํ๋ฐฐ์ฌ ์๋ณ์:
cj๋๋epost - ์ก์ฅ๋ฒํธ
- CJ๋ํํต์ด: ์ซ์ 10์๋ฆฌ ๋๋ 12์๋ฆฌ
- ์ฐ์ฒด๊ตญ: ์ซ์ 13์๋ฆฌ
Carrier adapter rule
์ด ์คํฌ์ ํ๋ฐฐ์ฌ๋ณ ๋ก์ง์ carrier adapter ๋จ์๋ก ๋๋๋ค.
์ ํ๋ฐฐ์ฌ๋ฅผ ๋ถ์ผ ๋๋ ์๋ ํ๋๋ฅผ ๋จผ์ ์ ํ๋ค.
carrier id: ์)cj,epostvalidator: ์ก์ฅ๋ฒํธ ์๋ฆฌ์/ํจํดentrypoint: ๊ณต์ ์กฐํ ์ง์ URLtransport: JSON API / HTML form / CLI ์ค ๋ฌด์์ ์ฐ๋์งparser: ์ด๋ค ํ๋๋ ํ ์ด๋ธ์์ ์ํ๋ฅผ ๋ฝ๋์งstatus map: ๊ฐ ํ๋ฐฐ์ฌ์ ์๋ณธ ์ํ ์ฝ๋๋ฅผ ๊ณตํต ์ํ๋ก ์ด๋ป๊ฒ ์ค์ผ์งretry policy: timeout/retry ๊ท์น
ํ์ฌ ์ด๋ํฐ๋ ์๋ ๋์ด๋ค.
| carrier adapter | official entry | transport | validator | parser focus |
|---|---|---|---|---|
cj | https://www.cjlogistics.com/ko/tool/parcel/tracking | page GET + tracking-detail POST JSON | 10์๋ฆฌ ๋๋ 12์๋ฆฌ ์ซ์ | parcelDetailResultMap.resultList |
epost | https://service.epost.go.kr/trace.RetrieveRegiPrclDeliv.postal?sid1= | form POST HTML | 13์๋ฆฌ ์ซ์ | ๊ธฐ๋ณธ์ ๋ณด table_col + ์์ธ processTable |
Workflow
0. Normalize the input first
- ํ๋ฐฐ์ฌ ์ด๋ฆ์
cj/epost๋ ์ค ํ๋๋ก ์ ๊ทํํ๋ค. - ์ก์ฅ๋ฒํธ์์ ๊ณต๋ฐฑ๊ณผ
-๋ฅผ ์ ๊ฑฐํ๋ค. - ์๋ฆฌ์ ๊ฒ์ฆ์ด ๋จผ์ ์คํจํ๋ฉด ์กฐํ๋ฅผ ๋ณด๋ด์ง ์๋๋ค.
1. CJ๋ํํต์ด: official JSON flow
๊ณต์ ์ง์
ํ์ด์ง์์ _csrf ๋ฅผ ์ฝ๊ณ , ๊ทธ ๊ฐ์ tracking-detail POST์ ๊ฐ์ด ๋ณด๋ธ๋ค.
- ์ง์
ํ์ด์ง:
https://www.cjlogistics.com/ko/tool/parcel/tracking - ์์ธ endpoint:
https://www.cjlogistics.com/ko/tool/parcel/tracking-detail - ํ์ ํ๋:
_csrf,paramInvcNo
๊ธฐ๋ณธ ์์๋ curl ๋ก _csrf ์ cookie๋ฅผ ์ ์งํ๊ณ , Python์ JSON ์ ๋ฆฌ์๋ง ์ด๋ค.
tmp_body="$(mktemp)"
tmp_cookie="$(mktemp)"
tmp_json="$(mktemp)"
invoice="1234567890" # ๊ณต์ ํ์ด์ง placeholder ์ฑ๊ฒฉ์ smoke-test ๊ฐ
curl -sS -L -c "$tmp_cookie" \
"https://www.cjlogistics.com/ko/tool/parcel/tracking" \
-o "$tmp_body"
csrf="$(python3 - <<'PY' "$tmp_body"
import re
import sys
text = open(sys.argv[1], encoding="utf-8", errors="ignore").read()
print(re.search(r'name="_csrf" value="([^"]+)"', text).group(1))
PY
)"
curl -sS -L -b "$tmp_cookie" \
-H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \
--data-urlencode "_csrf=$csrf" \
--data-urlencode "paramInvcNo=$invoice" \
"https://www.cjlogistics.com/ko/tool/parcel/tracking-detail" \
-o "$tmp_json"
python3 - <<'PY' "$tmp_json"
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
events = payload["parcelDetailResultMap"]["resultList"]
if not events:
raise SystemExit("์กฐํ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค.")
status_map = {
"11": "์ํ์ธ์",
"21": "์ํ์ด๋์ค",
"41": "์ํ์ด๋์ค",
"42": "๋ฐฐ์ก์ง๋์ฐฉ",
"44": "์ํ์ด๋์ค",
"82": "๋ฐฐ์ก์ถ๋ฐ",
"91": "๋ฐฐ๋ฌ์๋ฃ",
}
latest = events[-1]
normalized_events = [
{
"timestamp": event.get("dTime"),
"location": event.get("regBranNm"),
"status_code": event.get("crgSt"),
"status": status_map.get(event.get("crgSt"), event.get("scanNm") or "์์์์"),
}
for event in events
]
print(json.dumps({
"carrier": "cj",
"invoice": payload["parcelDetailResultMap"]["paramInvcNo"],
"status_code": latest.get("crgSt"),
"status": status_map.get(latest.get("crgSt"), latest.get("scanNm") or "์์์์"),
"timestamp": latest.get("dTime"),
"location": latest.get("regBranNm"),
"event_count": len(events),
"recent_events": normalized_events[-min(3, len(normalized_events)):],
}, ensure_ascii=False, indent=2))
PY
rm -f "$tmp_body" "$tmp_cookie" "$tmp_json"
CJ ๊ณต๊ฐ ์ถ๋ ฅ ์์
์๋ ๊ฐ์ 2026-03-27 ๊ธฐ์ค live smoke test(1234567890)์์ ํ์ธํ ์ ๊ทํ ๊ฒฐ๊ณผ๋ค.
{
"carrier": "cj",
"invoice": "1234567890",
"status_code": "91",
"status": "๋ฐฐ๋ฌ์๋ฃ",
"timestamp": "2026-03-21 12:22:13",
"location": "๊ฒฝ๊ธฐ๊ด์ฃผ์คํฌ",
"event_count": 3,
"recent_events": [
{
"timestamp": "2026-03-10 03:01:45",
"location": "์ฒญ์HUB",
"status_code": "44",
"status": "์ํ์ด๋์ค"
},
{
"timestamp": "2026-03-21 10:53:19",
"location": "๊ฒฝ๊ธฐ๊ด์ฃผ์คํฌ",
"status_code": "82",
"status": "๋ฐฐ์ก์ถ๋ฐ"
},
{
"timestamp": "2026-03-21 12:22:13",
"location": "๊ฒฝ๊ธฐ๊ด์ฃผ์คํฌ",
"status_code": "91",
"status": "๋ฐฐ๋ฌ์๋ฃ"
}
]
}
์ถ๊ฐ smoke test ๋ก๋ 000000000000 ๋ ์ฌ์ฉํ ์ ์๋ค.
CJ ์๋ต์ parcelResultMap.resultList ๊ฐ ๋น์ด ์์ด๋ parcelDetailResultMap.resultList ์ชฝ์ ์ด๋ฒคํธ๊ฐ ๋ค์ด์ฌ ์ ์์ผ๋ฏ๋ก, ์์ธ ์ด๋ฒคํธ ๋ฐฐ์ด์ ์ฐ์ ๋ณธ๋ค. published ์์๋ ๊ณตํต ๊ฒฐ๊ณผ ์คํค๋ง(carrier, invoice, status, timestamp, location, event_count, recent_events, ์ ํ์ status_code)์ ๋ง์ถฐ ๋น์๋ณ ํ๋๋ง ๋จ๊ธฐ๊ณ , ๋ด๋น์ ์ด๋ฆยท์ฐ๋ฝ์ฒ๊ฐ ์์ผ ์ ์๋ crgNm ์๋ฌธ์ ๊ทธ๋๋ก ๋ณด์ฌ์ฃผ์ง ์๋๋ค.
2. ์ฐ์ฒด๊ตญ: official HTML flow
์ฐ์ฒด๊ตญ์ ๊ณต์ entry page๊ฐ ๋ค์ trace.RetrieveDomRigiTraceList.comm ์ผ๋ก sid1 ์ POSTํ๋ ๊ตฌ์กฐ๋ค.
- ์ง์
ํ์ด์ง:
https://service.epost.go.kr/trace.RetrieveRegiPrclDeliv.postal?sid1= - ์ค์ ์กฐํ endpoint:
https://service.epost.go.kr/trace.RetrieveDomRigiTraceList.comm - ํ์ ํ๋:
sid1
์ฐ์ฒด๊ตญ์ ๋ก์ปฌ Python HTTP client๋ณด๋ค curl --http1.1 --tls-max 1.2 ๊ฒฝ๋ก๊ฐ ๋ ์์ ์ ์ด๋ฏ๋ก ๊ทธ ์กฐํฉ์ ๊ธฐ๋ณธ ์์๋ก ์ด๋ค.
tmp_html="$(mktemp)"
python3 - <<'PY' "$tmp_html"
import html
import json
import re
import subprocess
import sys
invoice = "1234567890123" # ๊ณต์ ํ์ด์ง placeholder ์ฑ๊ฒฉ์ smoke-test ๊ฐ
output_path = sys.argv[1]
cmd = [
"curl",
"--http1.1",
"--tls-max",
"1.2",
"--silent",
"--show-error",
"--location",
"--retry",
"3",
"--retry-all-errors",
"--retry-delay",
"1",
"--max-time",
"30",
"-o",
output_path,
"-d",
f"sid1={invoice}",
"https://service.epost.go.kr/trace.RetrieveDomRigiTraceList.comm",
]
subprocess.run(cmd, check=True)
page = open(output_path, encoding="utf-8", errors="ignore").read()
summary = re.search(
r"<th scope=\"row\">(?P<tracking>[^<]+)</th>.*?"
r"<td>(?P<sender>.*?)</td>.*?"
r"<td>(?P<receiver>.*?)</td>.*?"
r"<td>(?P<delivered_to>.*?)</td>.*?"
r"<td>(?P<kind>.*?)</td>.*?"
r"<td>(?P<result>.*?)</td>",
page,
re.S,
)
if not summary:
raise SystemExit("๊ธฐ๋ณธ์ ๋ณด ํ
์ด๋ธ์ ์ฐพ์ง ๋ชปํ์ต๋๋ค.")
def clean(raw: str) -> str:
text = re.sub(r"<[^>]+>", " ", raw)
return " ".join(html.unescape(text).split())
def clean_location(raw: str) -> str:
text = clean(raw)
return re.sub(r"\s*(TEL\s*:?\s*)?\d{2,4}[.\-]\d{3,4}[.\-]\d{4}", "", text).strip()
events = re.findall(
r"<tr>\s*<td>(\d{4}\.\d{2}\.\d{2})</td>\s*"
r"<td>(\d{2}:\d{2})</td>\s*"
r"<td>(.*?)</td>\s*"
r"<td>\s*<span class=\"evtnm\">(.*?)</span>(.*?)</td>\s*</tr>",
page,
re.S,
)
normalized_events = [
{
"timestamp": f"{day} {time_}",
"location": clean_location(location),
"status": clean(status),
}
for day, time_, location, status, _detail in events
]
latest_event = normalized_events[-1] if normalized_events else None
print(json.dumps({
"carrier": "epost",
"invoice": clean(summary.group("tracking")),
"status": clean(summary.group("result")),
"timestamp": latest_event["timestamp"] if latest_event else None,
"location": latest_event["location"] if latest_event else None,
"event_count": len(normalized_events),
"recent_events": normalized_events[-min(3, len(normalized_events)):],
}, ensure_ascii=False, indent=2))
PY
rm -f "$tmp_html"
์ฐ์ฒด๊ตญ ๊ณต๊ฐ ์ถ๋ ฅ ์์
์๋ ๊ฐ์ 2026-03-27 ๊ธฐ์ค live smoke test(1234567890123)์์ ํ์ธํ ์ ๊ทํ ๊ฒฐ๊ณผ๋ค.
{
"carrier": "epost",
"invoice": "1234567890123",
"status": "๋ฐฐ๋ฌ์๋ฃ",
"timestamp": "2025.12.04 15:13",
"location": "์ ์ฃผ์ฐํธ์ง์ค๊ตญ",
"event_count": 2,
"recent_events": [
{
"timestamp": "2025.12.04 15:13",
"location": "์ ์ฃผ์ฐํธ์ง์ค๊ตญ",
"status": "๋ฐฐ๋ฌ์ค๋น"
},
{
"timestamp": "2025.12.04 15:13",
"location": "์ ์ฃผ์ฐํธ์ง์ค๊ตญ",
"status": "๋ฐฐ๋ฌ์๋ฃ"
}
]
}
์ฐ์ฒด๊ตญ ๊ธฐ๋ณธ์ ๋ณด ํ
์ด๋ธ์ ๋ฑ๊ธฐ๋ฒํธ, ๋ณด๋ด๋ ๋ถ/์ ์์ผ์, ๋ฐ๋ ๋ถ, ์๋ น์ธ/๋ฐฐ๋ฌ์ผ์, ์ทจ๊ธ๊ตฌ๋ถ, ๋ฐฐ๋ฌ๊ฒฐ๊ณผ ์์๋ฅผ ์ฌ์ฉํ๊ณ , ์์ธ ์ด๋ฒคํธ๋ processTable ์๋ ๋ ์ง / ์๊ฐ / ๋ฐ์๊ตญ / ์ฒ๋ฆฌํํฉ ํ์ ์ฝ์ผ๋ฉด ๋๋ค. published ์์๋ CJ์ ๊ฐ์ ๊ณตํต ๊ฒฐ๊ณผ ์คํค๋ง(carrier, invoice, status, timestamp, location, event_count, recent_events)์ ๋ง์ถฐ ๋ฐฐ์ก ์ํ์ ํ์ํ ๊ฐ๋ง ๋จ๊ธฐ๊ณ , ์ด๋ฒคํธ location์ ์์ผ ์ ์๋ TEL ๋ฒํธ ์กฐ๊ฐ๋ ์ ๊ฑฐํ ๋ค ์๋ น์ธ/์์ธ ๋ฉ๋ชจ ์๋ฌธ์ ๊ทธ๋๋ก ๋
ธ์ถํ์ง ์๋๋ค.
3. Normalize for humans
์๋ต ์๋ฌธ์ ๊ทธ๋๋ก ๋ถ์ด์ง ๋ง๊ณ ์๋ ๊ณตํต ๊ฒฐ๊ณผ ์คํค๋ง๋ก ์์ฝํ๋ค.
๊ณตํต ๊ฒฐ๊ณผ ์คํค๋ง
carrier: ํ๋ฐฐ์ฌ ์๋ณ์ (cj๋๋epost)invoice: ์ ๊ทํ๋ ์ก์ฅ๋ฒํธstatus: ํ์ฌ ๋ฐฐ์ก ์ํtimestamp: ๋ง์ง๋ง ์ด๋ฒคํธ ์๊ฐlocation: ๋ง์ง๋ง ์ด๋ฒคํธ ์์นevent_count: ์ ์ฒด ์ด๋ฒคํธ ์recent_events: ์ต๊ทผ ์ต๋ 3๊ฐ ์ด๋ฒคํธ ๋ชฉ๋กstatus_code: ํ์ํ ๋๋ง ๋จ๊ธฐ๋ ์๋ณธ ์ํ ์ฝ๋ (ํ์ฌ๋ CJ ์์์์๋ง ์ฌ์ฉ)
4. Retry and fallback policy
- ์๋ฆฌ์ ์ค๋ฅ๋ฉด ๋ฐ๋ก ๋ฉ์ถ๊ณ ์ฌ๋ฐ๋ฅธ ํ์์ ๋ค์ ๋ฐ๋๋ค.
- CJ๋
_csrf์ฌ์ทจ๋ ํ ํ ๋ฒ ๋ ์๋ํ๋ค. - ์ฐ์ฒด๊ตญ์
curl --retry 3 --retry-all-errors --retry-delay 1์ ์ ์งํ๋ค. - ๋ค๋ฅธ ํ๋ฐฐ์ฌ๋ก ์ฐํํ์ง ์๋๋ค.
Done when
- ํ๋ฐฐ์ฌ์ ์ก์ฅ๋ฒํธ๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ์๋ณ๋์ด ์๋ค
- ํ์ฌ ์ํ์ ์ต๊ทผ ์ด๋ฒคํธ๊ฐ ์ ๋ฆฌ๋์ด ์๋ค
- ์ด๋ official surface๋ฅผ ์ผ๋์ง ์ค๋ช ํ ์ ์๋ค
- ๋ค๋ฅธ ํ๋ฐฐ์ฌ ํ์ฅ ์ ์ด๋ค carrier adapter ํ๋๋ฅผ ์ถ๊ฐํด์ผ ํ๋์ง ๋จ์ ์๋ค
Failure modes
- CJ:
_csrf์ถ์ถ ์คํจ ๋๋tracking-detail์๋ต ์คํค๋ง ๋ณ๊ฒฝ - CJ: ์ก์ฅ๋ฒํธ ๊ธธ์ด๊ฐ 10์๋ฆฌ ๋๋ 12์๋ฆฌ๊ฐ ์๋
- ์ฐ์ฒด๊ตญ:
sid1์ด 13์๋ฆฌ๊ฐ ์๋ - ์ฐ์ฒด๊ตญ: HTML ๋งํฌ์ ๋ณ๊ฒฝ์ผ๋ก ํ ์ด๋ธ ์ถ์ถ ๊ท์น์ด ๊นจ์ง
- ์ฐ์ฒด๊ตญ:
curl์์ด ๋ค๋ฅธ client๋ก ๋ถ๋ค๊ฐ timeout/reset ๋ฐ์
Notes
- ์กฐํํ ์คํฌ์ด๋ค.
- ๊ธฐ๋ณธ ํ๋ฉด์ ๊ณต์ carrier endpoint๋ง ์ฌ์ฉํ๋ค.
- ๋ค๋ฅธ ํ๋ฐฐ์ฌ ์ถ๊ฐ๋ ์ carrier adapter 1๊ฐ๋ฅผ ๊ฐ์ ํฌ๋งท์ผ๋ก ๋ถ์ด๋ ๋ฐฉ์์ผ๋ก ํ์ฅํ๋ค.
