import * as fs from 'fs'; import axios from 'axios'; import dotenv from 'dotenv'; import moment from 'moment'; import puppeteer from 'puppeteer'; import TelegramBot from 'node-telegram-bot-api'; import _ from 'lodash'; import * as util from './commonUtil.js'; import * as crypto from './cryptoUtil.js'; dotenv.config(); const COOKIE = process.env.COOKIE; const BOT_TOKEN = process.env.BOT_TOKEN; const BOT_CHATID = process.env.BOT_CHATID; const bot = new TelegramBot(BOT_TOKEN, { polling: true }); const __INFO__ = async (str) => { const prefix = '[INFO]' await bot.sendMessage(BOT_CHATID, `${prefix} ${str}`); console.log(`${prefix} ${str}`); } const __ERROR__ = async (str) => { const prefix = '[ERROR]' await bot.sendMessage(BOT_CHATID, `${prefix} ${str}`); console.error(`${prefix} ${str}`); } class cgvGetter { constructor(cookie) { this.seatAspx = 'https://m.cgv.co.kr/WebApp/Reservation/seat.aspx?'; this.loginAspx = 'https://m.cgv.co.kr/Webapp/Member/Login.aspx?'; } // 에러 발생 시 null 반환 async _post(postUrl, payload, headers) { return axios.post(postUrl, payload, { headers, timeout: 3000 }) .then(response => { return { cookies: response.headers['set-cookie'], data: response.data }; }) .catch(error => { __ERROR__('\t> Post request failed:', error.message); return null; }); } async _get(getUrl, headers) { return axios.get(getUrl, { headers, timeout: 3000 }) .then(response => { return { cookies: response.headers['set-cookie'], data: response.data }; }) .catch(error => { __ERROR__('\t> Get request failed:', error.message); return null; }); } // 가중치 계산식 calculateSeatWeight(seatPosition, centerPosition) { // 중앙점 설정 const centerX = centerPosition.x; const centerY = centerPosition.y.charCodeAt(0) - 'A'.charCodeAt(0) + 1; // 좌석 위치 설정 const seatX = seatPosition.x; const seatY = seatPosition.y.charCodeAt(0) - 'A'.charCodeAt(0) + 1; // 중앙으로부터의 수평/수직 거리 계산 const horizontalDistance = Math.abs(seatX - centerX); const verticalDistance = Math.abs(seatY - centerY); // 비선형 거리 가중치 const distanceWeight = Math.pow(horizontalDistance + verticalDistance, 2); // 앞쪽 행 선호도를 직접 반영 const rowPreference = (centerY > seatY) ? (centerY - seatY) * 0.00001 : 0; // 총 가중치 계산 - 거리 가중치와 앞쪽 행 선호도를 합산 const totalWeight = distanceWeight - rowPreference; return totalWeight; } // calculateSeatWeight(seat, centerSeat) { // const horizontalDistance = Math.abs(seat.x - centerSeat.x); // const verticalDistance = Math.abs(seat.y.charCodeAt(0) - centerSeat.y.charCodeAt(0)); // let positionWeight; // if (horizontalDistance === 0 && verticalDistance === 0) { // positionWeight = 1.0; // } else if (horizontalDistance === 1 && verticalDistance === 0) { // positionWeight = 0.9; // } else if (horizontalDistance === 0 && verticalDistance === 1) { // positionWeight = seat.y === centerSeat.y ? 0.85 : 0.8; // } else { // positionWeight = 1.0 - (horizontalDistance + verticalDistance) / 10.0; // } // const seatPreference = horizontalDistance + verticalDistance - positionWeight; // return seatPreference + 0.1; // } // 인접한 예매 가능 좌석 확인 - 넘겨받은 좌석 수 포함한 값 반환 countAdjacentSeats(seats, seatname) { const seatInfo = seats.find(seat => seat.seatname === seatname); if (!seatInfo) return -1; const startX = Math.min(...seats.map(seat => seat.locxnm)); const endX = Math.max(...seats.map(seat => seat.locxnm)); const row = seatInfo.locynm; const col = seatInfo.locxnm; let leftAdjacentCount = 0; let rightAdjacentCount = 0; // 왼쪽 for (let i = col - 1; i >= startX; i--) { const adjacentSeat = seats.find(seat => seat.locynm === row && seat.locxnm === i); if (adjacentSeat) leftAdjacentCount++; else break; // 연속된 좌석이 아닌 경우 반복문을 종료합니다. } // 오른쪽 for (let i = col + 1; i <= endX; i++) { const adjacentSeat = seats.find(seat => seat.locynm === row && seat.locxnm === i); if (adjacentSeat) rightAdjacentCount++; else break; // 연속된 좌석이 아닌 경우 반복문을 종료합니다. } return leftAdjacentCount + rightAdjacentCount + 1; } async getResultData(mgCD = null, ymd = null, td = null, fmac = null, fsrc = null) { const postUrl = "https://m.cgv.co.kr/WebAPP/Reservation/Common/ajaxTheaterScheduleList.aspx/GetTheaterScheduleList"; const param = { strRequestType: "COMPARE", // 영화별예매 : MOVIE, 극장별예매/상영시간표 : THEATER, 비교예매 : COMPARE strUserID: '', strPlayYMD: ymd ? ymd : '', // 비어있을 시 가장 빠른 날짜 strMovieGroupCd: mgCD ? mgCD : '20035290', // 영화 그룹코드 strTheaterCd: td ? td : '0013', strMovieTypeCd: fmac ? fmac : '04', // 영화 속성 IMAX strScreenTypeCd: '', strRankType: 'MOVIE' }; const headers = { 'User-Agent': 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8,ko-KR;q=0.7,de;q=0.6', 'Content-Type': 'application/json', Origin: 'https://m.cgv.co.kr', Cookie: COOKIE }; const result = await this._post(postUrl, param, headers); return result } async main_dev(mgCD = '20035290', ymd = null, dateAft = moment('2024-03-09', 'YYYY-MM-DD')) { const generalSeatCnt = 2; // const startTime = moment('2024-02-05'); // const endTIme = moment('2024-02-12'); const tmRange = { start: '1100', end: '1800' }; let targetYMD = null; let targetSchedule = null; let attemptCnt = 0; const init = moment(); //================================================== // requset - 날짜 탐색 //================================================== while (1) { // 스케줄 데이터 요청 const { cookies, data: result } = await this.getResultData(mgCD, targetYMD); // 20035290 듄, 20035256 웡카 // 예외 - 요청 성공 확인 if (result === null) throw new Error(`\t> Request failed`); // 예외(실험적) - 응답 데이터 구조 확인 if (!result.hasOwnProperty('d')) throw new Error(`\t> Response lacks the expected "d" property`); const gScheduleList = JSON.parse(result.d); // 예외 - CGV 응답 코드 확인 if (gScheduleList.ResultCode !== "00000" || gScheduleList.ResultMessage !== "성공") throw new Error(`\t> Invalid schedule response`); // 예외 - 영화 스케줄 존재 여부 확인 if (gScheduleList.ResultSchedule.ListPlayYmd.length === 0 && gScheduleList.ResultSchedule.ScheduleList.length === 0) { throw new Error(`\t> Schedule not found`); } // 날짜 추출 - YMD 문자열 moment로 변환 const listPlayYmd = gScheduleList.ResultSchedule.ListPlayYmd.split('|').map(YMD => util.YMDConvert(YMD)); // 예매 가능한 일정만 필터 const filteredDates = util.dayFilter(listPlayYmd); const targetDates = filteredDates.filter(date => date.isSameOrAfter(dateAft)); //================================================== // requset - 날짜 찾았을 때 //================================================== if (targetYMD === null && targetDates.length !== 0) { targetYMD = util.YMDConvert(targetDates[0]); await __INFO__(`조건 부합 날짜 식별됨 - 시도: ${++attemptCnt} / 시간: ${moment().format('MM-DD HH:mm:ss')} / 경과: ${init.fromNow()}`); continue //================================================== // requset - 시간표 찾았을 때 //================================================== } else if (targetYMD !== null && targetSchedule === null) { /* ++++++++++++ 남은 좌석 수 필터링 추가 'SeatRemainCnt' */ // 상영시간표 추출 const scheduleList = gScheduleList.ResultSchedule.ScheduleList; const seatFilteredSchedule = scheduleList.filter(schedule => schedule.SeatRemainCnt >= 550); const tmFilteredSchedule = seatFilteredSchedule.filter(schedule => { const scheduleTm = moment(schedule.PlayStartTm, 'HHmm'); const t_start = moment(tmRange.start, 'HHmm'); const t_end = moment(tmRange.end, 'HHmm'); return scheduleTm.isBetween(t_start, t_end, null, '[]'); }) if (tmFilteredSchedule.length !== 0) { targetSchedule = tmFilteredSchedule[0]; await __INFO__(`조건 부합 스케줄 식별됨 - 시도: ${++attemptCnt} / 시간: ${moment().format('MM-DD HH:mm:ss')} / 경과: ${init.fromNow()}`); break } } console.log(`탐색 중 - 시도: ${++attemptCnt} / 시간: ${moment().format('MM-DD HH:mm:ss')} / 경과: ${init.fromNow()}`); console.log(`\t 조건 - 날짜: ${targetYMD ? targetYMD : '빠른순'}`); await new Promise((resolve) => setTimeout(resolve, 60000)); } await __INFO__(`예매 시작 / 시간: ${moment().format('MM.DD HH:mm:ss')}`); await __INFO__(`\t 조건 - 날짜: ${targetSchedule.PlayYmd} / 시간: ${targetSchedule.PlayStartTm}`); //================================================== // requset - 예매 시작 //================================================== // 추출한 영화 정보 -> 페이로드로 재구성 const tcktDate = targetSchedule; const TicketTypeOrderInfo = [ { "TicketTypeName": "일반", "TicketTypeCode": "01", "TicketTypeCss": "General", "TicketTypeCount": generalSeatCnt }, { "TicketTypeName": "청소년", "TicketTypeCode": "02", "TicketTypeCss": "Student", "TicketTypeCount": 0 } ] const payload = { "hfTheaterCd": tcktDate.TheaterCd, "hfTheaterNm": tcktDate.TheaterNm, "hfCGVCode": tcktDate.MovieCd, "hfPlayYMD": tcktDate.PlayYmd, "hfPlayNum": tcktDate.PlayNum, "hfScreenCd": tcktDate.ScreenCd, "hfScreenNM": tcktDate.ScreenNm, "hfPlayTimeCd": tcktDate.PlayTimeCd, "hfScreenRatingCd": tcktDate.ScreenRatingCd, "hfRating": tcktDate.MovieRatingCd, "hfScreenRatingNm": tcktDate.ScreenRatingNm, "hfStartHHMM": tcktDate.PlayStartTm, "hfEndHHMM": tcktDate.PlayEndTm, "hfKidsScreenType": tcktDate.KidsScreenType, "hfmovieIdx": tcktDate.MovieIdx, "hfmovieName": tcktDate.MovieNmKor, "hfPlayEndTM": tcktDate.PlayEndTm, "hfPlatformNM": tcktDate.PlatformNm, "hfMovieRatingNM": tcktDate.MovieRatingNm, "hfPlayTimeNM": tcktDate.PlayTimeNm, "hfMoviePkgYn": tcktDate.MoviePkgYn, "hfMovieNoShowYn": tcktDate.MovieNoshowYn, "hfPlatformCd": tcktDate.PlatformCd, "hfGeneral_count": generalSeatCnt, "hfSpecialSeat_count": 0, "hfStudent_count": 0, "hfKid_count": 0, "hfGiveSpecial_count": 0, "hfSenior_count": 0, "hfArmy_count": 0, "hfMovieGroupCd": tcktDate.MovieGroupCd, "hfMovieAttrCd": tcktDate.MovieAttrCd, "hfMovieAttrNm": tcktDate.MovieAttrNm, "hfSeatRemainRate": tcktDate.SeatRate, "hfTicketTypeOrderInfo": JSON.stringify(TicketTypeOrderInfo) } // 페이로드 -> 요청용 쿼리 (암호화) const encryptedPayload = crypto.encrypt_hfObject(payload); const seatQuery = new URLSearchParams(encryptedPayload).toString(); const url_seat = this.seatAspx + seatQuery; //================================================== // requset - 로그인 쿠키 //================================================== // 로그인 세션 쿠키 추출 const loginParam = { "hfUserId": crypto.cgv_encrypt_hf(process.env.CGV_ID), "hfPasswordInter": encodeURIComponent(crypto.cgv_sha256_encrypt(process.env.CGV_PW)), "hfPasswordLocal": encodeURIComponent(crypto.cgv_sha256_encrypt(crypto.cgv_md5_encrypt(process.env.CGV_PW))), "hfReUrl": crypto.cgv_encrypt_hf('https%3a%2f%2fm.cgv.co.kr%2f'), "hfAgree": crypto.cgv_encrypt_hf('0'), "nonmemberStateCd": crypto.cgv_encrypt_hf('0') }; const loginHeaders = { 'User-Agent': 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8,ko-KR;q=0.7,de;q=0.6', 'Content-Type': 'application/x-www-form-urlencoded', Origin: 'https://m.cgv.co.kr', Cookie: COOKIE } const { cookies: loginCookies } = await this._post(this.loginAspx, loginParam, loginHeaders); /* ++++++++++++ 예외 추가 or 예외 자체를 요청과정에 합병 */ // cookie list -> cookie string const joinedLoginCookie = util.joinCookie(loginCookies); const parsedLoginCookie = util.parseCookie(joinedLoginCookie) const PuppeteerCookie = Object.entries(parsedLoginCookie).map(([key, val]) => ({ 'name': key, 'value': val })); //================================================== // puppeteer - 기본 설정 //================================================== // const browser = await puppeteer.launch({ headless: false }); const browser = await puppeteer.launch({ headless: 'new' }); const page = await browser.newPage(); await page.setViewport({ width: 1920, height: 1080, }); await page.setRequestInterception(true); page.on('request', (req) => { if (['font'].includes(req.resourceType())) req.abort(); else req.continue(); }); page.on('popup', async popup => { await __INFO__(await popup.title()); // await page.screenshot({path: 'popup.png'}); await popup.close(); }); page.on('dialog', async dialog => { await __INFO__(dialog.message()); // await page.screenshot({path: 'dialog.png'}); await dialog.dismiss(); // 혹은 accept()로 확인 가능 }); //================================================== // puppeteer - seatAspx //================================================== await page.goto('https://m.cgv.co.kr/'); await page.setCookie(...PuppeteerCookie); let failCnt = 0; // 반복 시작: 좌석 추출 -> 좌석 선택 -> 결제 시도 while (1) { try { await page.goto(url_seat); await __INFO__(` ===== 좌석창 들어옴`); // 연령 등급 팝업 제거 - jQeury함수 await page.evaluate(() => { jQuery.fn.closePopup('popAge') }); //================================================== // puppeteer - 좌석 추출 //================================================== // 예매 가능한 일반석 시트 정보 추출 const availableSeats = await page.$$eval('#seat_table > tbody > tr > td[reservation="Yes"].pointer[rating_nm="일반석"]', elements => { return elements.map(element => { const seatname = element.getAttribute('seatname').trim(); const locynm = element.getAttribute('locynm').trim(); const locxnm = Number(element.getAttribute('locxnm').trim()); return { seatname, locynm, locxnm }; }); }); //================================================== // puppeteer - 최적 좌석 탐색 알고리즘 //================================================== // 중심 및 범위 설정 const setCenterSeat = { x: 23, y: 'H' }; const setSeatRange = { startX: 16, endX: 29, startY: 'H', endY: 'K' } // 탐색 범위 제한 const filteredX = availableSeats.filter(seat => seat.locxnm >= setSeatRange.startX && seat.locxnm <= setSeatRange.endX); const filteredY = filteredX.filter(seat => { const seatChar = seat.seatname.charAt(0); return seatChar >= setSeatRange.startY && seatChar <= setSeatRange.endY; }); // 가중치 적용 const seatWeights = filteredY.map(seat => ({ ...seat, weight: this.calculateSeatWeight({ x: seat.locxnm, y: seat.locynm }, setCenterSeat) })); // 설정한 인원수 불충족 좌석 필터링 (= 좌석 연속성 확인) const continuousSeats = seatWeights.filter(seat => this.countAdjacentSeats(seatWeights, seat.seatname) >= generalSeatCnt); // 낮은 가중치순 정렬 const sortedSeats = continuousSeats.sort((a, b) => a.weight - b.weight); if (sortedSeats.length === 0) { await __INFO__(`자리가 없음!!!!`) return -1 } // 좌석 선택 const finalSeat = sortedSeats[0].seatname await page.evaluate((seatname) => { jQuery(`#seat_table > tbody > tr > td[seatname='${seatname}']`).click(); }, finalSeat); // 스마트결제로 이동 // await new Promise((resolve) => setTimeout(resolve, 1000000)); await page.evaluate(() => { submitSeat('O') }); //================================================== // puppeteer - 결제 카드 선택 //================================================== await page.waitForSelector('#ContainerView > article > div > ul > li:nth-child(1)', { visible: true }); await __INFO__(` ===== 카드창 들어옴`); // 첫번째 결제 카드 클릭 await page.evaluate(() => { jQuery('#ContainerView > article > div > ul > li:nth-child(1)').click(); }); // 결제 버튼 클릭 await page.evaluate(() => { jQuery('#next').click(); }); //================================================== // puppeteer - 결제 비번 입력 //================================================== await page.waitForSelector('#ownKeypad', { visible: true }); await page.waitForFunction(() => window.nshc.finishedCallback !== null); await __INFO__(` ===== 비번창 들어옴`); // 간편결제 비밀번호 입력 const nums = process.env.EZPAY_PW.split(''); for (let num of nums) { await page.evaluate((num) => { jQuery(`#ownKeypad > div.kpdWrap.kpd-img.kpdNum.typeB > div.nfilter_keypad_div.kpdGrp.number > [aria-label="${num}"]`).click(); }, num); } await page.evaluate(() => { jQuery(`#ownKeypad > div.kpdWrap.kpd-img.kpdNum.typeB > div.nfilter_keypad_div.kpdGrp.number > #nfilter_enter`).click(); }); break } catch (e) { await __ERROR__(e); failCnt++; if (failCnt >= 10) return -1 else continue } } await new Promise(resolve => setTimeout(resolve, 5000)); await page.screenshot({ path: 'result.png' }); return 1 } } await (async () => { console.time('timer'); const CGV = new cgvGetter(); let mode = null; if (process.argv[2] && process.argv[2] === '0') mode = 0; else if (process.argv[2] && process.argv[2] === '1') mode = 1; else if (process.argv[2] && process.argv[2] === '2') mode = 2; switch (mode) { /*====== infinite loop ======*/ case 0: let cnt = 0; const startTime = moment(); let movieData; let prevMovieData = null; // 이전 movieData를 저장할 변수 추가 while (1) { try { cnt++; const { cookies, data: result } = await CGV.getResultData("20035479", '20240218'); // 20035290 듄, 20035256 웡카 // 예외 - 요청 성공 확인 if (result === null) throw new Error(`\t> Request failed`); // 예외(실험적) - 응답 데이터 구조 확인 if (!result.hasOwnProperty('d') || result.d.length === 0) throw new Error(`\t> Response lacks the expected "d" property`); const gScheduleList = JSON.parse(result.d); // 예외 - CGV 응답 코드 확인 if (gScheduleList.ResultCode !== "00000" || gScheduleList.ResultMessage !== "성공") throw new Error(`\t> Invalid schedule response`); // 예외 - 영화 스케줄 존재 여부 확인 if (gScheduleList.ResultSchedule.ListPlayYmd.length === 0 && gScheduleList.ResultSchedule.ScheduleList.length === 0) { throw new Error(`\t> Schedule not found`) } // 데이터 가공 const listPlayYmd = gScheduleList.ResultSchedule.ListPlayYmd; movieData = listPlayYmd; // 현재 movieData 업데이트 // 이전 movieData와 현재 movieData 비교 if (!_.isEqual(movieData, prevMovieData)) { await __INFO__(`스케줄 변경됨\n\t> 시도: ${cnt} / 현재시간: ${moment().format('MM-DD HH:mm:ss')} / 경과: ${startTime.fromNow()}\n\t${movieData.replace(/\|/g, "\n\t")}`); prevMovieData = movieData; // 현재 movieData를 이전 movieData로 업데이트 } else { await __INFO__(`스케줄 변경 없음\n\t> 시도: ${cnt} / 현재시간: ${moment().format('YYYY-MM-DD HH:mm:ss')} / 경과: ${startTime.fromNow()}`); } } catch (err) { await __ERROR__(`사유: \n${err.message}\n\t> 시도: ${cnt} / 현재시간: ${moment().format('YYYY-MM-DD HH:mm:ss')} / 경과: ${startTime.fromNow()}`); } await new Promise(resolve => setTimeout(resolve, 60000)); } break; /*====== Dev Mode ======*/ case 1: const result = await CGV.main_dev('20035290', null, moment('2024-03-09', 'YYYY-MM-DD')); // '20035290', null, moment('2024-03-09', 'YYYY-MM-DD' //// '20035479', null, moment('2024-02-16', 'YYYY-MM-DD' if (result === -1) await __ERROR__('예매에 실패했습니다!'); else if (result === 1) await __INFO__(`예매에 성공했습니다!`); break /*====== Test Mode ======*/ case 2: // let url = 'https://m.cgv.co.kr/Webapp/Member/Login.aspx'; // let param = { // "hfUserId": "yymyeAbXPYSBovwH6LsywQ%3D%3D", // "hfPasswordInter": "a273558ce0cebd7bb31d76055eb9cf4886d72ccb997fadd908f1ff0e4e4dd089", // "hfPasswordLocal": "cfe9a2ee0e2989179a480c1dcbe8f07a556cc831f869cb069e623aa3f9d9e231", // "hfReUrl": "Nkzul22VO0IswIN0zK0vHDanF3%2By9OXs4cqrIhv8vP0%3D", // "hfAgree": "9LcHk229qY1EvJegzd%2FTQg%3D%3D", // "nonmemberStateCd": "QB1eobbBB2OMOrekypNmww%3D%3D" // }; // let headers = { // 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8,ko-KR;q=0.7,de;q=0.6', // 'Content-Type': 'application/x-www-form-urlencoded', // Origin: 'https://m.cgv.co.kr', // Cookie: COOKIE // } // let { cookies, data } = await CGV._post(url, param, headers); // const joinedCookie = util.joinCookie(cookies); // await __INFO__(util.parseCookie(joinedCookie)) // headers.Cookie = joinedCookie; // ({ cookies, data } = await CGV._get('https://m.cgv.co.kr/WebApp/MyCgvV5/myMain.aspx', headers)); // await __INFO__(util.parseCookie(util.joinCookie(cookies))) // const foo = { // "hfUserId": "yymyeAbXPYSBovwH6LsywQ%3D%3D", // "hfPasswordInter": "자체검열", // "hfPasswordLocal": "자체검열", // "hfReUrl": "Nkzul22VO0IswIN0zK0vHDanF3%2By9OXs4cqrIhv8vP0%3D", // "hfAgree": "9LcHk229qY1EvJegzd%2FTQg%3D%3D", // "nonmemberStateCd": "QB1eobbBB2OMOrekypNmww%3D%3D" // } // await __INFO__(crypto.decrypt_hfObject(foo)) const { cookies, data } = await CGV.getResultData('20035290', '20240228'); await __INFO__(data) } console.timeEnd('timer'); process.exit() })();