├── 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 |
4 |
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 |
4 |
5 |
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 |
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 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
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 |
--------------------------------------------------------------------------------