일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 |
- 자동화
- linux
- 개발자 도구 우회
- WSL
- 예매
- nginx
- 프록시
- FastAPI
- kotlin
- uvicorn
- AWS
- 자동화 도구
- GPT
- realtime
- Django
- App
- 일렉트론
- 개발자 도구
- 티켓링크
- WebRTC
- EC2
- fiddler
- puppeteer
- 콘서트
- 로그인
- 티켓
- 피들러
- 직링
- selenium
- 퍼피티어
- Today
- Total
개발 삽질 일지
[직링] 멜론 자동 호출 본문
이전 글들에서 퍼피티어와 일렉트론을 이용한 프로그램을 개발하면서, 타겟 서버의 서버 시간을 받아와서 그 시간에 맞춰 함수를 호출하고, 캡챠 이미지를 인식해서 자동으로 입력되어 티켓 예매 시간을 단축시키기 까지 진행했습니다. 이번 시간에는 함수 호출에서 실제 사용하는 API를 호출해보는 시간을 갖도록 하겠습니다.
⚠️ 이 글은 기술적인 호기심과 실험적인 분석을 위한 목적으로 작성되었습니다. 실제 예매 과정에서 이를 악용하거나 무단으로 활용하는 것은 서비스 약관 위반이 될 수 있으며, 법적 책임이 따를 수 있습니다. 또한, 이번 글에서는 코드 구현보다는 개념과 작동 원리에 집중하니 가볍게 읽어주시길 바랍니다.
우선 피들러를 사용해서 실제로 네트워크가 어떻게 나가는지 확인해봅시다.
5번과 6번 호출을 한 이후에 넷퍼넬과 연결되는 것을 확인할 수 있습니다. 그렇다면 5번과 6번 호출이 필요한지, 그리고 필요하다면 어떤 값들을 받아오면 되는지 분석해봅시다.
먼저 5번 호출 api/v1/authorization 응답값입니다. memberKey 값을 주기는 하지만, 우리는 일렉트론을 사용한 자동화 도구를 사용할 거고, 거기서 쿠키를 불러올 수 있으니 굳이 호출하지 않아도 괜찮을 것 같습니다. 바로 6번으로 넘어갑시다.
{"result":{"code":"1","message":""},"staticDomain":null,"httpsDomain":null,"melonMemberAuthRequestDto":{"memberKey":******,"pocId":null,"viewType":null,"condId":null,"fromType":null,"validation":null,"ticketViewType":"minors","ticketParameterViewType":"charge"},"responseData":{"authUrl":"","message":null,"viewType":"charge","failCodes":null,"appTitle":"본인확인","auth":true,"popupOpen":false},"httpDomain":null}
6번 api/product/prodKey 응답값입니다. nflActId와 key가 인상적입니다. 넷퍼넬 연결 이후에 이 값들을 사용하는지 확인하면 될 것 같습니다. xhCWhA 값과 PT/Ovvo를 기억해두고 나중에 필요하면 돌아오도록 합시다.
{"code":"0000","nflActId":"xhCWhA*****SQFnNCJ","staticDomain":null,"httpsDomain":null,"trafficCtrlYn":"Y","key":"PT/OVvoBHrUYF******2/QcPQp7PgfD+31who=","httpDomain":null}
8번부터는 넷퍼넬의 연속입니다. 간단한 넷퍼넬에 대한 설명입니다. 넷퍼넬은 대규모 트래픽이 몰리는 상황에서 서버 보호를 위한 트래픽 제어 솔루션입니다. 응답 코드 중 우리가 눈여겨봐야 하는 부분은 5101, 5002:201, 5002:200으로 보입니다.
우선 5101입니다. 대기열 진입 시점으로 보입니다.
API를 호출할 때 필요한 값들은 user_data, ttl, sid, prefix, opcode, nfid, js, aid 그리고 숫자입니다. 이 중에서 우리는 user_data(쿠키에서 얻을 수 있는 값)와 aid(6번 호출 시 응답값)을 알 수 있습니다. 나머지 값들은 다 고정 값으로 보이지만 마지막 숫자는 생긴게 시간인거 같습니다. 코드를 아래와 같이 짜줍시다.
const getNFkey = await page.evaluate(async (target_prodId, targetRound) => {
const response = await fetch(
`https://tktapi.melon.com/api/product/prodKey.json?prodId=${target_prodId}&scheduleNo=${targetRound}&v=1&requestservicetype=P`,
{
method: "GET",
credentials: "include",
headers: {
Accept: "application/json",
},
}
);
const data = response.json();
return data;
}, target_prodId, targetRound);
if (getNFkey.nflActId) {
const date = Date.now();
const NFScript = await page.evaluate(
async (key, aid, date) => {
const response = await fetch(
`https://zam.melon.com/ts.wseq?opcode=5101&nfid=0&prefix=NetFunnel.gRtype=5101;&sid=service_1&aid=${key}&js=yes&user_data=${aid}&${date}`,
{ method: "GET" }
);
const data = response.text();
return data;
},
getNFkey.nflActId,
cookieObj["keyCookie"],
date
);
}
퍼피티어 기준으로 작성된 코드입니다. 우선 6번 호출을 통해서 nflActId가 있는 경우에만 호출을 진행합니다. 아마 넷퍼넬 액션 Id? 정도로 생각됩니다. 코드를 이렇게 작성하게 되면 대기열이 없는 콘서트/예매의 경우에는 호출이 되지 않겠지만, 대기열이 없으면 그냥 직접 클릭합시다.
피들러 상에서 5101로 보낸 요청에 대한 응답입니다. 5002:201:key= 부분과 nwait, nnext, tps가 쓸만해보입니다. 바로 다음 호출로 넘어가봅시다.
NetFunnel.gRtype=5101;NetFunnel.gControl.result='5002:201:key=5234CCE2BD0F694DE09572B3952247B7F2A3D5BCFA8A11F2BE7175624CE182A07AA1634D7ED21BB380034DDDF40482CD4608820F03C134B42B150E9D63771CB281D3562C0520B26BBEAF69647B3BBCFFA54C30C4D3C9936B8C56E4B1BF3C91694809C295DA70C848E9F882E594AC748E434A2C302C352C312C363034362C30&nwait=6046&nnext=1&tps=1.399680&ttl=1&ip=zam.melon.com&port=443'; NetFunnel.gControl._showResult();
5002 호출 시 필요한 값들입니다. key는 방금 전에 응답받은 부분을 예쁘게 파싱해서(잘라서) 사용하면 될 것 같습니다. 시간은 바뀐걸 보니 계속해서 현재 시간을 넣으면 되는걸로 보입니다.
코드입니다.
while (!isPass) {
const NetFunnel = await page.evaluate(
async (netfunnel_pass_key, key, aid, date) => {
const response = await fetch(
`https://zam.melon.com/ts.wseq?opcode=5002&key=${netfunnel_pass_key}&nfid=0&prefix=NetFunnel.gRtype=5002;&ttl=1&sid=service_1&aid=${key}&user_data=${aid}&js=yes&${date}`,
{ method: "GET" },
);
const data = response.text();
return data;
},
netfunnel_pass_key,
getNFkey.nflActId,
cookieObj["keyCookie"],
Date.now(),
);
/// ... ///
if (subcode === "200") {
isPass = true;
mainKey = NetFunnel;
keyMatch = mainKey.match(/key=([^&]+)/);
break;
}
await new Promise((res) => setTimeout(res, 500));
}
지금은 5002:201인 상태에서 계속 도는 코드입니다. 5101에서 받은 응답들을 예쁘게 파싱해서 넘겨준 후 While문 안에 들어가면 코드가 반복됩니다. While 문은 조건이 참인 동안 계속해서 반복합니다. 즉 !isPass, 통과하지 못했을 때 계속해서 5002:201을 호출하는 코드라고 생각하시면 됩니다. 그렇다면 넷퍼넬의 마지막 관문 5002:200일때를 확인해봅시다.
참고로 아까 받은 nwait와 nnext, tps는 각각 대기번호, 뒤에 기다리는 사람들, transaction/sec 인거 같습니다. 어차피 화면에는 서버 시간과 대기열 진입 이후 대기순번만 알려주면 되니까 tps는 버리도록 합시다.
5002:200를 받고, 호출하는 API입니다. PT....? 아까 처음에 기억해둔 PT를 인코딩해서 값을 넣은 것으로 보입니다. key는 마지막에 받은 넷퍼넬 키를 이번에는 :key 부분까지 파싱해서 보내주네요
await page.evaluate(
async (target_prodId, chk, netFunnelKey) => {
const form = document.createElement("form");
form.method = "POST";
form.action = "https://ticket.melon.com/reservation/popup/onestop.htm";
form.target = "_blank";
/// ... ///
form.submit();
},
target_prodId,
encoded_key,
netFunnelKey,
);
대게 이런 사이트는 POST요청을 직접 API 호출이 아니라 폼(form)을 submit하는 방식입니다. 맞춰줍시다. 값이 제대로 넘어오면서 팝업이 아닌 새 창에서 열리는 구조로 짰습니다. 저번에 OCR을 진행하면서 굳이 자동화 도구를 쓸거면 새창으로 열리게하고 OCR까지 처리가 가능해서 이 구조로 짠 것도 있습니다. 이제 일렉트론 빌드를 하면서 사용자 편의를 조금이라도 신경써줍시다.
<body>
<div class="form-group">
<label for="concertId">콘서트 ID</label>
<input type="text" id="concertId" placeholder="예: 123456" />
</div>
<div class="form-group">
<label for="round">회차 선택</label>
<select id="round" name="round">
<option value="">회차를 선택하세요</option>
<option value="1">1회차</option>
<option value="2">2회차</option>
<option value="3">3회차</option>
<option value="4">4회차</option>
<option value="5">5회차</option>
<option value="6">6회차</option>
</select>
</div>
<div class="form-group">
<label for="concertTime">예매 시작 시간</label>
<input type="time" id="concertTime" step="1" />
</div>
<button id="run">예매 시작하기</button>
<script>
document.getElementById("run").addEventListener("click", () => {
const concertId = document.getElementById("concertId").value;
const now = new Date();
const koreaTime = new Date(now.getTime() + 9 * 60 * 60 * 1000);
const year = koreaTime.getUTCFullYear();
const month = String(koreaTime.getUTCMonth() + 1).padStart(2, "0");
const day = String(koreaTime.getUTCDate()).padStart(2, "0");
const concertDate = `${year}-${month}-${day}`;
const concertTime =
document.getElementById("concertTime").value || "20:00:00";
const round = document.getElementById("round").value || "1";
window.electronAPI.runPuppeteer({
concertId,
concertDate,
concertTime,
round,
});
});
</script>
</body>
뭐 거창한건 아니지만 나름대로 구색을 갖춘 모습입니다. 콘서트 ID값을 입력하고, 회차, 시간을 입력하고 실행하는 구조입니다. 회차를 설정하지 않으면 1회차, 시간을 설정하지 않으면 당일 오후 8시(20시)로 설정해뒀습니다. 콘서트ID같은 경우에는 무조건 미리 알아와야 하지만 대부분 URL에 노출되어 있기 때문에 이정도는 먼저 알아오도록 합시다.
마지막으로 구조입니다.
1. 콘서트 ID, 회차 등등의 값들을 변수로 담아 넘겨줍니다. 사용자는 예매하기 버튼을 클릭하면 자동으로 사이트가 열립니다.
2. 사이트에서 로그인을 하면 로그아웃 버튼이 생기는데, 이를 감지해서 바로 콘서트 예매 페이지로 이동해줍니다.
3. 서버 시간(이 부분은 오차가 많을거 같기는 한데 어차피 환경에 따른 변수가 너무 많아서 1초 간격으로 시간 설정해두면 될거 같아서 넘어갔습니다.)이 설정 시간보다 같거나 지나면 자동으로 함수를 호출합니다.
4. 오늘 위에서 공부해본 API들을 통해 대기열에 입장하게 되고, 시간이 지나면 티켓 예매 페이지가 새 창으로 열립니다.
5. 저번에 개발한 OCR을 통해 보안 문자가 알아서 넘어가게 되고, 틀린 경우에는 500ms 이후 자동으로 문자열을 새로고침 후, 입력해서 통과합니다. (한 3번 틀려도 사람보다 빠른거 같습니다.)
즉, 처음에 사용자가 입력과 실행, 그리고 로그인만 하면 파파팍 하고 좌석 선택까지 넘어가게 됩니다. 더 추가해서 개발을 한다면 좌석 선택까지 몇연석으로 설정해두고 구매 확정 페이지까지 이동하게 할 수 있을 거 같기는 하지만 그쯤되면 더 이상 실험적인 분석보다는 암표상들이 양성될 것 같아서 멜론은 여기서 마무리하도록 하겠습니다.
다시 한번, 이 글은 기술적인 호기심과 실험적인 분석을 위한 목적으로 작성되었습니다. 실제 예매 과정에서 이를 악용하거나 무단으로 활용하는 것은 서비스 약관 위반이 될 수 있으며, 법적 책임이 따를 수 있습니다.
언제나처럼 — 시작은 삽질이지만, 끝은 지식입니다.
'자유로운 개발일지 > 실험일지' 카테고리의 다른 글
[직링] 기능 개발 (4) | 2025.07.03 |
---|---|
[직링 이해하기] HTTP 요청 방식 (10) | 2025.07.02 |
[직링] 캡챠 이미지 인식 (11) | 2025.06.29 |
[직링] 서버 시간 불러오기 (1) | 2025.06.25 |
[직링 분석] 프록시 기반 분석 2.5탄 (0) | 2025.06.20 |