├── index.ts ├── .idea ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── vcs.xml ├── .gitignore ├── inspectionProfiles │ └── Project_Default.xml ├── modules.xml └── interpark-ticket-watcher.iml ├── test ├── utils.test.ts ├── Accessor.test.ts ├── SeatMapParser.test.ts ├── Catcher.test.ts └── Detector.test.ts ├── lib ├── actor │ ├── Fetcher.ts │ ├── Repository.ts │ ├── SeatMapParser.ts │ ├── Detector.ts │ ├── Runner.ts │ ├── Catcher.ts │ ├── Notifier.ts │ ├── Worker.ts │ └── Accessor.ts ├── common │ ├── axios.ts │ └── utils.ts ├── model │ └── Seat.ts └── Config.ts ├── README.md ├── package.json ├── .gitignore └── tsconfig.json /index.ts: -------------------------------------------------------------------------------- 1 | import Runner from './lib/actor/Runner'; 2 | 3 | new Runner().run().catch((e) => console.error(e)); 4 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import {interceptParameter} from '../lib/common/utils'; 2 | 3 | describe('유틸리티 잘 작동하나?', () => { 4 | it('함수 호출 스트링 리터럴에서 인자 빼오기', async () => { 5 | const result = interceptParameter('hello', 'hello(36);'); 6 | 7 | expect(result).toBe(36); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /lib/actor/Fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | export default class Fetcher { 4 | constructor(private readonly seatViewUrl: string) { 5 | } 6 | 7 | async fetchSeatMapHtml(): Promise { 8 | const response = await fetch(this.seatViewUrl); 9 | 10 | return await response.text(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /lib/common/axios.ts: -------------------------------------------------------------------------------- 1 | import {CookieJar} from 'tough-cookie'; 2 | import axios from 'axios'; 3 | import {wrapper} from 'axios-cookiejar-support'; 4 | 5 | export function newCookieJar() { 6 | return new CookieJar(); 7 | } 8 | 9 | export function newAxiosInstance(jar: CookieJar) { 10 | return wrapper(axios.create({validateStatus: null, jar})); 11 | } 12 | -------------------------------------------------------------------------------- /lib/actor/Repository.ts: -------------------------------------------------------------------------------- 1 | import Seat from '../model/Seat'; 2 | import Accessor from './Accessor'; 3 | import SeatMapParser from './SeatMapParser'; 4 | 5 | export default class Repository { 6 | constructor( 7 | private readonly accessor: Accessor 8 | ) { 9 | } 10 | 11 | async getAvailableSeats(): Promise { 12 | const fetched = await this.accessor.getSeatMapDetail(); 13 | 14 | return new SeatMapParser(fetched).availableSeats(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/Accessor.test.ts: -------------------------------------------------------------------------------- 1 | import Config from '../lib/Config'; 2 | import Accessor from '../lib/actor/Accessor'; 3 | 4 | describe('로그인하기', () => { 5 | it('되나?', async () => { 6 | const config = Config.of({ 7 | goodsCode: '24005722', 8 | placeCode: '20000611', 9 | playSeq: '003', 10 | 11 | username: 'qudwns1031', 12 | password: '', 13 | 14 | captureRegex: '.*' // 다 15 | }); 16 | 17 | const accessor = new Accessor(config); 18 | 19 | await accessor.login(); 20 | }); 21 | }); -------------------------------------------------------------------------------- /.idea/interpark-ticket-watcher.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/SeatMapParser.test.ts: -------------------------------------------------------------------------------- 1 | import Config from '../lib/Config'; 2 | import Accessor from '../lib/actor/Accessor'; 3 | import SeatMapParser from '../lib/actor/SeatMapParser'; 4 | 5 | describe('좌석 맵 해석', () => { 6 | it('예약 가능 좌석 가져오기', async () => { 7 | const accessor = new Accessor(Config.of({ 8 | goodsCode: '22003760', 9 | placeCode: '20000611', 10 | playSeq: '003' 11 | })); 12 | 13 | const html = await accessor.getSeatMapDetail(); 14 | 15 | console.log(new SeatMapParser(html).availableSeats()); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /lib/actor/SeatMapParser.ts: -------------------------------------------------------------------------------- 1 | import Seat from '../model/Seat'; 2 | import cheerio from 'cheerio'; 3 | 4 | export default class SeatMapParser { 5 | constructor(private readonly seatMapHtml: string) { 6 | } 7 | 8 | availableSeats(): Seat[] { 9 | const $ = cheerio.load(this.seatMapHtml); 10 | 11 | return $('#TmgsTable') 12 | .find('tr > td > img.stySeat') 13 | .map((i, el) => el.attribs['onclick']) 14 | .toArray() 15 | .map((onclick) => onclick.replace('javascript: ', '')) 16 | .map((statement) => Seat.fromSelectSeatCallStatement(statement)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/common/utils.ts: -------------------------------------------------------------------------------- 1 | export function interceptParameter(functionName: string, callString: string): any { 2 | const holder = {param: null}; 3 | 4 | eval(`function ${functionName}(param) { 5 | holder.param = param; 6 | } 7 | 8 | ${callString}`); 9 | 10 | return holder.param; 11 | } 12 | 13 | export function interceptParameters(functionName: string, callString: string): any[] { 14 | const args: any[] = []; 15 | 16 | eval(`function ${functionName}(param) { 17 | args.push(...arguments); 18 | } 19 | 20 | ${callString}`); 21 | 22 | return args; 23 | } 24 | 25 | export function sleep(ms: number) { 26 | return new Promise(resolve => setTimeout(resolve, ms)); 27 | } 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # interpark-ticket-watcher 2 | 3 | 인터파크 취소표 watcher 4 | 5 | ## 사용법 6 | 7 | ```bash 8 | $ npm install 9 | ``` 10 | 11 | ```bash 12 | $ npm start -- \ 13 | --goods-code [상품코드] 14 | --place-code [공연장 코드] 15 | --play-seq [회차번호] 16 | --username [인터파크 ID] 17 | --password [인터파크 비밀번호] 18 | --slack-webhook-url [슬랙 웹 훅 URL] 19 | --poll-interval-millis [폴링 간격] 20 | --capture-regex [잡을 좌석 정규식] 21 | ``` 22 | 23 | 예시: 24 | 25 | ```bash 26 | $ npm start -- \ 27 | --goods-code 22003760 28 | --place-code 20000611 29 | --play-seq 001 30 | --username myId 31 | --password myPassword@907 32 | --slack-webhook-url https://hooks.slack.com/services/1/2/3 33 | --poll-interval-millis 200 34 | --capture-regex ^[ABC](7|8|9|10|11|12|13)$ 35 | ``` 36 | -------------------------------------------------------------------------------- /lib/actor/Detector.ts: -------------------------------------------------------------------------------- 1 | import Seat from '../model/Seat'; 2 | 3 | export default class Detector { 4 | constructor( 5 | private readonly seatsBefore: Seat[], 6 | private readonly seatsAfter: Seat[] 7 | ) { 8 | } 9 | 10 | get hasNoChanges(): Boolean { 11 | return this.activatedSeats().length === 0 && this.deactivatedSeats().length === 0; 12 | } 13 | 14 | get hasChanges(): Boolean { 15 | return !this.hasNoChanges; 16 | } 17 | 18 | activatedSeats(): Seat[] { 19 | return this 20 | .seatsAfter 21 | .filter(s => this.seatsBefore.find(ss => ss.id === s.id) == null); 22 | } 23 | 24 | deactivatedSeats(): Seat[] { 25 | return this 26 | .seatsBefore 27 | .filter(s => this.seatsAfter.find(ss => ss.id === s.id) == null); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/Catcher.test.ts: -------------------------------------------------------------------------------- 1 | import Config from '../lib/Config'; 2 | import Catcher from '../lib/actor/Catcher'; 3 | import Accessor from '../lib/actor/Accessor'; 4 | import Repository from '../lib/actor/Repository'; 5 | 6 | describe('자리잡기!', () => { 7 | it('해보자!', async () => { 8 | const config = Config.of({ 9 | goodsCode: '22003760', 10 | placeCode: '20000611', 11 | playSeq: '003', 12 | 13 | username: 'qudwns1031', 14 | password: 'ㅎ', 15 | 16 | captureRegex: '.*' // 다 17 | }); 18 | 19 | const accessor = new Accessor(config); 20 | const repository = new Repository(accessor); 21 | const catcher = new Catcher(config, accessor); 22 | 23 | const seats = await repository.getAvailableSeats(); 24 | 25 | const results = await catcher.catchIfDesired(seats.slice(0, 1)); 26 | 27 | console.log(results); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /lib/actor/Runner.ts: -------------------------------------------------------------------------------- 1 | import Worker from './Worker'; 2 | import Config from '../Config'; 3 | import {sleep} from '../common/utils'; 4 | import Notifier from './Notifier'; 5 | import Accessor from './Accessor'; 6 | import Repository from './Repository'; 7 | import Catcher from './Catcher'; 8 | 9 | export default class Runner { 10 | async run() { 11 | Config.parseCommandLineArguments(); 12 | 13 | console.log(Config.current); 14 | console.log('시작'); 15 | 16 | const accessor = new Accessor(Config.current); 17 | const repo = new Repository(accessor); 18 | 19 | const catcher = new Catcher(Config.current, accessor); 20 | const notifier = new Notifier(Config.current); 21 | 22 | const worker = new Worker(repo, catcher, notifier); 23 | 24 | while (true) { 25 | await worker.tick(); 26 | 27 | await sleep(Config.current.pollIntervalMillis); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/model/Seat.ts: -------------------------------------------------------------------------------- 1 | import {interceptParameters} from '../common/utils'; 2 | 3 | export default class Seat { 4 | constructor( 5 | readonly id: string, 6 | readonly row: string, 7 | readonly column: string, 8 | readonly available: Boolean // 이 속성은 이 프로젝트에서는 안 써요. 9 | ) { 10 | } 11 | 12 | static fromSelectSeatCallStatement(selectSeatCallStatement: string): Seat { 13 | const params = interceptParameters('SelectSeat', selectSeatCallStatement); 14 | 15 | const row = params[3]; 16 | const column = params[4]; 17 | 18 | return new Seat(`${row}${column}`, row, column, true); 19 | } 20 | 21 | get valid(): Boolean { 22 | return this.row !== "" && this.column !== "-1" 23 | } 24 | 25 | toString(): string { 26 | return `${this.row}열 ${this.column}번`; 27 | } 28 | 29 | toNormalizedString(): string { 30 | return `${this.row}${this.column}`; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/Detector.test.ts: -------------------------------------------------------------------------------- 1 | import Seat from '../lib/model/Seat'; 2 | import Detector from '../lib/actor/Detector'; 3 | 4 | describe('Detector 감지!', () => { 5 | it('좌석 하나가 생길 때', async () => { 6 | const seatsBefore = [ 7 | new Seat("s1", "A", "1", true), 8 | ]; 9 | 10 | const seatsAfter = [ 11 | new Seat("s1", "A", "1", true), 12 | new Seat("s2", "A", "2", true), 13 | ]; 14 | 15 | const detector = new Detector(seatsBefore, seatsAfter); 16 | 17 | expect(detector.activatedSeats()[0].id).toBe('s2'); 18 | }); 19 | 20 | it('좌석 하나가 사라질 때', async () => { 21 | const seatsBefore = [ 22 | new Seat("s1", "A", "1", true), 23 | new Seat("s2", "A", "2", true), 24 | ]; 25 | 26 | const seatsAfter = [ 27 | new Seat("s1", "A", "1", true), 28 | ]; 29 | 30 | const detector = new Detector(seatsBefore, seatsAfter); 31 | 32 | expect(detector.hasChanges).toBeTruthy(); 33 | expect(detector.deactivatedSeats()[0].id).toBe('s2'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /lib/actor/Catcher.ts: -------------------------------------------------------------------------------- 1 | import Seat from '../model/Seat'; 2 | import Config from '../Config'; 3 | import Accessor, {AfterReserveScripts} from './Accessor'; 4 | 5 | export type CatchResult = { 6 | seat: Seat; 7 | scripts: AfterReserveScripts; 8 | }; 9 | 10 | /** 11 | * 자리를 잡아야 하는지 판단 및 실제로 자리를 잡아주는 친구입니다. 12 | */ 13 | export default class Catcher { 14 | constructor( 15 | private readonly config: Config, 16 | private readonly accessor: Accessor, 17 | ) { 18 | } 19 | 20 | async catchIfDesired(seats: Seat[]): Promise { 21 | const {captureRegex} = this.config; 22 | if (captureRegex == null) { 23 | return []; 24 | } 25 | 26 | const regex = new RegExp(captureRegex); 27 | 28 | const results: CatchResult[] = []; 29 | 30 | for (const seat of seats) { 31 | if (regex.test(seat.toNormalizedString())) { 32 | const scripts = await this.accessor.reserveSeat(seat); 33 | 34 | results.push({ 35 | seat, 36 | scripts 37 | }); 38 | } 39 | } 40 | 41 | return results; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/actor/Notifier.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import Seat from '../model/Seat'; 3 | import Config from '../Config'; 4 | import {CatchResult} from './Catcher'; 5 | 6 | type NotifyParams = { 7 | activatedSeats: Seat[], 8 | deactivatedSeats: Seat[] 9 | }; 10 | 11 | export default class Notifier { 12 | constructor( 13 | private readonly config: Config 14 | ) { 15 | } 16 | 17 | async notifyText(message: string) { 18 | await this.postToSlack(message); 19 | } 20 | 21 | async notifySeatChanges({activatedSeats, deactivatedSeats}: NotifyParams) { 22 | const added = activatedSeats.length > 0 ? `${activatedSeats.map(s => s.toString()).join(', ')} 생김\n` : ''; 23 | const gone = deactivatedSeats.length > 0 ? `${deactivatedSeats.map(s => s.toString()).join(', ')} 사라짐\n` : ''; 24 | const link = ``; 25 | 26 | await this.postToSlack(`${added}${gone}${link}`); 27 | } 28 | 29 | async notifySeatCatchResults(results: CatchResult[]) { 30 | const text = results 31 | .map((result) => `${result.seat.toString()} 예약하였습니다.\n예매를 계속 진행하려면 브라우저 콘솔에 다음을 붙여넣어 주세요:\n\`\`\`${result.scripts.nextStepScript}\`\`\``) 32 | .join('\n'); 33 | 34 | await this.postToSlack(text); 35 | } 36 | 37 | private async postToSlack(text: string) { 38 | await fetch(this.config.slackWebhookUrl, { 39 | method: 'POST', 40 | headers: {'content-type': 'application/json'}, 41 | body: JSON.stringify({text}), 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/Config.ts: -------------------------------------------------------------------------------- 1 | import {program} from 'commander'; 2 | 3 | const options = program 4 | .requiredOption('--goods-code ', '공연 식별자') 5 | .requiredOption('--place-code ', '장소 식별자') 6 | .requiredOption('--play-seq ', '공연 회차') 7 | .requiredOption('--username ', '로그인 ID') 8 | .requiredOption('--password ', '비밀번호') 9 | .requiredOption('--slack-webhook-url ', '슬랙으로 메시지를 보낼 웹 훅 URL') 10 | .option('--poll-interval-millis ', '폴링 간격(밀리초)', '500') 11 | .option('--capture-regex ', '예약할 좌석 정규표현식') 12 | 13 | export default class Config { 14 | static current: Config; 15 | 16 | readonly goodsCode: string; 17 | readonly placeCode: string; 18 | readonly playSeq: string; 19 | 20 | readonly username: string; 21 | readonly password: string; 22 | 23 | readonly slackWebhookUrl: string; 24 | readonly pollIntervalMillis: number; 25 | 26 | readonly captureRegex?: string; 27 | 28 | static parseCommandLineArguments() { 29 | this.current = Config.fromCommandLineArguments(); 30 | } 31 | 32 | private static fromCommandLineArguments() { 33 | const opts = options.parse().opts(); 34 | 35 | return this.of({ 36 | goodsCode: opts.goodsCode, 37 | placeCode: opts.placeCode, 38 | playSeq: opts.playSeq, 39 | username: opts.username, 40 | password: opts.password, 41 | slackWebhookUrl: opts.slackWebhookUrl, 42 | pollIntervalMillis: parseInt(opts.pollIntervalMillis), 43 | captureRegex: opts.captureRegex, 44 | }); 45 | } 46 | 47 | static of(partial: Partial) { 48 | return Object.assign(new Config(), partial); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/actor/Worker.ts: -------------------------------------------------------------------------------- 1 | import Seat from '../model/Seat'; 2 | import Detector from './Detector'; 3 | import Notifier from './Notifier'; 4 | import Repository from './Repository'; 5 | import Catcher from './Catcher'; 6 | 7 | export default class Worker { 8 | constructor( 9 | private readonly repo: Repository, 10 | private readonly catcher: Catcher, 11 | private readonly notifier: Notifier 12 | ) { 13 | } 14 | 15 | private working: Boolean = false; 16 | private previousSeats: Seat[] = []; 17 | 18 | async tick() { 19 | let currentSeats: Seat[] = []; 20 | 21 | try { 22 | currentSeats = await this.repo.getAvailableSeats(); 23 | } catch (e) { 24 | console.error(e); 25 | return; 26 | } 27 | 28 | try { 29 | process.stdout.write('.'); 30 | 31 | if (!this.working) { 32 | this.working = true; 33 | 34 | process.stdout.write('!'); 35 | await this.notifier.notifyText('!'); 36 | 37 | return; 38 | } 39 | 40 | const catchResults = await this.catcher.catchIfDesired(currentSeats); 41 | if (catchResults.length > 0) { 42 | console.log(catchResults); 43 | 44 | await this.notifier.notifySeatCatchResults(catchResults); 45 | } 46 | 47 | const detector = new Detector(this.previousSeats, currentSeats); 48 | if (detector.hasChanges) { 49 | process.stdout.write('_'); 50 | 51 | await this.notifier.notifySeatChanges({ 52 | activatedSeats: detector.activatedSeats(), 53 | deactivatedSeats: detector.deactivatedSeats() 54 | }); 55 | } 56 | 57 | } catch (e: any) { 58 | console.error(e); 59 | await this.notifier.notifyText(e.message); 60 | } finally { 61 | this.previousSeats = currentSeats; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "interpark-ticket-watcher", 3 | "version": "0.0.1", 4 | "description": "인터파크 취소표 watcher", 5 | "scripts": { 6 | "test": "jest", 7 | "dev": "nodemon --exec ts-node index.ts", 8 | "build": "tsc", 9 | "start": "node dist/index.js", 10 | "preview": "npm run build && node dist/index.js", 11 | "postinstall": "npm run build" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/potados99/interpark-ticket-watcher.git" 16 | }, 17 | "keywords": [], 18 | "author": "potados99", 19 | "license": "GPL-3.0-or-later", 20 | "bugs": { 21 | "url": "https://github.com/potados99/interpark-ticket-watcher/issues" 22 | }, 23 | "homepage": "https://github.com/potados99/interpark-ticket-watcher#readme", 24 | "dependencies": { 25 | "axios": "^0.26.1", 26 | "axios-cookiejar-support": "^2.0.4", 27 | "cheerio": "^1.0.0-rc.10", 28 | "commander": "^9.0.0", 29 | "date-fns": "^2.28.0", 30 | "iconv-lite": "^0.6.3", 31 | "node-fetch": "^2.6.1", 32 | "qs": "^6.10.3", 33 | "tough-cookie": "^4.0.0" 34 | }, 35 | "devDependencies": { 36 | "@types/date-fns": "^2.6.0", 37 | "@types/jest": "^26.0.22", 38 | "@types/node": "^14.14.31", 39 | "@types/node-fetch": "^2.5.10", 40 | "@types/qs": "^6.9.7", 41 | "@types/tough-cookie": "^4.0.1", 42 | "jest": "^26.6.3", 43 | "nodemon": "^2.0.7", 44 | "prettier": "^2.3.2", 45 | "ts-jest": "^26.5.2", 46 | "ts-node": "^9.1.1", 47 | "typescript": "^4.2.2" 48 | }, 49 | "jest": { 50 | "transform": { 51 | "^.+\\.ts$": "ts-jest" 52 | }, 53 | "testRegex": "\\.test\\.ts$", 54 | "moduleFileExtensions": [ 55 | "ts", 56 | "js" 57 | ], 58 | "testEnvironment": "node" 59 | }, 60 | "prettier": { 61 | "tabWidth": 2, 62 | "semi": true, 63 | "singleQuote": true, 64 | "bracketSpacing": false, 65 | "printWidth": 100 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 16 | 17 | 24 | 25 | 28 | 29 | 36 | 37 | 44 | 45 | 52 | 53 | 58 | 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Node template 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | .parcel-cache 79 | 80 | # Next.js build output 81 | .next 82 | out 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and not Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | 109 | # Stores VSCode versions used for testing VSCode extensions 110 | .vscode-test 111 | 112 | # yarn v2 113 | .yarn/cache 114 | .yarn/unplugged 115 | .yarn/build-state.yml 116 | .yarn/install-state.gz 117 | .pnp.* 118 | 119 | ### Node template 120 | # Logs 121 | logs 122 | *.log 123 | npm-debug.log* 124 | yarn-debug.log* 125 | yarn-error.log* 126 | lerna-debug.log* 127 | 128 | # Diagnostic reports (https://nodejs.org/api/report.html) 129 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 130 | 131 | # Runtime data 132 | pids 133 | *.pid 134 | *.seed 135 | *.pid.lock 136 | 137 | # Directory for instrumented libs generated by jscoverage/JSCover 138 | lib-cov 139 | 140 | # Coverage directory used by tools like istanbul 141 | coverage 142 | *.lcov 143 | 144 | # nyc test coverage 145 | .nyc_output 146 | 147 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 148 | .grunt 149 | 150 | # Bower dependency directory (https://bower.io/) 151 | bower_components 152 | 153 | # node-waf configuration 154 | .lock-wscript 155 | 156 | # Compiled binary addons (https://nodejs.org/api/addons.html) 157 | build/Release 158 | 159 | # Dependency directories 160 | node_modules/ 161 | jspm_packages/ 162 | 163 | # Snowpack dependency directory (https://snowpack.dev/) 164 | web_modules/ 165 | 166 | # TypeScript cache 167 | *.tsbuildinfo 168 | 169 | # Optional npm cache directory 170 | .npm 171 | 172 | # Optional eslint cache 173 | .eslintcache 174 | 175 | # Microbundle cache 176 | .rpt2_cache/ 177 | .rts2_cache_cjs/ 178 | .rts2_cache_es/ 179 | .rts2_cache_umd/ 180 | 181 | # Optional REPL history 182 | .node_repl_history 183 | 184 | # Output of 'npm pack' 185 | *.tgz 186 | 187 | # Yarn Integrity file 188 | .yarn-integrity 189 | 190 | # dotenv environment variables file 191 | .env 192 | .env.test 193 | 194 | # parcel-bundler cache (https://parceljs.org/) 195 | .cache 196 | .parcel-cache 197 | 198 | # Next.js build output 199 | .next 200 | out 201 | 202 | # Nuxt.js build / generate output 203 | .nuxt 204 | dist 205 | 206 | # Gatsby files 207 | .cache/ 208 | # Comment in the public line in if your project uses Gatsby and not Next.js 209 | # https://nextjs.org/blog/next-9-1#public-directory-support 210 | # public 211 | 212 | # vuepress build output 213 | .vuepress/dist 214 | 215 | # Serverless directories 216 | .serverless/ 217 | 218 | # FuseBox cache 219 | .fusebox/ 220 | 221 | # DynamoDB Local files 222 | .dynamodb/ 223 | 224 | # TernJS port file 225 | .tern-port 226 | 227 | # Stores VSCode versions used for testing VSCode extensions 228 | .vscode-test 229 | 230 | # yarn v2 231 | .yarn/cache 232 | .yarn/unplugged 233 | .yarn/build-state.yml 234 | .yarn/install-state.gz 235 | .pnp.* 236 | 237 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | "lib": ["es2019", "dom"], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "dist", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | "strictPropertyInitialization": false, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolut 44 | ion Options */ 45 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 49 | // "typeRoots": [], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | 66 | /* Advanced Options */ 67 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 68 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 69 | }, 70 | 71 | "exclude": [ 72 | "node_modules", "test", "dist" 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /lib/actor/Accessor.ts: -------------------------------------------------------------------------------- 1 | import qs from 'qs'; 2 | import Seat from '../model/Seat'; 3 | import iconv from 'iconv-lite'; 4 | import Config from '../Config'; 5 | import cheerio from 'cheerio'; 6 | import {newAxiosInstance, newCookieJar} from '../common/axios'; 7 | 8 | type Session = { 9 | sessionId: string; 10 | oneStopModel: any; 11 | } 12 | 13 | export type AfterReserveScripts = { 14 | nextStepScript: string; 15 | cancelCurlScript: string; 16 | }; 17 | 18 | /** 19 | * 인터파크에 요청 보낼 때에 사용하는 친구입니다. 20 | * 계정 정보가 있으면 로그인할 수 있습니다. 21 | * 쿠키 저장소를 가지고 있습니다. 22 | */ 23 | export default class Accessor { 24 | constructor( 25 | private readonly config: Config 26 | ) { 27 | } 28 | 29 | private jar = newCookieJar(); 30 | private axios = newAxiosInstance(this.jar); 31 | 32 | /** 33 | * 좌석을 잡는 데에 사용될 세션입니다. 34 | * 테스트 결과 한 번 만들어 놓으면 계속 가는 것 같으니, 35 | * 따로 새로고침은 안 합니다. 36 | * @private 37 | */ 38 | private session: Session; 39 | 40 | /** 41 | * 새로고침합니다. 로그인 후 세션 ID와 OneStopModel 까지 다시 받아옵니다. 42 | */ 43 | async reload(): Promise { 44 | await this.login(); 45 | 46 | this.session = await this.generateSession(); 47 | 48 | console.log(`Accessor 리로드 완료!`); 49 | } 50 | 51 | /** 52 | * 로그인합니다. 결과는 쿠키에 담깁니다. 53 | */ 54 | async login(): Promise { 55 | // 로그인 폼 화면에 진입합니다. 쿠키가 우수수 떨어집니다. 56 | await this.axios.get(`https://accounts.interpark.com/authorize/ticket-mweb?origin=http%3A%2F%2Fmticket.interpark.com%2FMyTicket%2F&postProc=NONE`); 57 | 58 | // 로그인 요청을 쏩니다. 쿠키도 들고 갑니다. 59 | const loginResult = await this.axios.post(`https://accounts.interpark.com/login/submit`, qs.stringify({ 60 | userId: this.config.username, 61 | userPwd: this.config.password 62 | }), { 63 | headers: { 64 | 'X-Requested-With': 'XMLHttpRequest' 65 | } 66 | }); 67 | 68 | const resultCode = loginResult.data.result_code; 69 | const callbackUrl = loginResult.data.callback_url; 70 | 71 | if (resultCode !== '00') { 72 | throw new Error(`로그인 실패!! 자세한 응답은 요기: ${JSON.stringify(loginResult.data)}`); 73 | } 74 | 75 | // 주어진 콜백 URL로 들어가면 또 쿠키가 우수수 떨어지는데, 그중에 id_token이 있습니다. 76 | await this.axios.get(callbackUrl); 77 | 78 | const idToken = this.jar.toJSON().cookies.find(c => c.key === 'id_token'); 79 | if (idToken == null) { 80 | throw new Error('로그인 후 id_token이 없습니다ㅠ'); 81 | } 82 | } 83 | 84 | /** 85 | * 좌석 킵할때 사용될 세션 ID를 만들어옵니다. 86 | * 주의: 로그인이 되어 있어야(쿠키에 id_token이 숨쉬고 있어야) 합니다. 87 | */ 88 | async generateSession(): Promise { 89 | const result = await this.axios.get( 90 | `https://moticket.interpark.com/OneStop/Session?GoodsCode=${this.config.goodsCode}`, 91 | {responseType: 'arraybuffer'} 92 | ); 93 | 94 | const content = iconv.decode(result.data, 'EUC-KR').toString(); 95 | 96 | const $ = cheerio.load(content); 97 | const $input = $('input#OneStopModel'); 98 | 99 | const oneStopModelString = $input.attr('value'); 100 | if (oneStopModelString == null) { 101 | throw new Error('페이지 내에 OneStopModel이 없습니다ㅠ'); 102 | } 103 | 104 | const oneStopModel = JSON.parse(oneStopModelString); 105 | 106 | return { 107 | sessionId: oneStopModel.GoodsInfo.SessionID, 108 | oneStopModel 109 | }; 110 | } 111 | 112 | /** 113 | * 자리를 킵해둡니다. 114 | * 115 | * @param seat 킵할 자리. 116 | */ 117 | async reserveSeat(seat: Seat): Promise { 118 | if (this.session == null) { 119 | await this.reload(); 120 | } 121 | 122 | const reserveParams = { 123 | GoodsCode: this.config.goodsCode, 124 | PlaceCode: this.config.placeCode, 125 | PlaySeq: this.config.playSeq, 126 | SessionID: this.session.sessionId, 127 | SeatCnt: `1`, 128 | SeatGrade: `1^`, 129 | Floor: `^`, 130 | RowNo: `${seat.row}^`, 131 | SeatNo: `${seat.column}^`, 132 | BlankSeatCheckYN: 'N', 133 | SportsYN: 'N' 134 | }; 135 | 136 | await this.axios.post( 137 | 'https://moticket.interpark.com/OneStop/SaveSeatAjax', 138 | qs.stringify(reserveParams) 139 | ); 140 | 141 | return this.generateScripts(seat); 142 | } 143 | 144 | /** 145 | * 브라우저 콘솔에서 실행할 예매 재개 스크립트와 예약 취소 cURL 스크립트를 만들어옵니다. 146 | * 147 | * @param seat 예매할 자리. 148 | * @private 149 | */ 150 | private generateScripts(seat: Seat): AfterReserveScripts { 151 | const oneStopModel = { 152 | 'GoodsInfo': this.session.oneStopModel.GoodsInfo, 153 | 'PlayDateTime': { 154 | 'PlaySeq': this.config.playSeq 155 | }, 156 | 'SeatInfo': { 157 | 'BlockNo': '001^', 158 | 'Floor': '^', 159 | 'RowNo': `${seat.row}^`, 160 | 'SeatNo': `${seat.column}^`, 161 | 'SeatGrade': '1^', 162 | 'IsBlock': 'Y', 163 | 'SeatCnt': 1 164 | } 165 | }; 166 | 167 | const body = `OneStopModel=${encodeURIComponent(JSON.stringify(oneStopModel))}`; 168 | 169 | // 브라우저 콘솔에서 실행합니다. 결제화면으로 넘어가는 스크립트입니다. 170 | const nextStepScript = ` 171 | fetch('https://moticket.interpark.com/OneStop/Seat', { 172 | method: 'POST', 173 | headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'}, 174 | body: '${body}' 175 | } 176 | ).finally(() => { 177 | document.getElementById('ifrmSeat').contentWindow.document.getElementById('ifrmSeatDetail').onload = () => { 178 | document.getElementById('ifrmSeat').contentWindow.document.getElementById('ifrmSeatDetail').onload = () => {}; 179 | document.getElementById('ifrmSeat').contentWindow.document.getElementById('ifrmSeatDetail').contentWindow.document.querySelector('img.stySeat[alt="[전석] ${seat.row}-${seat.column}"]').click(); 180 | document.getElementById('ifrmSeat').contentWindow.fnSelect(); 181 | }; 182 | document.getElementById('ifrmSeat').contentWindow.fnRefresh(); 183 | });`; 184 | 185 | // 쉘에서 실행합니다. 자리 예약을 취소하는 명령입니다. 186 | const cancelCurlScript = `curl --location --request POST 'https://moticket.interpark.com/OneStop/Seat' --header 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' --data-raw '${body}'`; 187 | 188 | return { 189 | nextStepScript, 190 | cancelCurlScript 191 | }; 192 | } 193 | 194 | /** 195 | * 좌석맵 HTML 쓸어옵니다. 196 | */ 197 | async getSeatMapDetail(): Promise { 198 | const querystring = qs.stringify({ 199 | GoodsCode: this.config.goodsCode, 200 | PlaceCode: this.config.placeCode, 201 | PlaySeq: this.config.playSeq, 202 | Block: 'RGN001', 203 | TmgsOrNot: 'D2006' 204 | }); 205 | 206 | const result = await this.axios.get(`https://aspseat-ticket.interpark.com/Booking/App/MOSeatDetail.asp?${querystring}`); 207 | 208 | return result.data; 209 | } 210 | } 211 | --------------------------------------------------------------------------------