├── .gitignore ├── src ├── api │ ├── index.ts │ ├── auth.ts │ ├── live.ts │ └── channel.ts ├── chat │ ├── index.ts │ ├── event.ts │ ├── types.ts │ └── chat.ts ├── index.ts ├── const.ts ├── types.ts └── client.ts ├── .eslintrc.json ├── .npmignore ├── .editorconfig ├── tsconfig.json ├── package.json ├── LICENSE ├── example.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | dist 3 | node_modules 4 | test.ts 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./live" 2 | export * from "./channel" 3 | export * from "./auth" -------------------------------------------------------------------------------- /src/chat/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./chat" 2 | export * from "./types" 3 | export * from "./event" -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api" 2 | export * from "./chat" 3 | export * from "./client" 4 | export * from "./types" 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "semi": [ 4 | 2, 5 | "never" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src 3 | .* 4 | example.ts 5 | package-lock.json 6 | tsconfig.json 7 | 8 | test.ts 9 | images 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | 9 | ij_javascript_force_semicolon_style = true 10 | ij_javascript_use_semicolon_after_statement = false 11 | ij_typescript_force_semicolon_style = true 12 | ij_typescript_use_semicolon_after_statement = false 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "ESNext", 5 | ], 6 | "outDir": "./dist/", 7 | "target": "ESNext", 8 | "esModuleInterop": true, 9 | "declaration": true, 10 | "module": "CommonJS", 11 | "moduleResolution": "node" 12 | }, 13 | "include": [ 14 | "src" 15 | ], 16 | "exclude": [ 17 | "node_modules" 18 | ] 19 | } -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | import {SoopAPIBaseUrls} from "./types" 2 | 3 | export const DEFAULT_BASE_URLS: SoopAPIBaseUrls = { 4 | soopLiveBaseUrl: 'https://live.sooplive.co.kr', 5 | soopChannelBaseUrl: 'https://chapi.sooplive.co.kr', 6 | soopAuthBaseUrl: 'https://login.sooplive.co.kr' 7 | } 8 | 9 | export const DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/37.0.2049.0 Safari/537.36" 10 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import {SoopChat, SoopChatOptions} from "./chat" 2 | 3 | export interface SoopAPIBaseUrls { 4 | soopLiveBaseUrl?: string 5 | soopChannelBaseUrl?: string 6 | soopAuthBaseUrl?: string 7 | } 8 | 9 | export interface SoopClientOptions { 10 | baseUrls?: SoopAPIBaseUrls 11 | userAgent?: string 12 | } 13 | 14 | export interface SoopLoginOptions { 15 | userId?: string, 16 | password?: string 17 | } 18 | 19 | export type SoopChatFunc = { 20 | (options: SoopChatOptions): SoopChat 21 | } 22 | -------------------------------------------------------------------------------- /src/chat/event.ts: -------------------------------------------------------------------------------- 1 | export enum SoopChatEvent { 2 | CONNECT = 'connect', 3 | ENTER_CHAT_ROOM = 'enterChatRoom', 4 | DISCONNECT = 'disconnect', 5 | 6 | CHAT = 'chat', 7 | EMOTICON = 'emoticon', 8 | NOTIFICATION = 'notification', 9 | 10 | TEXT_DONATION = 'textDonation', 11 | VIDEO_DONATION = 'videoDonation', 12 | AD_BALLOON_DONATION = 'adBalloonDonation', 13 | SUBSCRIBE = 'subscribe', 14 | 15 | VIEWER = 'viewer', 16 | EXIT = 'exit', 17 | 18 | UNKNOWN = 'unknown', 19 | RAW = 'raw', 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "soop-extension", 3 | "version": "1.3.1", 4 | "license": "MIT", 5 | "description": "라이브 스트리밍 서비스 숲(soop)의 비공식 API 라이브러리", 6 | "keywords": [ 7 | "soop" 8 | ], 9 | "author": { 10 | "name": "reindeer002", 11 | "email": "lsj000424@gmail.com", 12 | "url": "https://github.com/maro5397" 13 | }, 14 | "main": "dist/index.js", 15 | "typings": "dist/index.d.ts", 16 | "scripts": { 17 | "build": "tsc", 18 | "test": "ts-node example.ts", 19 | "lint": "eslint" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/maro5397/soop.git" 24 | }, 25 | "bugs": { 26 | "url": "https://github.com/maro5397/soop/issues", 27 | "email": "lsj000424@gmail.com" 28 | }, 29 | "devDependencies": { 30 | "@types/node": "^22.10.2", 31 | "@types/ws": "^8.5.13", 32 | "typescript": "^5.7.2" 33 | }, 34 | "dependencies": { 35 | "eslint": "^9.17.0", 36 | "ws": "^8.18.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 reindeer002 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import {SoopAuth, SoopLive} from "./api" 2 | import {SoopChatFunc, SoopClientOptions} from "./types" 3 | import {DEFAULT_BASE_URLS, DEFAULT_USER_AGENT} from "./const" 4 | import { SoopChatOptions } from "./chat" 5 | import { SoopChat } from "./chat" 6 | import {SoopChannel} from "./api" 7 | 8 | export class SoopClient { 9 | readonly options: SoopClientOptions 10 | live = new SoopLive(this) 11 | channel = new SoopChannel(this) 12 | auth = new SoopAuth(this) 13 | 14 | constructor(options: SoopClientOptions = {}) { 15 | options.baseUrls = options.baseUrls || DEFAULT_BASE_URLS 16 | options.userAgent = options.userAgent || DEFAULT_USER_AGENT 17 | 18 | this.options = options 19 | } 20 | 21 | get chat(): SoopChatFunc { 22 | return (options: SoopChatOptions) => { 23 | return new SoopChat({ 24 | client: this, 25 | baseUrls: this.options.baseUrls, 26 | ...options 27 | }) 28 | } 29 | } 30 | 31 | fetch(url: string, options?: RequestInit): Promise { 32 | const headers = { 33 | "User-Agent": this.options.userAgent, 34 | ...(options?.headers || {}) 35 | } 36 | 37 | return fetch(url, { 38 | headers: headers, 39 | ...options 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/api/auth.ts: -------------------------------------------------------------------------------- 1 | import {SoopClient} from "../client" 2 | import {DEFAULT_BASE_URLS} from "../const" 3 | 4 | export interface Cookie { 5 | AbroadChk: string, 6 | AbroadVod: string, 7 | AuthTicket: string, 8 | BbsTicket: string, 9 | RDB: string, 10 | UserTicket: string, 11 | _au: string, 12 | _au3rd: string, 13 | _ausa: string, 14 | _ausb: string, 15 | isBbs: Number 16 | } 17 | 18 | export class SoopAuth { 19 | private client: SoopClient 20 | 21 | constructor(client: SoopClient) { 22 | this.client = client 23 | } 24 | 25 | async signIn( 26 | userId: string, 27 | password: string, 28 | baseUrl: string = DEFAULT_BASE_URLS.soopAuthBaseUrl 29 | ): Promise { 30 | const formData = new FormData() 31 | formData.append("szWork", "login") 32 | formData.append("szType", "json") 33 | formData.append("szUid", userId) 34 | formData.append("szPassword", password) 35 | 36 | const response = await this.client.fetch(`${baseUrl}/app/LoginAction.php`, { 37 | method: "POST", 38 | body: formData 39 | }) 40 | 41 | const setCookieHeader = response.headers.get("set-cookie") 42 | if (!setCookieHeader) { 43 | throw new Error("No set-cookie header found in response") 44 | } 45 | 46 | const getCookieValue = (name: string): string => { 47 | const regex = new RegExp(`${name}=([^;]+)`) 48 | const match = setCookieHeader.match(regex) 49 | if (!match) { 50 | throw new Error(`Cookie "${name}" not found in set-cookie header`) 51 | } 52 | return match[1] 53 | } 54 | 55 | return { 56 | AbroadChk: getCookieValue("AbroadChk"), 57 | AbroadVod: getCookieValue("AbroadVod"), 58 | AuthTicket: getCookieValue("AuthTicket"), 59 | BbsTicket: getCookieValue("BbsTicket"), 60 | RDB: getCookieValue("RDB"), 61 | UserTicket: getCookieValue("UserTicket"), 62 | _au: getCookieValue("_au"), 63 | _au3rd: getCookieValue("_au3rd"), 64 | _ausa: getCookieValue("_ausa"), 65 | _ausb: getCookieValue("_ausb"), 66 | isBbs: Number(getCookieValue("isBbs")) 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /src/chat/types.ts: -------------------------------------------------------------------------------- 1 | import {SoopAPIBaseUrls, SoopLoginOptions} from "../types" 2 | import {SoopClient} from "../client" 3 | 4 | export interface SoopChatOptions { 5 | streamerId: string 6 | login?: SoopLoginOptions 7 | baseUrls?: SoopAPIBaseUrls 8 | } 9 | 10 | export interface SoopChatOptionsWithClient extends SoopChatOptions { 11 | client?: SoopClient 12 | } 13 | 14 | export enum ChatDelimiter { 15 | STARTER = "\x1b\t", 16 | SEPARATOR = "\x0c", 17 | ELEMENT_START = "\x11", 18 | ELEMENT_END = "\x12", 19 | SPACE = "\x06" 20 | } 21 | 22 | export enum ChatType { 23 | PING = "0000", 24 | CONNECT = "0001", 25 | ENTER_CHAT_ROOM = "0002", 26 | EXIT = "0004", 27 | CHAT = "0005", 28 | DISCONNECT = "0007", 29 | ENTER_INFO = "0012", 30 | TEXT_DONATION = "0018", 31 | AD_BALLOON_DONATION = "0087", 32 | SUBSCRIBE = "0093", 33 | NOTIFICATION = "0104", 34 | EMOTICON = "0109", 35 | VIDEO_DONATION = "0105", 36 | VIEWER = "0127", 37 | // UNKNOWN = "0009", 38 | // UNKNOWN = "0054", 39 | // UNKNOWN = "0088", 40 | // UNKNOWN = "0094", 41 | } 42 | 43 | export interface Events { 44 | connect: ConnectResponse 45 | enterChatRoom: EnterChatRoomResponse 46 | notification: NotificationResponse 47 | chat: ChatResponse 48 | emoticon: EmotionResponse 49 | textDonation: DonationResponse 50 | videoDonation: DonationResponse 51 | adBalloonDonation: DonationResponse 52 | subscribe: SubscribeResponse 53 | exit: ExitResponse 54 | disconnect: DisconnectResponse 55 | viewer: ViewerResponse 56 | unknown: string 57 | raw: string 58 | } 59 | 60 | export interface Response { 61 | receivedTime: string 62 | } 63 | 64 | export interface ConnectResponse extends Response{ 65 | syn: string 66 | username: string 67 | streamerId: string 68 | } 69 | 70 | export interface EnterChatRoomResponse extends Response{ 71 | synAck: string 72 | streamerId: string 73 | } 74 | 75 | export interface NotificationResponse extends Response{ 76 | notification: string 77 | } 78 | 79 | export interface ChatResponse extends Response{ 80 | username: string 81 | userId: string 82 | comment: string 83 | } 84 | 85 | export interface DonationResponse extends Response{ 86 | to: string 87 | from: string 88 | fromUsername: string 89 | amount: string 90 | fanClubOrdinal: string 91 | } 92 | 93 | export interface EmotionResponse extends Response{ 94 | userId: string 95 | username: string 96 | emoticonId: string 97 | } 98 | 99 | export interface ViewerResponse extends Response{ 100 | userId: string[] 101 | } 102 | 103 | export interface SubscribeResponse extends Response { 104 | to: string 105 | from: string 106 | fromUsername: string 107 | monthCount: string 108 | tier: string 109 | } 110 | 111 | export interface ExitResponse extends Response{ 112 | username: string 113 | userId: string 114 | } 115 | 116 | export interface DisconnectResponse extends Response{ 117 | streamerId: string 118 | } -------------------------------------------------------------------------------- /src/api/live.ts: -------------------------------------------------------------------------------- 1 | import {SoopClient} from "../client" 2 | import {DEFAULT_BASE_URLS} from "../const" 3 | import {Cookie} from "./auth" 4 | 5 | export interface LiveDetail { 6 | CHANNEL: { 7 | geo_cc: string 8 | geo_rc: string 9 | acpt_lang: string 10 | svc_lang: string 11 | ISSP: number 12 | LOWLAYTENCYBJ: number 13 | VIEWPRESET: ViewPreset[] 14 | RESULT: number 15 | PBNO: string 16 | BNO: string 17 | BJID: string 18 | BJNICK: string 19 | BJGRADE: number 20 | STNO: string 21 | ISFAV: string 22 | CATE: string 23 | CPNO: number 24 | GRADE: string 25 | BTYPE: string 26 | CHATNO: string 27 | BPWD: string 28 | TITLE: string 29 | BPS: string 30 | RESOLUTION: string 31 | CTIP: string 32 | CTPT: string 33 | VBT: string 34 | CTUSER: number 35 | S1440P: number 36 | AUTO_HASHTAGS: string[] 37 | CATEGORY_TAGS: string[] 38 | HASH_TAGS: string[] 39 | CHIP: string 40 | CHPT: string 41 | CHDOMAIN: string 42 | CDN: string 43 | RMD: string 44 | GWIP: string 45 | GWPT: string 46 | STYPE: string 47 | ORG: string 48 | MDPT: string 49 | BTIME: number 50 | DH: number 51 | WC: number 52 | PCON: number 53 | PCON_TIME: number 54 | PCON_MONTH: string[] 55 | PCON_OBJECT: any[] 56 | FTK: string 57 | BPCBANNER: boolean 58 | BPCCHATPOPBANNER: boolean 59 | BPCTIMEBANNER: boolean 60 | BPCCONNECTBANNER: boolean 61 | BPCLOADINGBANNER: boolean 62 | BPCPOSTROLL: string 63 | BPCPREROLL: string 64 | MIDROLL: Midroll 65 | PREROLLTAG: string 66 | MIDROLLTAG: string 67 | POSTROLLTAG: string 68 | BJAWARD: boolean 69 | BJAWARDWATERMARK: boolean 70 | BJAWARDYEAR: string 71 | GEM: boolean 72 | GEM_LOG: boolean 73 | CLEAR_MODE_CATE: string[] 74 | PLAYTIMINGBUFFER_DURATION: string 75 | STREAMER_PLAYTIMINGBUFFER_DURATION: string 76 | MAXBUFFER_DURATION: string 77 | LOWBUFFER_DURATION: string 78 | PLAYBACKRATEDELTA: string 79 | MAXOVERSEEKDURATION: string 80 | TIER1_NICK: string 81 | TIER2_NICK: string 82 | EXPOSE_FLAG: number 83 | SUB_PAY_CNT: number 84 | } 85 | } 86 | 87 | export interface ViewPreset { 88 | label: string 89 | label_resolution: string 90 | name: string 91 | bps: number 92 | } 93 | 94 | export interface Midroll { 95 | VALUE: string 96 | OFFSET_START_TIME: number 97 | OFFSET_END_TIME: number 98 | } 99 | 100 | export interface LiveDetailOptions { 101 | type: string 102 | pwd: string 103 | player_type: string 104 | stream_type: string 105 | quality: string 106 | mode: string 107 | from_api: string 108 | is_revive: boolean 109 | } 110 | 111 | export const DEFAULT_REQUEST_BODY_FOR_LIVE_STATUS = { 112 | 'type': 'live', 113 | 'pwd': '', 114 | 'player_type': 'html5', 115 | 'stream_type': 'common', 116 | 'quality': 'HD', 117 | 'mode': 'landing', 118 | 'from_api': '0', 119 | 'is_revive': false 120 | } 121 | 122 | export class SoopLive { 123 | private client: SoopClient 124 | 125 | constructor(client: SoopClient) { 126 | this.client = client 127 | } 128 | 129 | async detail( 130 | streamerId: string, 131 | cookie: Cookie = null, 132 | options: LiveDetailOptions = DEFAULT_REQUEST_BODY_FOR_LIVE_STATUS, 133 | baseUrl: string = DEFAULT_BASE_URLS.soopLiveBaseUrl 134 | ): Promise { 135 | const body = { 136 | bid: streamerId, 137 | ...(options || {}) 138 | } 139 | const params = new URLSearchParams( 140 | Object.entries(body).reduce((acc, [key, value]) => { 141 | acc[key] = String(value) 142 | return acc 143 | }, {} as Record) 144 | ) 145 | return this.client.fetch(`${baseUrl}/afreeca/player_live_api.php?bjid=${streamerId}`, { 146 | method: "POST", 147 | headers: { 148 | "Content-Type": "application/x-www-form-urlencoded", 149 | "Cookie": cookie && this.buildCookieString(cookie) 150 | }, 151 | body: params.toString() 152 | }) 153 | .then(response => response.json()) 154 | .then(data => { 155 | return {CHANNEL: data["CHANNEL"]} 156 | }) 157 | } 158 | 159 | private buildCookieString(data: Cookie): string { 160 | return Object.entries(data) 161 | .map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`) 162 | .join("; ") 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/api/channel.ts: -------------------------------------------------------------------------------- 1 | import {SoopClient} from "../client" 2 | import {DEFAULT_BASE_URLS} from "../const" 3 | 4 | interface StationInfo { 5 | profile_image: string; 6 | station: Station; 7 | broad: Broad; 8 | starballoon_top: StarBalloonTop[]; 9 | sticker_top: StickerTop[]; 10 | subscription: Subscription; 11 | is_best_bj: boolean; 12 | is_partner_bj: boolean; 13 | is_ppv_bj: boolean; 14 | is_af_supporters_bj: boolean; 15 | is_shopfreeca_bj: boolean; 16 | is_favorite: boolean; 17 | is_subscription: boolean; 18 | is_owner: boolean; 19 | is_manager: boolean; 20 | is_notice: boolean; 21 | is_adsence: boolean; 22 | is_mobile_push: boolean; 23 | subscribe_visible: string; 24 | country: string; 25 | current_timestamp: string; 26 | } 27 | 28 | interface Station { 29 | display: Display; 30 | groups: Group[]; 31 | menus: Menu[]; 32 | upd: Upd; 33 | vods: Vod[]; 34 | broad_start: string; 35 | grade: number; 36 | jointime: string; 37 | station_name: string; 38 | station_no: number; 39 | station_title: string; 40 | total_broad_time: number; 41 | user_id: string; 42 | user_nick: string; 43 | active_no: number; 44 | } 45 | 46 | interface Display { 47 | main_type: string; 48 | title_type: string; 49 | title_text: string; 50 | profile_text: string; 51 | skin_type: number; 52 | skin_no: number; 53 | title_skin_image: string; 54 | } 55 | 56 | interface Group { 57 | idx: number; 58 | group_no: number; 59 | priority: number; 60 | info: { 61 | group_name: string; 62 | group_class_name: string; 63 | group_background_color: string; 64 | }; 65 | } 66 | 67 | interface Menu { 68 | bbs_no: number; 69 | station_no: number; 70 | auth_no: number; 71 | w_auth_no: number; 72 | display_type: number; 73 | rnum: number; 74 | line: number; 75 | indention: number; 76 | name: string; 77 | name_font: number; 78 | main_view_yn: number; 79 | view_type: number; 80 | } 81 | 82 | interface Upd { 83 | station_no: number; 84 | user_id: string; 85 | asp_code: number; 86 | fan_cnt: number; 87 | today0_visit_cnt: number; 88 | today1_visit_cnt: number; 89 | total_visit_cnt: number; 90 | today0_ok_cnt: number; 91 | today1_ok_cnt: number; 92 | today0_fav_cnt: number; 93 | today1_fav_cnt: number; 94 | total_ok_cnt: number; 95 | total_view_cnt: number; 96 | } 97 | 98 | interface Vod { 99 | bbs_no: number; 100 | station_no: number; 101 | auth_no: number; 102 | w_auth_no: number; 103 | display_type: number; 104 | rnum: number; 105 | line: number; 106 | indention: number; 107 | name: string; 108 | name_font: number; 109 | main_view_yn: number; 110 | view_type: number; 111 | } 112 | 113 | interface Broad { 114 | user_id: string; 115 | broad_no: number; 116 | broad_title: string; 117 | current_sum_viewer: number; 118 | broad_grade: number; 119 | is_password: boolean; 120 | } 121 | 122 | interface StarBalloonTop { 123 | user_id: string; 124 | user_nick: string; 125 | profile_image: string; 126 | } 127 | 128 | interface StickerTop { 129 | user_id: string; 130 | user_nick: string; 131 | profile_image: string; 132 | } 133 | 134 | interface Subscription { 135 | total: number; 136 | tier1: number; 137 | tier2: number; 138 | } 139 | 140 | export class SoopChannel { 141 | private client: SoopClient 142 | 143 | constructor(client: SoopClient) { 144 | this.client = client 145 | } 146 | 147 | async station(streamerId: string, baseUrl: string = DEFAULT_BASE_URLS.soopChannelBaseUrl): Promise { 148 | return this.client.fetch(`${baseUrl}/api/${streamerId}/station`, { 149 | method: "GET", 150 | }) 151 | .then(response => response.json()) 152 | .then(data => { 153 | return { 154 | profile_image: data["profile_image"], 155 | station: data["station"], 156 | broad: data["broad"], 157 | starballoon_top: data["starballoon_top"], 158 | sticker_top: data["sticker_top"], 159 | subscription: data["subscription"], 160 | is_best_bj: data["is_best_bj"], 161 | is_partner_bj: data["is_partner_bj"], 162 | is_ppv_bj: data["is_ppv_bj"], 163 | is_af_supporters_bj: data["is_af_supporters_bj"], 164 | is_shopfreeca_bj: data["is_shopfreeca_bj"], 165 | is_favorite: data["is_favorite"], 166 | is_subscription: data["is_subscription"], 167 | is_owner: data["is_owner"], 168 | is_manager: data["is_manager"], 169 | is_notice: data["is_notice"], 170 | is_adsence: data["is_adsence"], 171 | is_mobile_push: data["is_mobile_push"], 172 | subscribe_visible: data["subscribe_visible"], 173 | country: data["country"], 174 | current_timestamp: data["current_timestamp"] 175 | }; 176 | }) 177 | } 178 | } -------------------------------------------------------------------------------- /example.ts: -------------------------------------------------------------------------------- 1 | import {SoopChatEvent, SoopClient} from "./src" 2 | 3 | (async function () { 4 | const streamerId = process.env.STREAMER_ID 5 | const client = new SoopClient(); 6 | 7 | // 라이브 세부정보 8 | // 로그인 (쿠키 반환) 9 | // 아래와 같이 숲 ID, PASSWORD 문자열 입력 가능 (그대로 VCS 업로드 시 공개된 공간에 노출될 수 있음) 10 | // const cookie = await client.auth.signIn("USERID", "PASSWORD"); 11 | const cookie = await client.auth.signIn(process.env.USERID, process.env.PASSWORD); 12 | console.log(cookie) 13 | 14 | // 라이브 세부정보 (로그인 후) 15 | const liveDetailWithCookie = await client.live.detail(streamerId, cookie); 16 | console.log(liveDetailWithCookie); 17 | 18 | // 라이브 세부정보 19 | const liveDetailWithoutCookie = await client.live.detail(streamerId); 20 | console.log(liveDetailWithoutCookie); 21 | 22 | // 채널 정보 23 | const stationInfo = await client.channel.station(streamerId); 24 | console.log(stationInfo) 25 | 26 | const soopChat = client.chat({ 27 | streamerId: streamerId, 28 | login: { userId: process.env.USERID, password: process.env.PASSWORD } // sendChat 기능을 사용하고 싶을 경우 세팅 29 | }) 30 | 31 | // 연결 성공 32 | soopChat.on(SoopChatEvent.CONNECT, response => { 33 | if(response.username) { 34 | console.log(`[${response.receivedTime}] ${response.username} is connected to ${response.streamerId}`) 35 | } else { 36 | console.log(`[${response.receivedTime}] Connected to ${response.streamerId}`) 37 | } 38 | console.log(`[${response.receivedTime}] SYN packet: ${response.syn}`) 39 | }) 40 | 41 | // 채팅방 입장 42 | soopChat.on(SoopChatEvent.ENTER_CHAT_ROOM, response => { 43 | console.log(`[${response.receivedTime}] Enter to ${response.streamerId}'s chat room`) 44 | console.log(`[${response.receivedTime}] SYN/ACK packet: ${response.synAck}`) 45 | }) 46 | 47 | // 채팅방 공지 48 | soopChat.on(SoopChatEvent.NOTIFICATION, response => { 49 | console.log('-'.repeat(50)) 50 | console.log(`[${response.receivedTime}]`) 51 | console.log(response.notification.replace(/\r\n/g, '\n')) 52 | console.log('-'.repeat(50)) 53 | }) 54 | 55 | // 일반 채팅 56 | soopChat.on(SoopChatEvent.CHAT, response => { 57 | console.log(`[${response.receivedTime}] ${response.username}(${response.userId}): ${response.comment}`) 58 | }) 59 | 60 | // 이모티콘 채팅 61 | soopChat.on(SoopChatEvent.EMOTICON, response => { 62 | console.log(`[${response.receivedTime}] ${response.username}(${response.userId}): ${response.emoticonId}`) 63 | }) 64 | 65 | // 텍스트/음성 후원 채팅 66 | soopChat.on(SoopChatEvent.TEXT_DONATION, response => { 67 | console.log(`\n[${response.receivedTime}] ${response.fromUsername}(${response.from})님이 ${response.to}님에게 ${response.amount}개 후원`) 68 | if (Number(response.fanClubOrdinal) !== 0) { 69 | console.log(`[${response.receivedTime}] ${response.fanClubOrdinal}번째 팬클럽 가입을 환영합니다.\n`) 70 | } else { 71 | console.log(`[${response.receivedTime}] 이미 팬클럽에 가입된 사용자입니다.\n`) 72 | } 73 | }) 74 | 75 | // 영상 후원 채팅 76 | soopChat.on(SoopChatEvent.VIDEO_DONATION, response => { 77 | console.log(`\n[${response.receivedTime}] ${response.fromUsername}(${response.from})님이 ${response.to}님에게 ${response.amount}개 후원`) 78 | if (Number(response.fanClubOrdinal) !== 0) { 79 | console.log(`[${response.receivedTime}] ${response.fanClubOrdinal}번째 팬클럽 가입을 환영합니다.\n`) 80 | } else { 81 | console.log(`[${response.receivedTime}] 이미 팬클럽에 가입된 사용자입니다.\n`) 82 | } 83 | }) 84 | 85 | // 애드벌룬 후원 채팅 86 | soopChat.on(SoopChatEvent.AD_BALLOON_DONATION, response => { 87 | console.log(`\n[${response.receivedTime}] ${response.fromUsername}(${response.from})님이 ${response.to}님에게 ${response.amount}개 후원`) 88 | if (Number(response.fanClubOrdinal) !== 0) { 89 | console.log(`[${response.receivedTime}] ${response.fanClubOrdinal}번째 팬클럽 가입을 환영합니다.\n`) 90 | } else { 91 | console.log(`[${response.receivedTime}] 이미 팬클럽에 가입된 사용자입니다.\n`) 92 | } 93 | }) 94 | 95 | // 구독 채팅 96 | soopChat.on(SoopChatEvent.SUBSCRIBE, response => { 97 | console.log(`\n[${response.receivedTime}] ${response.fromUsername}(${response.from})님이 ${response.to}님을 구독하셨습니다.`) 98 | console.log(`[${response.receivedTime}] ${response.monthCount}개월, ${response.tier}티어\n`) 99 | }) 100 | 101 | // 퇴장 정보 102 | soopChat.on(SoopChatEvent.EXIT, response => { 103 | console.log(`\n[${response.receivedTime}] ${response.username}(${response.userId})이/가 퇴장하셨습니다\n`) 104 | }) 105 | 106 | // 입장 정보 107 | soopChat.on(SoopChatEvent.VIEWER, response => { 108 | if(response.userId.length > 1) { 109 | console.log(`[${response.receivedTime}] 수신한 채팅방 사용자는 ${response.userId.length}명 입니다.`) 110 | } else { 111 | console.log(`[${response.receivedTime}] ${response.userId[0]}이/가 입장하셨습니다`) 112 | } 113 | }) 114 | 115 | // 방송 종료 116 | soopChat.on(SoopChatEvent.DISCONNECT, response => { 117 | console.log(`[${response.receivedTime}] ${response.streamerId}의 방송이 종료됨`) 118 | }) 119 | 120 | // 특정하지 못한 패킷 121 | soopChat.on(SoopChatEvent.UNKNOWN, packet => { 122 | console.log(packet) 123 | }) 124 | 125 | // 패킷을 바이너리 형태로 확인 126 | soopChat.on(SoopChatEvent.RAW, packet => { 127 | console.log(packet) 128 | }) 129 | 130 | // 본인이 송신한 채팅 131 | soopChat.on(SoopChatEvent.CHAT, response => { 132 | if( response.userId.includes(process.env.USERID) ) { 133 | console.log(`[${response.receivedTime}] ${response.username}(${response.userId}): ${response.comment}`) 134 | } 135 | }) 136 | 137 | // 채팅 연결 138 | await soopChat.connect() 139 | 140 | // 채팅 송신 141 | // 바로 채팅 송신 시 채팅방 연결까지 대기 후 송신 142 | // 연속으로 채팅 송신 시 벤 및 송신 실패할 수 있으므로 송신 전 대기 시간 설정 필요 143 | await soopChat.sendChat("ㅎㅇ"); 144 | setInterval(async () => { 145 | await soopChat.sendChat("신기하다"); 146 | }, 5000) 147 | })(); 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # soop 2 | 3 | [![npm version](https://img.shields.io/npm/v/soop-extension.svg?style=for-the-badge)](https://www.npmjs.com/package/soop-extension) 4 | [![npm downloads](https://img.shields.io/npm/dm/soop-extension.svg?style=for-the-badge)](http://npm-stat.com/charts.html?package=soop-extension) 5 | [![license](https://img.shields.io/github/license/maro5397/soop?style=for-the-badge)](https://github.com/maro5397/soop/blob/main/LICENSE) 6 | ![language](https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white) 7 | ![createAt](https://img.shields.io/github/created-at/maro5397/soop?style=for-the-badge) 8 | 9 | 라이브 스트리밍 서비스 숲(soop)의 비공식 API 라이브러리 10 | 11 | 라이브 스트리밍 서비스 SOOP의 비공식 API 라이브러리입니다. 12 | 13 | 현재 구현된 기능은 다음과 같습니다. 14 | 15 | - 방송 상태 및 상세 정보 조회 16 | - 로그인 (세션/쿠키 조회) 17 | - 채팅 송/수신 18 | - 채팅 송/수신 기능 사용 시 각별한 주의가 필요함 19 | - 채팅 송/수신 시 사용자 정보(팬클럽, 구독, 팬가입)정보가 채팅방에 노출됨 20 | - **_악의적 목적으로 사용할 시 책임을 지지 않음을 밝힘_** 21 | 22 | ## 설치 23 | 24 | > Node 20.17.0 버전에서 개발되었습니다. 25 | 26 | ```bash 27 | npm install soop-extension 28 | ``` 29 | 30 | ## 예시 31 | ### API 코드 예시 32 | ```ts 33 | import { SoopChatEvent, SoopClient } from 'soop-extension' 34 | 35 | (async function () { 36 | const streamerId = process.env.STREAMER_ID 37 | const client = new SoopClient(); 38 | 39 | // 라이브 세부정보 40 | // 로그인 (쿠키 반환) 41 | // 아래와 같이 숲 ID, PASSWORD 문자열 입력 가능 (그대로 VCS 업로드 시 공개된 공간에 노출될 수 있음) 42 | // const cookie = await client.auth.signIn("USERID", "PASSWORD"); 43 | const cookie = await client.auth.signIn(process.env.USERID, process.env.PASSWORD); 44 | console.log(cookie) 45 | 46 | // 라이브 세부정보 (로그인 후) 47 | const liveDetailWithCookie = await client.live.detail(streamerId, cookie); 48 | console.log(liveDetailWithCookie); 49 | 50 | // 라이브 세부정보 51 | const liveDetailWithoutCookie = await client.live.detail(streamerId); 52 | console.log(liveDetailWithoutCookie); 53 | 54 | // 채널 정보 55 | const stationInfo = await client.channel.station(streamerId); 56 | console.log(stationInfo) 57 | })(); 58 | 59 | ``` 60 | ### 웹소켓(채팅) 코드 예시 61 | ```ts 62 | import { SoopChatEvent, SoopClient } from 'soop-extension' 63 | 64 | (async function () { 65 | const streamerId = process.env.STREAMER_ID 66 | const client = new SoopClient(); 67 | 68 | const soopChat = client.chat({ 69 | streamerId: streamerId, 70 | login: { userId: process.env.USERID, password: process.env.PASSWORD } // sendChat 기능을 사용하고 싶을 경우 세팅 71 | }) 72 | 73 | // 연결 성공 74 | soopChat.on(SoopChatEvent.CONNECT, response => { 75 | if(response.username) { 76 | console.log(`[${response.receivedTime}] ${response.username} is connected to ${response.streamerId}`) 77 | } else { 78 | console.log(`[${response.receivedTime}] Connected to ${response.streamerId}`) 79 | } 80 | console.log(`[${response.receivedTime}] SYN packet: ${response.syn}`) 81 | }) 82 | 83 | // 채팅방 입장 84 | soopChat.on(SoopChatEvent.ENTER_CHAT_ROOM, response => { 85 | console.log(`[${response.receivedTime}] Enter to ${response.streamerId}'s chat room`) 86 | console.log(`[${response.receivedTime}] SYN/ACK packet: ${response.synAck}`) 87 | }) 88 | 89 | // 채팅방 공지 90 | soopChat.on(SoopChatEvent.NOTIFICATION, response => { 91 | console.log('-'.repeat(50)) 92 | console.log(`[${response.receivedTime}]`) 93 | console.log(response.notification.replace(/\r\n/g, '\n')) 94 | console.log('-'.repeat(50)) 95 | }) 96 | 97 | // 일반 채팅 98 | soopChat.on(SoopChatEvent.CHAT, response => { 99 | console.log(`[${response.receivedTime}] ${response.username}(${response.userId}): ${response.comment}`) 100 | }) 101 | 102 | // 이모티콘 채팅 103 | soopChat.on(SoopChatEvent.EMOTICON, response => { 104 | console.log(`[${response.receivedTime}] ${response.username}(${response.userId}): ${response.emoticonId}`) 105 | }) 106 | 107 | // 텍스트/음성 후원 채팅 108 | soopChat.on(SoopChatEvent.TEXT_DONATION, response => { 109 | console.log(`\n[${response.receivedTime}] ${response.fromUsername}(${response.from})님이 ${response.to}님에게 ${response.amount}개 후원`) 110 | if (Number(response.fanClubOrdinal) !== 0) { 111 | console.log(`[${response.receivedTime}] ${response.fanClubOrdinal}번째 팬클럽 가입을 환영합니다.\n`) 112 | } else { 113 | console.log(`[${response.receivedTime}] 이미 팬클럽에 가입된 사용자입니다.\n`) 114 | } 115 | }) 116 | 117 | // 영상 후원 채팅 118 | soopChat.on(SoopChatEvent.VIDEO_DONATION, response => { 119 | console.log(`\n[${response.receivedTime}] ${response.fromUsername}(${response.from})님이 ${response.to}님에게 ${response.amount}개 후원`) 120 | if (Number(response.fanClubOrdinal) !== 0) { 121 | console.log(`[${response.receivedTime}] ${response.fanClubOrdinal}번째 팬클럽 가입을 환영합니다.\n`) 122 | } else { 123 | console.log(`[${response.receivedTime}] 이미 팬클럽에 가입된 사용자입니다.\n`) 124 | } 125 | }) 126 | 127 | // 애드벌룬 후원 채팅 128 | soopChat.on(SoopChatEvent.AD_BALLOON_DONATION, response => { 129 | console.log(`\n[${response.receivedTime}] ${response.fromUsername}(${response.from})님이 ${response.to}님에게 ${response.amount}개 후원`) 130 | if (Number(response.fanClubOrdinal) !== 0) { 131 | console.log(`[${response.receivedTime}] ${response.fanClubOrdinal}번째 팬클럽 가입을 환영합니다.\n`) 132 | } else { 133 | console.log(`[${response.receivedTime}] 이미 팬클럽에 가입된 사용자입니다.\n`) 134 | } 135 | }) 136 | 137 | // 구독 채팅 138 | soopChat.on(SoopChatEvent.SUBSCRIBE, response => { 139 | console.log(`\n[${response.receivedTime}] ${response.fromUsername}(${response.from})님이 ${response.to}님을 구독하셨습니다.`) 140 | console.log(`[${response.receivedTime}] ${response.monthCount}개월, ${response.tier}티어\n`) 141 | }) 142 | 143 | // 퇴장 정보 144 | soopChat.on(SoopChatEvent.EXIT, response => { 145 | console.log(`\n[${response.receivedTime}] ${response.username}(${response.userId})이/가 퇴장하셨습니다\n`) 146 | }) 147 | 148 | // 입장 정보 149 | soopChat.on(SoopChatEvent.VIEWER, response => { 150 | if(response.userId.length > 1) { 151 | console.log(`[${response.receivedTime}] 수신한 채팅방 사용자는 ${response.userId.length}명 입니다.`) 152 | } else { 153 | console.log(`[${response.receivedTime}] ${response.userId[0]}이/가 입장하셨습니다`) 154 | } 155 | }) 156 | 157 | // 방송 종료 158 | soopChat.on(SoopChatEvent.DISCONNECT, response => { 159 | console.log(`[${response.receivedTime}] ${response.streamerId}의 방송이 종료됨`) 160 | }) 161 | 162 | // 특정하지 못한 패킷 163 | soopChat.on(SoopChatEvent.UNKNOWN, packet => { 164 | console.log(packet) 165 | }) 166 | 167 | // 패킷을 바이너리 형태로 확인 168 | soopChat.on(SoopChatEvent.RAW, packet => { 169 | console.log(packet) 170 | }) 171 | 172 | // 본인이 송신한 채팅 173 | soopChat.on(SoopChatEvent.CHAT, response => { 174 | if( response.userId.includes(process.env.USERID) ) { 175 | console.log(`[${response.receivedTime}] ${response.username}(${response.userId}): ${response.comment}`) 176 | } 177 | }) 178 | 179 | // 채팅 연결 180 | await soopChat.connect() 181 | 182 | // 채팅 송신 183 | // 바로 채팅 송신 시 채팅방 연결까지 대기 후 송신 184 | // 연속으로 채팅 송신 시 벤 및 송신 실패할 수 있으므로 송신 전 대기 시간 설정 필요 185 | await soopChat.sendChat("ㅎㅇ"); 186 | setInterval(async () => { 187 | await soopChat.sendChat("신기하다"); 188 | }, 5000) 189 | })(); 190 | ``` 191 | -------------------------------------------------------------------------------- /src/chat/chat.ts: -------------------------------------------------------------------------------- 1 | import WebSocket, {MessageEvent} from "ws" 2 | import {DEFAULT_BASE_URLS} from "../const" 3 | import {SoopClient} from "../client" 4 | import {ChatDelimiter, ChatType, Events, SoopChatOptions, SoopChatOptionsWithClient} from "./types" 5 | import {Cookie, LiveDetail} from "../api" 6 | import {createSecureContext, SecureContextOptions} from "tls" 7 | import {Agent} from "https" 8 | import {SoopChatEvent} from "./event" 9 | 10 | export class SoopChat { 11 | private readonly client: SoopClient 12 | private ws: WebSocket 13 | private chatUrl: string 14 | private liveDetail: LiveDetail 15 | private cookie: Cookie = null 16 | private options: SoopChatOptions 17 | private handlers: [string, (data: any) => void][] = [] 18 | private pingIntervalId = null 19 | 20 | constructor(options: SoopChatOptionsWithClient) { 21 | this.options = options 22 | this.options.baseUrls = options.baseUrls ?? DEFAULT_BASE_URLS 23 | this.client = options.client ?? new SoopClient({baseUrls: options.baseUrls}) 24 | } 25 | 26 | private _connected: boolean = false 27 | private _entered: boolean = false 28 | 29 | async connect() { 30 | if (this._connected) { 31 | this.errorHandling('Already connected') 32 | } 33 | 34 | if(this.options.login) { 35 | this.cookie = await this.client.auth.signIn(this.options.login.userId, this.options.login.password); 36 | this.liveDetail = await this.client.live.detail(this.options.streamerId, this.cookie) 37 | } else { 38 | this.liveDetail = await this.client.live.detail(this.options.streamerId) 39 | } 40 | 41 | if (this.liveDetail.CHANNEL.RESULT === 0) { 42 | throw this.errorHandling("Not Streaming now") 43 | } 44 | this.chatUrl = this.makeChatUrl(this.liveDetail) 45 | 46 | this.ws = new WebSocket( 47 | this.chatUrl, 48 | ['chat'], 49 | { agent: this.createAgent() } 50 | ) 51 | 52 | this.ws.onopen = () => { 53 | const CONNECT_PACKET = this.getConnectPacket(); 54 | this.ws.send(CONNECT_PACKET) 55 | } 56 | 57 | this.ws.onmessage = this.handleMessage.bind(this) 58 | this.startPingInterval(); 59 | 60 | this.ws.onclose = () => { 61 | this.disconnect() 62 | } 63 | } 64 | 65 | async disconnect() { 66 | if (!this._connected) { 67 | return 68 | } 69 | const receivedTime = new Date().toISOString(); 70 | this.emit(SoopChatEvent.DISCONNECT, {streamerId: this.options.streamerId, receivedTime: receivedTime}) 71 | this.stopPingInterval() 72 | this.ws?.close() 73 | this.ws = null 74 | this._connected = false 75 | } 76 | 77 | public async sendChat(message: string): Promise { 78 | if (!this.cookie?.AuthTicket) { 79 | this.errorHandling("No Auth"); 80 | return false; 81 | } 82 | if (!this.ws) return false; 83 | if (!this._entered) await this.waitForEnter(); 84 | const packet = `${ChatDelimiter.SEPARATOR.repeat(1)}${message}${ChatDelimiter.SEPARATOR.repeat(1)}0${ChatDelimiter.SEPARATOR.repeat(1)}` 85 | this.ws.send(`${ChatDelimiter.STARTER}${ChatType.CHAT}${this.getPayloadLength(packet)}00${packet}`); 86 | return true; 87 | } 88 | 89 | public on(event: T, handler: (data: Events[T]) => void) { 90 | const e = event as string 91 | this.handlers[e] = this.handlers[e] || [] 92 | this.handlers[e].push(handler) 93 | } 94 | 95 | public emit(event: string, data: any) { 96 | if (this.handlers[event]) { 97 | for (const handler of this.handlers[event]) { 98 | handler(data) 99 | } 100 | } 101 | } 102 | 103 | private async handleMessage(data: MessageEvent) { 104 | const receivedTime = new Date().toISOString(); 105 | const packet = data.data.toString() 106 | this.emit(SoopChatEvent.RAW, Buffer.from(packet)) 107 | 108 | const messageType = this.parseMessageType(packet) 109 | 110 | switch (messageType) { 111 | case ChatType.CONNECT: 112 | this._connected = true 113 | const connect = this.parseConnect(packet) 114 | this.emit(SoopChatEvent.CONNECT, {...connect, streamerId: this.options.streamerId, receivedTime: receivedTime}) 115 | const JOIN_PACKET = this.getJoinPacket(); 116 | this.ws.send(JOIN_PACKET); 117 | break 118 | 119 | case ChatType.ENTER_CHAT_ROOM: 120 | const enterChatRoom = this.parseEnterChatRoom(packet); 121 | this.emit(SoopChatEvent.ENTER_CHAT_ROOM, {...enterChatRoom, receivedTime: receivedTime}) 122 | if(this.cookie?.AuthTicket) { 123 | const ENTER_INFO_PACKET = this.getEnterInfoPacket(enterChatRoom.synAck); 124 | this.ws.send(ENTER_INFO_PACKET); 125 | } 126 | this._entered = true 127 | break 128 | 129 | case ChatType.NOTIFICATION: 130 | const notification = this.parseNotification(packet) 131 | this.emit(SoopChatEvent.NOTIFICATION, {...notification, receivedTime: receivedTime}) 132 | break 133 | 134 | case ChatType.CHAT: 135 | const chat = this.parseChat(packet) 136 | this.emit(SoopChatEvent.CHAT, {...chat, receivedTime: receivedTime}) 137 | break 138 | 139 | case ChatType.VIDEO_DONATION: 140 | const videoDonation = this.parseVideoDonation(packet) 141 | this.emit(SoopChatEvent.VIDEO_DONATION, {...videoDonation, receivedTime: receivedTime}) 142 | break 143 | 144 | case ChatType.TEXT_DONATION: 145 | const textDonation = this.parseTextDonation(packet) 146 | this.emit(SoopChatEvent.TEXT_DONATION, {...textDonation, receivedTime: receivedTime}) 147 | break 148 | 149 | case ChatType.AD_BALLOON_DONATION: 150 | const adBalloonDonation = this.parseAdBalloonDonation(packet) 151 | this.emit(SoopChatEvent.AD_BALLOON_DONATION, {...adBalloonDonation, receivedTime: receivedTime}) 152 | break 153 | 154 | case ChatType.EMOTICON: 155 | const emoticon = this.parseEmoticon(packet) 156 | this.emit(SoopChatEvent.EMOTICON, {...emoticon, receivedTime: receivedTime}) 157 | break 158 | 159 | case ChatType.VIEWER: 160 | const viewer = this.parseViewer(packet) 161 | this.emit(SoopChatEvent.VIEWER, {...viewer, receivedTime: receivedTime}) 162 | break 163 | 164 | case ChatType.SUBSCRIBE: 165 | const subscribe = this.parseSubscribe(packet) 166 | this.emit(SoopChatEvent.SUBSCRIBE, {...subscribe, receivedTime: receivedTime}) 167 | break 168 | 169 | case ChatType.EXIT: 170 | const exit = this.parseExit(packet) 171 | this.emit(SoopChatEvent.EXIT, {...exit, receivedTime: receivedTime}) 172 | break 173 | 174 | case ChatType.DISCONNECT: 175 | await this.disconnect(); 176 | break; 177 | 178 | default: 179 | const parts = packet.split(ChatDelimiter.SEPARATOR); 180 | this.emit(SoopChatEvent.UNKNOWN, parts) 181 | break; 182 | } 183 | } 184 | 185 | private parseConnect(packet: string) { 186 | const parts = packet.split(ChatDelimiter.SEPARATOR); 187 | const [, username, syn] = parts 188 | return {username: username, syn: syn} 189 | } 190 | 191 | private parseEnterChatRoom(packet: string) { 192 | const parts = packet.split(ChatDelimiter.SEPARATOR); 193 | const [, , streamerId, , , , , synAck] = parts 194 | return {streamerId: streamerId, synAck: synAck} 195 | } 196 | 197 | private parseSubscribe(packet: string) { 198 | const parts = packet.split(ChatDelimiter.SEPARATOR); 199 | const [, to, from, fromUsername, amount, , , , tier] = parts 200 | return {to: to, from: from, fromUsername: fromUsername, amount: amount, tier: tier} 201 | } 202 | 203 | private parseAdBalloonDonation(packet: string) { 204 | const parts = packet.split(ChatDelimiter.SEPARATOR); 205 | const [, , to, from, fromUsername, , , , , , amount, fanClubOrdinal] = parts 206 | return {to: to, from: from, fromUsername: fromUsername, amount: amount, fanClubOrdinal: fanClubOrdinal} 207 | } 208 | 209 | private parseVideoDonation(packet: string) { 210 | const parts = packet.split(ChatDelimiter.SEPARATOR); 211 | const [, , to, from, fromUsername, amount, fanClubOrdinal] = parts 212 | return {to: to, from: from, fromUsername: fromUsername, amount: amount, fanClubOrdinal: fanClubOrdinal} 213 | } 214 | 215 | private parseViewer(packet: string) { 216 | const parts = packet.split(ChatDelimiter.SEPARATOR); 217 | if (parts.length > 4) { 218 | return { userId: this.getViewerElements(parts) } 219 | } else { 220 | const [, userId] = parts 221 | return { userId: [ userId ] } 222 | } 223 | } 224 | 225 | private parseExit(packet: string) { 226 | const parts = packet.split(ChatDelimiter.SEPARATOR); 227 | const [, , userId, username] = parts 228 | return {userId: userId, username: username} 229 | } 230 | 231 | private parseEmoticon(packet: string) { 232 | const parts = packet.split(ChatDelimiter.SEPARATOR); 233 | const [, , , emoticonId, , , userId, username] = parts 234 | return {userId: userId, username: username, emoticonId: emoticonId} 235 | } 236 | 237 | private parseTextDonation(packet: string) { 238 | const parts = packet.split(ChatDelimiter.SEPARATOR); 239 | const [, to, from, fromUsername, amount, fanClubOrdinal] = parts 240 | return {to: to, from: from, fromUsername: fromUsername, amount: amount, fanClubOrdinal: fanClubOrdinal} 241 | } 242 | 243 | private parseNotification(packet: string) { 244 | const parts = packet.split(ChatDelimiter.SEPARATOR); 245 | const [, , , , notification] = parts 246 | return {notification: notification} 247 | } 248 | 249 | private parseChat(packet: string) { 250 | const parts = packet.split(ChatDelimiter.SEPARATOR); 251 | const [, comment, userId, , , , username] = parts; 252 | return {userId: userId, comment: comment, username: username}; 253 | } 254 | 255 | private parseMessageType(packet: string): string { 256 | if(!packet.startsWith(ChatDelimiter.STARTER)) { 257 | throw this.errorHandling("Invalid data: does not start with STARTER byte"); 258 | } 259 | if (packet.length >= 5) { 260 | return packet.substring(2, 6); 261 | } 262 | throw this.errorHandling("Invalid data: does not have any data for opcode"); 263 | } 264 | 265 | private startPingInterval() { 266 | if (this.pingIntervalId) { 267 | clearInterval(this.pingIntervalId); 268 | } 269 | 270 | this.pingIntervalId = setInterval(() => this.sendPing(), 60000); 271 | } 272 | 273 | private stopPingInterval() { 274 | if (this.pingIntervalId) { 275 | clearInterval(this.pingIntervalId); 276 | this.pingIntervalId = null; 277 | } 278 | } 279 | 280 | private sendPing() { 281 | if (!this.ws) return; 282 | const packet = this.getPacket(ChatType.PING, ChatDelimiter.SEPARATOR); 283 | this.ws.send(packet); 284 | } 285 | 286 | private makeChatUrl(detail: LiveDetail): string { 287 | return `wss://${detail.CHANNEL.CHDOMAIN.toLowerCase()}:${Number(detail.CHANNEL.CHPT) + 1}/Websocket/${this.options.streamerId}` 288 | } 289 | 290 | private getByteSize(input: string): number { 291 | return Buffer.byteLength(input, 'utf-8'); 292 | } 293 | 294 | private getPayloadLength(packet: string): string { 295 | return this.getByteSize(packet).toString().padStart(6, '0'); 296 | } 297 | 298 | private createAgent(): Agent { 299 | const options: SecureContextOptions = {}; 300 | const secureContext = createSecureContext(options); 301 | return new Agent({ 302 | secureContext, 303 | rejectUnauthorized: false 304 | }) 305 | } 306 | 307 | private getViewerElements(array: T[]): T[] { 308 | return array.filter((_, index) => index % 2 === 1); 309 | } 310 | 311 | private getConnectPacket(): string { 312 | let payload = `${ChatDelimiter.SEPARATOR.repeat(3)}16${ChatDelimiter.SEPARATOR}`; 313 | if(this.cookie?.AuthTicket) { 314 | payload = `${ChatDelimiter.SEPARATOR.repeat(1)}${this.cookie?.AuthTicket}${ChatDelimiter.SEPARATOR.repeat(2)}16${ChatDelimiter.SEPARATOR}` 315 | } 316 | return this.getPacket(ChatType.CONNECT, payload); 317 | } 318 | 319 | private getJoinPacket(): string { 320 | let payload = `${ChatDelimiter.SEPARATOR}${this.liveDetail.CHANNEL.CHATNO}`; 321 | 322 | if(this.cookie) { 323 | payload += `${ChatDelimiter.SEPARATOR}${this.liveDetail.CHANNEL.FTK}`; 324 | payload += `${ChatDelimiter.SEPARATOR}0${ChatDelimiter.SEPARATOR}` 325 | const log = { 326 | set_bps: this.liveDetail.CHANNEL.BPS, 327 | view_bps: this.liveDetail.CHANNEL.VIEWPRESET[0].bps, 328 | quality: 'normal', 329 | uuid: this.cookie._au, 330 | geo_cc: this.liveDetail.CHANNEL.geo_cc, 331 | geo_rc: this.liveDetail.CHANNEL.geo_rc, 332 | acpt_lang: this.liveDetail.CHANNEL.acpt_lang, 333 | svc_lang: this.liveDetail.CHANNEL.svc_lang, 334 | subscribe: 0, 335 | lowlatency: 0, 336 | mode: "landing" 337 | } 338 | const query = this.objectToQueryString(log) 339 | payload += `log${ChatDelimiter.ELEMENT_START}${query}${ChatDelimiter.ELEMENT_END}` 340 | payload += `pwd${ChatDelimiter.ELEMENT_START}${ChatDelimiter.ELEMENT_END}` 341 | payload += `auth_info${ChatDelimiter.ELEMENT_START}NULL${ChatDelimiter.ELEMENT_END}` 342 | payload += `pver${ChatDelimiter.ELEMENT_START}2${ChatDelimiter.ELEMENT_END}` 343 | payload += `access_system${ChatDelimiter.ELEMENT_START}html5${ChatDelimiter.ELEMENT_END}` 344 | payload += `${ChatDelimiter.SEPARATOR}` 345 | } else { 346 | payload += `${ChatDelimiter.SEPARATOR.repeat(5)}` 347 | } 348 | return this.getPacket(ChatType.ENTER_CHAT_ROOM, payload); 349 | } 350 | 351 | private getEnterInfoPacket(synAck: string): string { 352 | const payload = `${ChatDelimiter.SEPARATOR}${synAck}${ChatDelimiter.SEPARATOR}0${ChatDelimiter.SEPARATOR}` 353 | return this.getPacket(ChatType.ENTER_INFO, payload); 354 | } 355 | 356 | private getPacket(chatType: ChatType, payload: string): string { 357 | const packetHeader = `${ChatDelimiter.STARTER}${chatType}${this.getPayloadLength(payload)}00`; 358 | return packetHeader + payload; 359 | } 360 | 361 | private waitForEnter(): Promise { 362 | return new Promise((resolve) => { 363 | if (this._entered) { 364 | resolve(); 365 | return; 366 | } 367 | const checkInterval = setInterval(() => { 368 | if (this._entered) { 369 | clearInterval(checkInterval); 370 | resolve(); 371 | } 372 | }, 500); 373 | }); 374 | } 375 | 376 | private errorHandling(message: string): Error { 377 | const error = new Error(message); 378 | console.error(error); 379 | return error; 380 | } 381 | 382 | private objectToQueryString(obj: Record): string { 383 | return Object.entries(obj) 384 | .map(([key, value]) => `${ChatDelimiter.SPACE}&${ChatDelimiter.SPACE}${key}${ChatDelimiter.SPACE}=${ChatDelimiter.SPACE}${value}`) 385 | .join(""); 386 | } 387 | } 388 | --------------------------------------------------------------------------------