├── .gitignore ├── unavailable ├── unavailable.sh ├── unavailable.lftp ├── mirror.lftp ├── .lftp ├── mirror.sh ├── www ├── api │ └── .htaccess └── .htaccess ├── package.json ├── urls.txt ├── channels.json └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /unavailable: -------------------------------------------------------------------------------- 1 | unavailable 2 | -------------------------------------------------------------------------------- /unavailable.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | cd $(dirname $0) 4 | lftp -f unavailable.lftp 5 | -------------------------------------------------------------------------------- /unavailable.lftp: -------------------------------------------------------------------------------- 1 | source .lftp 2 | open namami.sakura.ne.jp 3 | put unavailable -o www/unavailable 4 | close 5 | quit 6 | -------------------------------------------------------------------------------- /mirror.lftp: -------------------------------------------------------------------------------- 1 | source .lftp 2 | open namami.sakura.ne.jp 3 | mirror --reverse --delete --parallel=20 www www 4 | close 5 | quit 6 | -------------------------------------------------------------------------------- /.lftp: -------------------------------------------------------------------------------- 1 | set net:max-retries 1 2 | set ftp:ssl-auth TLS 3 | set ftp:ssl-force true 4 | set ftp:ssl-allow yes 5 | set ftp:ssl-protect-list yes 6 | set ftp:ssl-protect-data yes 7 | set ftp:ssl-protect-fxp yes 8 | -------------------------------------------------------------------------------- /mirror.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | cd $(dirname $0) 4 | wget --quiet --tries=10 --waitretry=0.5 --retry-connrefused --output-document=/dev/null $(head -n 1 urls.txt) 5 | wget --quiet --directory-prefix=www --mirror --no-host-directories --input-file=urls.txt 6 | lftp -f mirror.lftp 7 | -------------------------------------------------------------------------------- /www/api/.htaccess: -------------------------------------------------------------------------------- 1 | ForceType "application/xml; charset=UTF-8" 2 | 3 | 4 | ForceType "text/html; charset=UTF-8" 5 | 6 | 7 | ForceType "text/plain; charset=UTF-8" 8 | 9 | 10 | ForceType "text/plain; charset=UTF-8" 11 | 12 | -------------------------------------------------------------------------------- /www/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine On 2 | 3 | RewriteCond %{DOCUMENT_ROOT}/unavailable -f 4 | RewriteRule ^.*$ /unavailable [R=503,L] 5 | 6 | RewriteCond %{QUERY_STRING} ^(.+)$ 7 | RewriteRule ^(.+)$ $1\%3F%1? 8 | 9 | RewriteRule ^$ /tv 10 | RewriteRule ^api/v2/(.+)$ /api/v2_app/$1 [L] 11 | 12 | ForceType "text/html; charset=UTF-8" 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "namami", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "dependencies": { 7 | "ws": "^8.17.0" 8 | }, 9 | "devDependencies": { 10 | "pkg": "^5.8.1" 11 | }, 12 | "scripts": { 13 | "test": "echo \"Error: no test specified\" && exit 1", 14 | "pkg": "pkg ." 15 | }, 16 | "bin": { 17 | "namami": "index.js" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/asannou/namami.git" 22 | }, 23 | "author": "asannou", 24 | "license": "ISC", 25 | "bugs": { 26 | "url": "https://github.com/asannou/namami/issues" 27 | }, 28 | "homepage": "https://github.com/asannou/namami#readme" 29 | } 30 | -------------------------------------------------------------------------------- /urls.txt: -------------------------------------------------------------------------------- 1 | http://127.0.0.1:8000/tv 2 | http://127.0.0.1:8000/radio 3 | http://127.0.0.1:8000/bs 4 | http://127.0.0.1:8000/api/v2_app/getapplicationversion 5 | http://127.0.0.1:8000/api/v2_app/session.create 6 | http://127.0.0.1:8000/api/v2_app/session.destroy 7 | http://127.0.0.1:8000/api/v2_app/ng.client 8 | http://127.0.0.1:8000/api/v2_app/ng.owner 9 | http://127.0.0.1:8000/api/v2_app/getchannels 10 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1 11 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk2 12 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk4 13 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk5 14 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk6 15 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk7 16 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk8 17 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk9 18 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk10 19 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk11 20 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk12 21 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk594 22 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk693 23 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk825 24 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk792 25 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1287 26 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1440 27 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk3925 28 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk761 29 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk800 30 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk813 31 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk954 32 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1134 33 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1197 34 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1242 35 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk764 36 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk863 37 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk780 38 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk795 39 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk847 40 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1422 41 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk778 42 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk807 43 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1053 44 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1053 45 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1431 46 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk789 47 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk765 48 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk802 49 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk851 50 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk899 51 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1008 52 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1179 53 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1314 54 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1143 55 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk558 56 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1557 57 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk808 58 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk827 59 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1278 60 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1413 61 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk101 62 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk103 63 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk141 64 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk151 65 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk161 66 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk171 67 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk181 68 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk191 69 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk192 70 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk193 71 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk200 72 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk201 73 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk202 74 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk211 75 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk222 76 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk231 77 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk234 78 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk236 79 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk238 80 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk241 81 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk242 82 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk243 83 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk244 84 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk245 85 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk251 86 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk252 87 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk255 88 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk256 89 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk258 90 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk910 91 | http://127.0.0.1:8000/api/v2_app/getpostkey 92 | -------------------------------------------------------------------------------- /channels.json: -------------------------------------------------------------------------------- 1 | { 2 | "jk1": { 3 | "thread_id": 0, 4 | "name": "NHK 総合", 5 | "tags": ["NHK総合"] 6 | }, 7 | "jk2": { 8 | "thread_id": 1, 9 | "name": "Eテレ", 10 | "tags": ["NHK_Eテレ"] 11 | }, 12 | "jk4": { 13 | "thread_id": 2, 14 | "name": "日本テレビ", 15 | "tags": ["日本テレビ"] 16 | }, 17 | "jk5": { 18 | "thread_id": 3, 19 | "name": "テレビ朝日", 20 | "tags": ["テレビ朝日"] 21 | }, 22 | "jk6": { 23 | "thread_id": 4, 24 | "name": "TBS テレビ", 25 | "tags": ["TBSテレビ"] 26 | }, 27 | "jk7": { 28 | "thread_id": 5, 29 | "name": "テレビ東京", 30 | "tags": ["テレビ東京"] 31 | }, 32 | "jk8": { 33 | "thread_id": 6, 34 | "name": "フジテレビ", 35 | "tags": ["フジテレビ"] 36 | }, 37 | "jk9": { 38 | "thread_id": 7, 39 | "name": "TOKYO MX", 40 | "tags": ["TOKYO_MX"] 41 | }, 42 | "jk10": { 43 | "thread_id": 8, 44 | "name": "テレ玉", 45 | "tags": ["テレ玉"], 46 | "communities": ["co5253063"] 47 | }, 48 | "jk11": { 49 | "thread_id": 9, 50 | "name": "tvk", 51 | "tags": ["tvk"], 52 | "communities": ["co5215296"] 53 | }, 54 | "jk12": { 55 | "thread_id": 10, 56 | "name": "チバテレビ", 57 | "tags": ["チバテレビ"] 58 | }, 59 | "jk594": { 60 | "id": 1594, 61 | "radiko_id": "r1", 62 | "thread_id": 11, 63 | "name": "NHKラジオ第1" 64 | }, 65 | "jk693": { 66 | "id": 1693, 67 | "radiko_id": "r2", 68 | "thread_id": 12, 69 | "name": "NHKラジオ第2" 70 | }, 71 | "jk825": { 72 | "id": 1825, 73 | "radiko_id": "fm", 74 | "thread_id": 13, 75 | "name": "NHK-FM" 76 | }, 77 | "jk792": { 78 | "id": 10792, 79 | "radiko_id": "AIR-G", 80 | "thread_id": 14, 81 | "name": "AIR-G'" 82 | }, 83 | "jk1287": { 84 | "id": 11287, 85 | "radiko_id": "HBC", 86 | "thread_id": 15, 87 | "name": "HBCラジオ" 88 | }, 89 | "jk1440": { 90 | "id": 11440, 91 | "radiko_id": "STV", 92 | "thread_id": 16, 93 | "name": "STVラジオ" 94 | }, 95 | "jk3925": { 96 | "id": 13925, 97 | "radiko_id": "NSB", 98 | "thread_id": 17, 99 | "name": "ラジオNIKKEI第1放送" 100 | }, 101 | "jk761": { 102 | "id": 80761, 103 | "radiko_id": "INT", 104 | "thread_id": 18, 105 | "name": "Inter FM" 106 | }, 107 | "jk800": { 108 | "id": 80800, 109 | "radiko_id": "FMT", 110 | "thread_id": 19, 111 | "name": "TOKYO FM" 112 | }, 113 | "jk813": { 114 | "id": 80813, 115 | "radiko_id": "FMJ", 116 | "thread_id": 20, 117 | "name": "J-WAVE" 118 | }, 119 | "jk954": { 120 | "id": 80954, 121 | "radiko_id": "TBS", 122 | "thread_id": 21, 123 | "name": "TBSラジオ" 124 | }, 125 | "jk1134": { 126 | "id": 81134, 127 | "radiko_id": "QRR", 128 | "thread_id": 22, 129 | "name": "文化放送" 130 | }, 131 | "jk1197": { 132 | "id": 81197, 133 | "radiko_id": "IBS", 134 | "thread_id": 23, 135 | "name": "茨城放送" 136 | }, 137 | "jk1242": { 138 | "id": 81242, 139 | "radiko_id": "LFR", 140 | "thread_id": 24, 141 | "name": "ニッポン放送" 142 | }, 143 | "jk764": { 144 | "id": 90764, 145 | "radiko_id": "RADIOBERRY", 146 | "thread_id": 25, 147 | "name": "RADIO BERRY" 148 | }, 149 | "jk863": { 150 | "id": 100863, 151 | "radiko_id": "FMGUNMA", 152 | "thread_id": 26, 153 | "name": "FMぐんま" 154 | }, 155 | "jk780": { 156 | "id": 110780, 157 | "radiko_id": "BAYFM78", 158 | "thread_id": 27, 159 | "name": "bayfm" 160 | }, 161 | "jk795": { 162 | "id": 110795, 163 | "radiko_id": "NACK5", 164 | "thread_id": 28, 165 | "name": "NACK5" 166 | }, 167 | "jk847": { 168 | "id": 110847, 169 | "radiko_id": "YFM", 170 | "thread_id": 29, 171 | "name": "FMヨコハマ" 172 | }, 173 | "jk1422": { 174 | "id": 111422, 175 | "radiko_id": "JORF", 176 | "thread_id": 30, 177 | "name": "ラジオ日本" 178 | }, 179 | "jk778": { 180 | "id": 210778, 181 | "radiko_id": "ZIP-FM", 182 | "thread_id": 31, 183 | "name": "ZIP-FM" 184 | }, 185 | "jk807": { 186 | "id": 210807, 187 | "radiko_id": "FMAICHI", 188 | "thread_id": 32, 189 | "name": "FM AICHI" 190 | }, 191 | "jk1053": { 192 | "id": 211053, 193 | "radiko_id": "CBC", 194 | "thread_id": 33, 195 | "name": "CBCラジオ" 196 | }, 197 | "jk1053": { 198 | "id": 211332, 199 | "radiko_id": "TOKAIRADIO", 200 | "thread_id": 34, 201 | "name": "東海ラジオ" 202 | }, 203 | "jk1431": { 204 | "id": 211431, 205 | "radiko_id": "GBS", 206 | "thread_id": 35, 207 | "name": "ぎふチャン" 208 | }, 209 | "jk789": { 210 | "id": 240789, 211 | "radiko_id": "FMMIE", 212 | "thread_id": 36, 213 | "name": "radio CUBE FM三重" 214 | }, 215 | "jk765": { 216 | "id": 250765, 217 | "radiko_id": "CCL", 218 | "thread_id": 37, 219 | "name": "FM COCOLO" 220 | }, 221 | "jk802": { 222 | "id": 250802, 223 | "radiko_id": "802", 224 | "thread_id": 38, 225 | "name": "FM802" 226 | }, 227 | "jk851": { 228 | "id": 250851, 229 | "radiko_id": "FMO", 230 | "thread_id": 39, 231 | "name": "FM OSAKA" 232 | }, 233 | "jk899": { 234 | "id": 250899, 235 | "radiko_id": "KISSFMKOBE", 236 | "thread_id": 40, 237 | "name": "Kiss FM KOBE" 238 | }, 239 | "jk1008": { 240 | "id": 251008, 241 | "radiko_id": "ABC", 242 | "thread_id": 41, 243 | "name": "朝日放送" 244 | }, 245 | "jk1179": { 246 | "id": 251179, 247 | "radiko_id": "MBS", 248 | "thread_id": 42, 249 | "name": "毎日放送" 250 | }, 251 | "jk1314": { 252 | "id": 251314, 253 | "radiko_id": "OBC", 254 | "thread_id": 43, 255 | "name": "ラジオ大阪" 256 | }, 257 | "jk1143": { 258 | "id": 261143, 259 | "radiko_id": "KBS", 260 | "thread_id": 44, 261 | "name": "KBS京都" 262 | }, 263 | "jk558": { 264 | "id": 280558, 265 | "radiko_id": "CRK", 266 | "thread_id": 45, 267 | "name": "ラジオ関西" 268 | }, 269 | "jk1557": { 270 | "id": 301557, 271 | "radiko_id": "WBS", 272 | "thread_id": 46, 273 | "name": "和歌山放送" 274 | }, 275 | "jk808": { 276 | "id": 400808, 277 | "radiko_id": "FMFUKUOKA", 278 | "thread_id": 47, 279 | "name": "FM FUKUOKA" 280 | }, 281 | "jk827": { 282 | "id": 400827, 283 | "radiko_id": "LOVEFM", 284 | "thread_id": 48, 285 | "name": "Love FM" 286 | }, 287 | "jk1278": { 288 | "id": 401278, 289 | "radiko_id": "RKB", 290 | "thread_id": 49, 291 | "name": "RKBラジオ" 292 | }, 293 | "jk1413": { 294 | "id": 401413, 295 | "radiko_id": "KBC", 296 | "thread_id": 50, 297 | "name": "九州朝日放送" 298 | }, 299 | "jk101": { 300 | "thread_id": 51, 301 | "bs": true, 302 | "name": "NHKBS-1", 303 | "tags": ["NHK_BS1", "NHKBS-1"] 304 | }, 305 | "jk103": { 306 | "thread_id": 52, 307 | "bs": true, 308 | "name": "NHK BSプレミアム", 309 | "tags": ["NHK_BSプレミアム", "NHKBSプレミアム", "BSプレミアム"], 310 | "communities": ["co5175227"] 311 | }, 312 | "jk141": { 313 | "thread_id": 53, 314 | "bs": true, 315 | "name": "BS 日テレ", 316 | "tags": ["BS_日テレ", "BS日テレ"], 317 | "communities": ["co5175341"] 318 | }, 319 | "jk151": { 320 | "thread_id": 54, 321 | "bs": true, 322 | "name": "BS 朝日", 323 | "tags": ["BS_朝日", "BS朝日"], 324 | "communities": ["co5175345"] 325 | }, 326 | "jk161": { 327 | "thread_id": 55, 328 | "bs": true, 329 | "name": "BS-TBS", 330 | "tags": ["BS-TBS", "BSTBS"], 331 | "communities": ["co5176119"] 332 | }, 333 | "jk171": { 334 | "thread_id": 56, 335 | "bs": true, 336 | "name": "BSジャパン", 337 | "tags": ["BSジャパン", "BSテレ東"], 338 | "communities": ["co5176122"] 339 | }, 340 | "jk181": { 341 | "thread_id": 57, 342 | "bs": true, 343 | "name": "BSフジ", 344 | "tags": ["BSフジ"], 345 | "communities": ["co5176125"] 346 | }, 347 | "jk191": { 348 | "thread_id": 58, 349 | "bs": true, 350 | "name": "WOWOWプライム", 351 | "tags": ["WOWOW_PRIME"], 352 | "communities": ["co5251972"] 353 | }, 354 | "jk192": { 355 | "thread_id": 59, 356 | "bs": true, 357 | "name": "WOWOWライブ", 358 | "tags": ["WOWOW_LIVE"], 359 | "communities": ["co5251976"] 360 | }, 361 | "jk193": { 362 | "thread_id": 60, 363 | "bs": true, 364 | "name": "WOWOWシネマ", 365 | "tags": ["WOWOW_CINEMA"], 366 | "communities": ["co5251983"] 367 | }, 368 | "jk200": { 369 | "thread_id": 61, 370 | "bs": true, 371 | "name": "スターチャンネル1" 372 | }, 373 | "jk201": { 374 | "thread_id": 62, 375 | "bs": true, 376 | "name": "スターチャンネル2" 377 | }, 378 | "jk202": { 379 | "thread_id": 63, 380 | "bs": true, 381 | "name": "スターチャンネル3" 382 | }, 383 | "jk211": { 384 | "thread_id": 64, 385 | "bs": true, 386 | "name": "BSイレブン", 387 | "tags": ["BS11"] 388 | }, 389 | "jk222": { 390 | "thread_id": 65, 391 | "bs": true, 392 | "name": "TwellV", 393 | "tags": ["TwellV", "BS12"], 394 | "communities": ["co5193029"] 395 | }, 396 | "jk231": { 397 | "thread_id": 66, 398 | "bs": true, 399 | "name": "放送大学" 400 | }, 401 | "jk234": { 402 | "thread_id": 67, 403 | "bs": true, 404 | "name": "BSグリーンチャンネル", 405 | "tags": ["BSグリーンチャンネル", "グリーンチャンネル"], 406 | "communities": ["co5217651"] 407 | }, 408 | "jk236": { 409 | "thread_id": 68, 410 | "bs": true, 411 | "name": "BSアニマックス" 412 | }, 413 | "jk238": { 414 | "thread_id": 69, 415 | "bs": true, 416 | "name": "FOX bs 238" 417 | }, 418 | "jk241": { 419 | "thread_id": 70, 420 | "bs": true, 421 | "name": "BSスカパー!" 422 | }, 423 | "jk242": { 424 | "thread_id": 71, 425 | "bs": true, 426 | "name": "J Sports 1" 427 | }, 428 | "jk243": { 429 | "thread_id": 72, 430 | "bs": true, 431 | "name": "J Sports 2" 432 | }, 433 | "jk244": { 434 | "thread_id": 73, 435 | "bs": true, 436 | "name": "J Sports 3" 437 | }, 438 | "jk245": { 439 | "thread_id": 74, 440 | "bs": true, 441 | "name": "J Sports 4" 442 | }, 443 | "jk251": { 444 | "thread_id": 75, 445 | "bs": true, 446 | "name": "BS釣りビジョン" 447 | }, 448 | "jk252": { 449 | "thread_id": 76, 450 | "bs": true, 451 | "name": "IMAGICA BS", 452 | "communities": ["co5683458"] 453 | }, 454 | "jk255": { 455 | "thread_id": 77, 456 | "bs": true, 457 | "name": "BS日本映画専門チャンネル" 458 | }, 459 | "jk256": { 460 | "thread_id": 78, 461 | "bs": true, 462 | "name": "ディズニー・チャンネル" 463 | }, 464 | "jk258": { 465 | "thread_id": 79, 466 | "bs": true, 467 | "name": "Dlife" 468 | }, 469 | "jk260": { 470 | "thread_id": 80, 471 | "bs": true, 472 | "name": "BS松竹東急", 473 | "communities": ["co5682554"] 474 | }, 475 | "jk263": { 476 | "thread_id": 81, 477 | "bs": true, 478 | "name": "BSJapanext", 479 | "communities": ["co5682551"] 480 | }, 481 | "jk265": { 482 | "thread_id": 82, 483 | "bs": true, 484 | "name": "BSよしもと", 485 | "communities": ["co5682548"] 486 | }, 487 | "jk333": { 488 | "thread_id": 83, 489 | "bs": true, 490 | "name": "AT-X", 491 | "tags": ["AT-X"], 492 | "communities": ["co5245469"] 493 | }, 494 | "jk910": { 495 | "thread_id": 84, 496 | "bs": true, 497 | "name": "SOLiVE24" 498 | } 499 | } 500 | 501 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const process = require('process'); 4 | const { URL, URLSearchParams } = require('url'); 5 | const { Resolver } = require('dns').promises; 6 | const http = require('http'); 7 | const https = require('https'); 8 | const net = require('net'); 9 | const { PassThrough, Transform } = require('stream'); 10 | const WebSocket = require('ws'); 11 | const channels = require('./channels.json'); 12 | const { debug } = console; 13 | 14 | const port = process.argv[3] ?? 80; 15 | const ms_port = 2525; 16 | const ms_address = process.argv[2] ?? '127.0.0.1' 17 | const http_port = port; 18 | const force_delay = 20; 19 | 20 | const lives = {}; 21 | const threads = {}; 22 | 23 | let local_time, server_time; 24 | 25 | function getServerTime() { 26 | if (server_time) { 27 | const elapsed_time = getNow() - local_time; 28 | return Math.round(server_time + elapsed_time); 29 | } else { 30 | return Math.round(getNow()); 31 | } 32 | } 33 | 34 | function getNow() { 35 | return Date.now() / 1000; 36 | } 37 | 38 | function getBaseTime(unix_time = getServerTime()) { 39 | const hour = 60 * 60; 40 | const day = hour * 24; 41 | const timezone = hour * 9; 42 | const offset = timezone - (hour * 4); 43 | return Math.floor((unix_time + offset) / day) * day - offset + 1; 44 | } 45 | 46 | function waitSeconds(delay) { 47 | return new Promise((resolve) => setTimeout(resolve, delay * 1000)); 48 | } 49 | 50 | async function httpRequest(url, options) { 51 | return new Promise((resolve, reject) => { 52 | const { protocol } = new URL(url); 53 | const module = protocol == 'https:' ? https : http; 54 | const req = module.request(url, { 55 | ...options, 56 | headers: { 57 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', 58 | ...options.headers 59 | } 60 | }, resolve); 61 | req.on('error', reject); 62 | req.end(); 63 | }); 64 | } 65 | 66 | function httpGet(url, options) { 67 | debug('get', url.toString()); 68 | return httpRequest(url, { method: 'GET', ...options }); 69 | } 70 | 71 | function slurpMessage(message) { 72 | return new Promise((resolve) => { 73 | let data = ''; 74 | message.on('data', (chunk) => data += chunk); 75 | message.on('end', () => resolve(data)); 76 | }); 77 | } 78 | 79 | async function searchLives(tags, status) { 80 | const url = new URL('https://live.nicovideo.jp/search'); 81 | url.search = new URLSearchParams({ 82 | status: status, 83 | sortOrder: 'recentDesc', 84 | isTagSearch: 'true', 85 | keyword: tags.join(' ') 86 | }); 87 | try { 88 | const { searchResult: { programs } } = await getEmbeddedData(url); 89 | return programs[status].map((program) => program.id); 90 | } catch (error) { 91 | return []; 92 | } 93 | } 94 | 95 | async function getEmbeddedData(url) { 96 | const res = await httpGet(url); 97 | const data = await slurpMessage(res); 98 | const re = /id="embedded-data" data-props="([^"]+)/; 99 | const embedded_data = data.match(re); 100 | if (embedded_data) { 101 | const [, data_props] = embedded_data; 102 | return JSON.parse(data_props.replace(/"/g, '"')); 103 | } 104 | } 105 | 106 | function createWebSocket(webSocketUrl, video) { 107 | return new Promise((resolve, reject) => { 108 | const ws = new WebSocket( 109 | webSocketUrl, 110 | [], 111 | { headers: { 'User-Agent': 'Mozilla/5.0' } } 112 | ); 113 | let keep_seat; 114 | ws.on('open', () => { 115 | debug('ws:open', ws._socket.remoteAddress); 116 | sendStartWatching(ws); 117 | }) 118 | ws.on('message', (data) => { 119 | const message = JSON.parse(data); 120 | //debug('ws:message', ws._socket.remoteAddress, message); 121 | switch (message.type) { 122 | case 'ping': { 123 | ws.send(JSON.stringify({ "type": "pong" })); 124 | break; 125 | } 126 | case 'serverTime': { 127 | local_time = getNow(); 128 | const current_ms = new Date(message.data.currentMs); 129 | server_time = current_ms.getTime() / 1000; 130 | break; 131 | } 132 | case 'seat': { 133 | keep_seat = setInterval( 134 | () => ws.send(JSON.stringify({ "type": "keepSeat" })), 135 | message.data.keepIntervalSec * 1000 136 | ); 137 | break; 138 | } 139 | case 'room': { 140 | resolve({ room: message, ws }); 141 | break; 142 | } 143 | case 'statistics': { 144 | for (const thread of Object.values(threads)) { 145 | for (const live of thread.lives) { 146 | if (live === video) { 147 | thread.viewers = message.data.viewers; 148 | thread.comments = message.data.comments; 149 | } 150 | } 151 | } 152 | break; 153 | } 154 | } 155 | }); 156 | ws.on('close', () => { 157 | debug('ws:close', ws._socket.remoteAddress); 158 | clearInterval(keep_seat); 159 | }); 160 | ws.on('error', () => { 161 | debug('ws:error', ws._socket.remoteAddress); 162 | reject(); 163 | }); 164 | }); 165 | } 166 | 167 | function sendStartWatching(ws) { 168 | return ws.send(JSON.stringify( 169 | { 170 | "type": "startWatching", 171 | "data": { 172 | "stream": { 173 | "quality": "high", 174 | "protocol": "hls", 175 | "latency": "high", 176 | "chasePlay": false 177 | }, 178 | "room": { 179 | "protocol": "webSocket", 180 | "commentable": true 181 | }, 182 | "reconnect": false 183 | } 184 | } 185 | )); 186 | } 187 | 188 | function createMessageWebSocket(room, revisionCheckIntervalMs) { 189 | const { data } = room; 190 | const mws = new WebSocket(data.messageServer.uri); 191 | let interval; 192 | mws.on('open', () => { 193 | debug('mws:open', mws._socket.remoteAddress); 194 | mws.send(JSON.stringify( 195 | [ 196 | { "ping": { "content": "rs:0" } }, 197 | { "ping": { "content": "ps:0" } }, 198 | { 199 | "thread": { 200 | "thread": data.threadId, 201 | "version": "20061206", 202 | "user_id": "guest", 203 | "res_from": 0, 204 | "with_global": 1, 205 | "scores": 1, 206 | "nicoru": 0 207 | } 208 | }, 209 | { "ping": { "content": "pf:0" } }, 210 | { "ping": { "content": "rf:0" } } 211 | ] 212 | )); 213 | interval = setInterval(() => mws.send(''), revisionCheckIntervalMs); 214 | }); 215 | mws.on('close', () => { 216 | debug('mws:close', mws._socket.remoteAddress); 217 | clearInterval(interval); 218 | }); 219 | mws.on('error', (error) => { 220 | debug('mws:error', mws._socket?.remoteAddress); 221 | throw error; 222 | }); 223 | return mws; 224 | } 225 | 226 | function createHttpServer() { 227 | const handlers = { 228 | '': handleIndex, 229 | 'tv': handleIndex, 230 | 'radio': handleIndex, 231 | 'bs': handleIndex, 232 | 'watch': handleWatch, 233 | 'api': { 234 | 'v2_app': { 235 | 'getapplicationversion': handleGetApplicationVersion, 236 | 'session.create': handleSessionCreate, 237 | 'session.destroy': handleSessionDestroy, 238 | 'ng.client': handleNg, 239 | 'ng.owner': handleNg, 240 | 'getchannels': handleGetChannels, 241 | 'getflv': handleGetFlv, 242 | 'getpostkey': handleGetPostkey 243 | }, 244 | 'v2': { 245 | 'getchannels': handleGetChannels, 246 | 'getflv': handleGetFlv, 247 | 'getpostkey': handleGetPostkey, 248 | 'getwaybackkey': handleGetWaybackkey 249 | }, 250 | 'getpostkey': handleGetPostkey, 251 | 'thread': handleGetThread 252 | } 253 | } 254 | const server = http.createServer(async (req, res) => { 255 | const url = new URL(req.url, `http://${req.headers.host}`); 256 | const data = await slurpMessage(req); 257 | if (data) url.search = data; 258 | //debug('req', res.socket.remoteAddress, req.method, url.toString()); 259 | //debug(req.headers, data); 260 | const paths = url.pathname.substring(1).split('/'); 261 | const AsyncFunction = (async () => {}).constructor; 262 | let handler = handlers; 263 | for (const path of paths) { 264 | handler = handler[path]; 265 | if (typeof handler == 'function') { 266 | if (handler instanceof AsyncFunction) { 267 | await handler({ url, paths, res }); 268 | } else { 269 | handler({ url, paths, res }); 270 | } 271 | res.end(); 272 | return; 273 | } else if (!handler) { 274 | break; 275 | } 276 | } 277 | res.writeHead(404); 278 | res.end(); 279 | }); 280 | server.listen(port); 281 | debug('listen', port); 282 | return server; 283 | } 284 | 285 | function handleGetApplicationVersion({ res }) { 286 | res.setHeader('Content-Type', 'application/xml; charset=UTF-8'); 287 | res.write('\n'); 288 | res.write('\n'); 289 | res.write(' windows\n'); 290 | res.write(' 2012-02-09 14:50:59\n'); 291 | res.write(' \n'); 292 | res.write(' 1\n'); 293 | res.write(' 2\n'); 294 | res.write(' 1\n'); 295 | res.write(' 1516\n'); 296 | res.write(' \n'); 297 | res.write('\n'); 298 | } 299 | 300 | function handleGetChannels({ res }) { 301 | res.setHeader('Content-Type', 'application/xml; charset=UTF-8'); 302 | res.write('\n'); 303 | for (const video of Object.keys(channels)) { 304 | const channel = channels[video]; 305 | const tagname = channel.bs ? 'bs_channel' : 306 | channel.radiko_id ? 'radio_channel' : 'channel'; 307 | const id = channel.id ?? parseInt(video.substring(2)); 308 | const thread = getThread(video); 309 | if (!thread) continue; 310 | const last_res = getLastRes(thread); 311 | res.write(`<${tagname}>\n`); 312 | res.write(`${id}\n`); 313 | if (channel.radiko_id) { 314 | res.write(`${channel.radiko_id}\n`); 315 | } else if (!channel.bs) { 316 | res.write(`${id}\n`); 317 | } 318 | res.write(`${channel.name}\n`); 319 | res.write(`\n`); 320 | res.write(`\n`); 321 | res.write(`${thread.id}\n`); 322 | res.write(`${escapeChat(last_res)}\n`); 323 | res.write(`${thread.force}\n`); 324 | res.write(`${thread.viewers}\n`); 325 | res.write(`${thread.comments}\n`); 326 | res.write(`\n`); 327 | res.write(`\n`); 328 | } 329 | res.write('\n'); 330 | } 331 | 332 | function getLastRes(thread) { 333 | const recent_res = thread.recent_res.filter(Boolean).reverse(); 334 | const last_res = recent_res.map((r) => r.content).join(' '); 335 | if (last_res.length <= 50) return last_res; 336 | return last_res.substring(0, 50) + '...'; 337 | } 338 | 339 | async function handleGetFlv({ url, res }) { 340 | const use_ngrok = false; 341 | const video = url.searchParams.get('v'); 342 | const channel = channels[video]; 343 | const start_time = parseInt(url.searchParams.get('start_time')); 344 | const thread = getThread(video, start_time || undefined); 345 | let params; 346 | if (channel && thread) { 347 | const archive = start_time ? { archive: 1 } : {}; 348 | const tunnel = use_ngrok ? await getNgrokTunnel() : null; 349 | params = new URLSearchParams({ 350 | done: 'true', 351 | ...archive, 352 | thread_id: thread.id, 353 | ms: tunnel?.address ?? ms_address, 354 | ms_port: tunnel?.port ?? ms_port, 355 | http_port: http_port, 356 | channel_no: parseInt(video.substring(2)), 357 | channel_name: channel.name, 358 | genre_id: 1, 359 | twitter_enabled: 1, 360 | vip_follower_disabled: 0, 361 | twitter_vip_mode_count: 10000, 362 | twitter_hashtag: '#namami', 363 | twitter_api_url: 'http://jk.nicovideo.jp/api/v2/', 364 | base_time: thread.base_time, 365 | open_time: thread.open_time, 366 | start_time: thread.start_time, 367 | end_time: thread.end_time, 368 | user_id: 2525, 369 | is_premium: 0, 370 | nickname: 'namami' 371 | }); 372 | } else { 373 | params = new URLSearchParams({ 374 | code: 1, 375 | error: 'invalid_thread', 376 | done: 'true', 377 | }); 378 | } 379 | res.setHeader('Content-Type', 'text/html; charset=UTF-8'); 380 | res.write(params.toString()); 381 | } 382 | 383 | function handleGetPostkey({ res }) { 384 | res.setHeader('Content-Type', 'text/plain; charset=UTF-8'); 385 | res.write(`postkey=.${getServerTime()}.unavailable2525252525252525`); 386 | } 387 | 388 | function handleGetWaybackkey({ res }) { 389 | res.setHeader('Content-Type', 'text/plain; charset=UTF-8'); 390 | res.write(`waybackkey=${getServerTime()}.unavailable2525252525252525`); 391 | } 392 | 393 | function handleSessionCreate({ res }) { 394 | res.setHeader('Content-Type', 'application/xml; charset=UTF-8'); 395 | res.write(`\n`); 396 | res.write(`\n`); 397 | res.write(` \n`); 398 | res.write(` 2525\n`); 399 | res.write(` namami@example.com\n`); 400 | res.write(` exist\n`); 401 | res.write(` namami@example.com\n`); 402 | res.write(` namami@example.net\n`); 403 | res.write(` namami\n`); 404 | res.write(` 0\n`); 405 | res.write(` 東京都\n`); 406 | res.write(` 2000-01-01\n`); 407 | res.write(` 男性\n`); 408 | res.write(` Asia/Tokyo\n`); 409 | res.write(` Japan\n`); 410 | res.write(` JP\n`); 411 | res.write(` ja-jp\n`); 412 | res.write(` jp\n`); 413 | res.write(` reminder\n`); 414 | res.write(` answer\n`); 415 | res.write(` \n`); 416 | res.write(` 000000000002000000000000000000000000000001\n`); 417 | res.write(` ${res.socket.remoteAddress}\n`); 418 | res.write(` 2020-12-01T00:00:00+09:00\n`); 419 | res.write(` 2007-01-01T00:00:00+09:00\n`); 420 | res.write(` 2020-12-01T00:00:00+09:00\n`); 421 | res.write(` https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg\n`); 422 | res.write(` 0\n`); 423 | res.write(` \n`); 424 | res.write(` namami2525namami\n`); 425 | res.write(`\n`); 426 | } 427 | 428 | function handleSessionDestroy({ res }) { 429 | res.setHeader('Content-Type', 'application/xml; charset=UTF-8'); 430 | res.write(`\n`); 431 | res.write(`\n`); 432 | } 433 | 434 | function handleNg({ res }) { 435 | res.setHeader('Content-Type', 'application/xml; charset=UTF-8'); 436 | res.write(`\n`); 437 | res.write(`\n`); 438 | res.write(` 0\n`); 439 | res.write(`\n`); 440 | } 441 | 442 | function handleGetThread({ url, res }) { 443 | const thread = url.searchParams.get('thread'); 444 | res.setHeader('Content-Type', 'text/xml'); 445 | res.write(``); 446 | res.write(``); 447 | res.write(``); 448 | res.write(``); 449 | } 450 | 451 | function handleIndex({ paths, res }) { 452 | const filters = { 453 | 'tv': (channel) => !channel.radiko_id && !channel.bs, 454 | 'radio': (channel) => channel.radiko_id, 455 | 'bs': (channel) => channel.bs, 456 | }; 457 | const filter = (key) => filters[paths[0] || 'tv'](channels[key]); 458 | res.setHeader('Content-Type', 'text/html; charset=UTF-8'); 459 | res.setHeader('Cache-Control', 'no-cache'); 460 | res.write(``); 461 | res.write(``); 462 | res.write(``); 467 | res.write(``); 468 | res.write(``); 469 | res.write(``); 470 | res.write(``); 471 | res.write(``); 472 | res.write(``); 473 | res.write(``); 474 | for (const video of Object.keys(channels).filter(filter)) { 475 | const no = parseInt(video.substring(2)); 476 | const channel = channels[video]; 477 | const thread = getThread(video); 478 | if (!thread) continue; 479 | const last_res = getLastRes(thread); 480 | res.write(``); 481 | res.write(``); 482 | res.write(``); 483 | res.write(``); 484 | res.write(``); 485 | res.write(``); 486 | } 487 | res.write(`
チャンネルチャンネル名最新のコメント勢い
${no}ch${channel.name}${escapeChat(last_res)}${thread.force}コメ/分
`); 488 | res.write(``); 489 | res.write(``); 490 | } 491 | 492 | async function handleWatch({ paths, res }) { 493 | const video = paths[1]; 494 | const thread = getThread(video); 495 | if (!thread) return; 496 | res.setHeader('Content-Type', 'text/html; charset=UTF-8'); 497 | res.setHeader('Cache-Control', 'no-cache'); 498 | res.write(``); 499 | res.write(``); 500 | res.write(``); 509 | res.write(``); 510 | res.write(``); 511 | } 512 | 513 | function createWebSocketServer(port) { 514 | const server = net.createServer((socket) => { 515 | debug('c:connect', socket.localPort, socket.remoteAddress); 516 | const client = { stream: createClientStream(socket) }; 517 | socket.on('data', (data) => { 518 | data = data.toString(); 519 | debug('c:data', socket.remoteAddress, data); 520 | const element = parseElement(data); 521 | switch (element.name) { 522 | case 'thread': { 523 | handleThread(socket, client, element); 524 | break; 525 | } 526 | case 'chat': { 527 | break; 528 | } 529 | } 530 | }); 531 | socket.on('end', () => { 532 | debug('c:end', socket.remoteAddress); 533 | }); 534 | socket.on('close', () => { 535 | debug('c:close', socket.remoteAddress); 536 | client.thread_stream?.unpipe(client.stream).resume(); 537 | clearTimeout(client.timeout); 538 | clearInterval(client.counter); 539 | }); 540 | socket.on('error', (err) => { 541 | debug('c:error', socket.remoteAddress, err); 542 | }); 543 | client.timeout = setTimeout(() => { 544 | debug('c:timeout', socket.remoteAddress); 545 | client.leave?.call(); 546 | socket.destroy(); 547 | }, getNextBaseTimeDelay() * 1000); 548 | }); 549 | server.listen(port); 550 | debug('s:listen', port); 551 | return server; 552 | } 553 | 554 | function createClientStream(socket) { 555 | const stream = PassThrough({ objectMode: true }).resume(); 556 | stream.on('pause', () => { 557 | debug('c:pause', socket.remoteAddress); 558 | stream.unpipe().resume(); 559 | }); 560 | stream.pipe(socket); 561 | return stream; 562 | } 563 | 564 | function parseElement(element) { 565 | const parsed = {}; 566 | if (element.startsWith('<')) { 567 | const [, attribute, content] = element.split(/[<>]/); 568 | const attributes = attribute.split(/ +/); 569 | parsed.name = attributes.shift(); 570 | for (const a of attributes) { 571 | const [name, value] = a.split('='); 572 | parsed[name] = value?.split(/["']/)[1]; 573 | } 574 | parsed.content = content; 575 | } 576 | debug(parsed); 577 | return parsed; 578 | } 579 | 580 | function handleThread(socket, client, element) { 581 | const thread = threads[element.thread]; 582 | if (!thread) return; 583 | if (element.res_from < 0) { 584 | replayThread(socket, thread, element.res_from); 585 | } else { 586 | writeThread(socket, thread); 587 | } 588 | client.thread_stream?.unpipe(client.stream).resume(); 589 | client.thread_stream = thread.stream; 590 | client.thread_stream.pipe(client.stream); 591 | debug('c:pipe', thread.id, socket.remoteAddress); 592 | clearInterval(client.counter); 593 | const counter_delay = 60; 594 | //const counter_delay = 10; 595 | client.counter = setInterval( 596 | () => writeViewCounter(socket, thread), 597 | counter_delay * 1000 598 | ); 599 | client.leave = () => writeLeaveThread(socket, thread); 600 | } 601 | 602 | function replayThread(socket, thread, res_from) { 603 | const recent_res = thread.recent_res.filter(Boolean).splice(res_from); 604 | const last_res = thread.last_res - recent_res.length; 605 | writeThread(socket, { ...thread, last_res }); 606 | for (const res of recent_res) { 607 | const element = createChatElement(thread, res); 608 | socket.write(`${element}\0`); 609 | } 610 | } 611 | 612 | function writeThread(socket, thread) { 613 | const { resultcode, last_res, ticket } = thread; 614 | const element = ``; 622 | socket.write(`${element}\0`); 623 | debug(element); 624 | } 625 | 626 | function writeViewCounter(socket, thread) { 627 | const id = thread.id - threads[thread.id].base_time + 1; 628 | const element = ``; 629 | socket.write(`${element}\0`); 630 | //debug(element); 631 | } 632 | 633 | function writeLeaveThread(socket, thread) { 634 | const element = ``; 638 | socket.write(`${element}\0`); 639 | debug(element); 640 | } 641 | 642 | function getNextBaseTimeDelay() { 643 | const next_base_time = getBaseTime() + 86400; 644 | return next_base_time - getServerTime(); 645 | } 646 | 647 | function createThread(thread_id) { 648 | const recent_length = 50; 649 | const base_time = getBaseTime(thread_id); 650 | const thread = { 651 | resultcode: 0, 652 | id: thread_id, 653 | last_res: 0, 654 | last_min_res: Array(60 / force_delay).fill(0), 655 | recent_res: Array(recent_length), 656 | ticket: '0x25252525', 657 | revision: 1, 658 | base_time: base_time, 659 | open_time: base_time, 660 | start_time: base_time, 661 | end_time: base_time, 662 | force: 0, 663 | viewers: -1, 664 | comments: -1, 665 | lives: new Set() 666 | }; 667 | thread.stream = createTransform(thread); 668 | thread.stream.resume(); 669 | thread.stream.setMaxListeners(0); 670 | const prev_thread_id = thread_id - 86400; 671 | const prev_thread = threads[prev_thread_id]; 672 | if (prev_thread) { 673 | thread.lives = prev_thread.lives; 674 | for (const live_video of thread.lives) { 675 | const live = lives[live_video]; 676 | if (!live) continue; 677 | live.stream.unpipe(prev_thread.stream).resume(); 678 | debug('unpipe', live_video, prev_thread.id); 679 | live.stream.pipe(thread.stream); 680 | debug('pipe', live_video, thread.id); 681 | } 682 | delete threads[prev_thread_id]; 683 | } 684 | return thread; 685 | } 686 | 687 | function getThread(video, start_time) { 688 | const channel = channels[video]; 689 | if (!channel) return; 690 | const thread_id = getBaseTime(start_time) + channel.thread_id; 691 | return threads[thread_id]; 692 | } 693 | 694 | function createTransform(thread) { 695 | return Transform({ 696 | objectMode: true, 697 | transform: function(chunk, encoding, callback) { 698 | chunk.no = ++thread.last_res; 699 | const element = createChatElement(thread, chunk); 700 | this.push(`${element}\0`); 701 | //debug(element); 702 | thread.recent_res.shift(); 703 | thread.recent_res.push(chunk); 704 | thread.end_time = chunk.date; 705 | if (chunk.premium == 3 && chunk.content.startsWith('/')) { 706 | const commands = chunk.content.split(' '); 707 | debug(commands); 708 | if (commands[0] == '/jump') pipeLive(commands[1]); 709 | } 710 | callback(); 711 | } 712 | }); 713 | } 714 | 715 | function createChatElement(thread, chunk) { 716 | const { 717 | no, 718 | vpos_time, 719 | date, 720 | date_usec, 721 | mail, 722 | user_id, 723 | premium, 724 | anonymity, 725 | content 726 | } = chunk; 727 | const vpos = Math.round((vpos_time - thread.base_time) * 100); 728 | return `` + 739 | escapeChat(content) + 740 | ``; 741 | } 742 | 743 | function escapeChat(string) { 744 | return string. 745 | replace(/&/g, '&'). 746 | replace(//g, '>'); 748 | } 749 | 750 | function getLive(video) { 751 | return new Promise((resolve) => { 752 | if (lives[video]) return resolve(lives[video]); 753 | lives[video] = { stream: PassThrough({ objectMode: true }).resume() }; 754 | (async () => { 755 | const retry = 10; 756 | for (let i = 0; i < retry; i++) { 757 | const live = await watchLive(video); 758 | if (!live) break; 759 | const { tags, community, stream, promise } = live; 760 | lives[video].tags = tags; 761 | lives[video].community = community; 762 | resolve(lives[video]); 763 | stream.pipe(lives[video].stream); 764 | await promise; 765 | stream.unpipe(lives[video].stream).resume(); 766 | await waitSeconds(3); 767 | } 768 | delete lives[video]; 769 | debug('delete', video); 770 | resolve({ tags: [] }); 771 | })(); 772 | }); 773 | } 774 | 775 | async function watchLive(video) { 776 | const url = `https://live.nicovideo.jp/watch/${video}`; 777 | const embedded_data = await getEmbeddedData(url); 778 | if (!embedded_data) return; 779 | const { program: { tag, status }, community } = embedded_data; 780 | const watchable = new Set(['RELEASED', 'ON_AIR']); 781 | if (!watchable.has(status)) return; 782 | //const tags = tag.list.filter((t) => t.isLocked).map((t) => t.text); 783 | const tags = tag.list.map((t) => t.text); 784 | debug('watch', video, tags); 785 | const stream = PassThrough({ objectMode: true }).resume(); 786 | const promise = promiseWatchLive(url, video, embedded_data, stream); 787 | return { tags, community, stream, promise }; 788 | } 789 | 790 | async function promiseWatchLive(url, video, embedded_data, stream) { 791 | const { 792 | site: { relive: { webSocketUrl }, tag: { revisionCheckIntervalMs } }, 793 | program: { beginTime, vposBaseTime }, 794 | } = embedded_data; 795 | const getWebSocketUrl = async () => { 796 | debug('wait', url); 797 | await waitSeconds(beginTime - getServerTime()); 798 | const embedded_data = await getEmbeddedData(url); 799 | return embedded_data?.site.relive.webSocketUrl; 800 | } 801 | const ws_url = webSocketUrl || await getWebSocketUrl(); 802 | if (!ws_url) return; 803 | const { room, ws } = await createWebSocket(ws_url, video); 804 | const mws = createMessageWebSocket(room, revisionCheckIntervalMs); 805 | mws.on('message', (data) => { 806 | //debug(data); 807 | const message = JSON.parse(data); 808 | //debug('mws:message', mws._socket.remoteAddress, message); 809 | const { chat } = message; 810 | if (chat) { 811 | chat.vpos_time = vposBaseTime + chat.vpos / 100; 812 | stream.write(chat); 813 | //debug(chat); 814 | } 815 | }); 816 | await new Promise((resolve) => { 817 | ws.on('close', () => { 818 | mws.close(); 819 | resolve(); 820 | }); 821 | mws.on('close', () => { 822 | ws.close(); 823 | resolve(); 824 | }); 825 | }); 826 | } 827 | 828 | function createThreads() { 829 | const exclude_radio = true; 830 | const filter = exclude_radio ? (channel) => !channel.radiko_id : () => true; 831 | for (const channel of Object.values(channels).filter(filter)) { 832 | const thread_id = getBaseTime() + channel.thread_id; 833 | threads[thread_id] = createThread(thread_id); 834 | } 835 | } 836 | 837 | async function setPipesInterval() { 838 | const delay = 300; 839 | const pipe = () => pipeLives(['ニコニコ実況']); 840 | await pipe(); 841 | return setInterval(pipe, delay * 1000); 842 | } 843 | 844 | function setForceInterval() { 845 | return setInterval(() => { 846 | for (const video of Object.keys(channels)) { 847 | const thread = getThread(video); 848 | if (thread) { 849 | thread.force = thread.last_res - thread.last_min_res.shift(); 850 | thread.last_min_res.push(thread.last_res); 851 | } 852 | } 853 | }, force_delay * 1000); 854 | } 855 | 856 | async function pipeLives(tags, videos) { 857 | for (const status of ['onair', 'reserved']) { 858 | for (const live_video of await searchLives(tags, status)) { 859 | await pipeLive(live_video, videos); 860 | } 861 | } 862 | } 863 | 864 | async function pipeLive(live_video, videos = []) { 865 | const { tags, community, stream } = await getLive(live_video); 866 | const threads = new Set(); 867 | videos.forEach((video) => threads.add(getThread(video))); 868 | tags.forEach((tag) => threads.add(getThreadByTag(tag))); 869 | if (community) threads.add(getThreadByCommunity(community)); 870 | for (const thread of threads) { 871 | if (!thread) continue; 872 | if (!isPiped(stream, thread.stream)) { 873 | stream.pipe(thread.stream); 874 | debug('pipe', live_video, thread.id); 875 | thread.lives.add(live_video); 876 | } 877 | } 878 | } 879 | 880 | function getThreadByTag(tag) { 881 | if (tag.startsWith('jk')) return getThread(tag); 882 | const finder = (key) => channels[key].tags?.includes(tag); 883 | const video = Object.keys(channels).find(finder); 884 | return getThread(video); 885 | } 886 | 887 | function getThreadByCommunity(community) { 888 | const finder = (key) => channels[key].communities?.includes(community.id); 889 | const video = Object.keys(channels).find(finder); 890 | return getThread(video); 891 | } 892 | 893 | function isPiped(src, dest) { 894 | return src._readableState.pipes.includes(dest); 895 | } 896 | 897 | function closeServer(server) { 898 | return new Promise((resolve) => server.close(resolve)); 899 | } 900 | 901 | async function getNgrokTunnel() { 902 | const url = 'http://localhost:4040/api/tunnels/namami'; 903 | const res = await httpGet(url); 904 | const data = await slurpMessage(res); 905 | const { public_url, metrics: { conns: { rate1 } } } = JSON.parse(data); 906 | const threshold = 0.5; 907 | if (rate1 < threshold) { 908 | const { hostname, port } = new URL(public_url); 909 | const resolver = new Resolver(); 910 | const [address] = await resolver.resolve4(hostname); 911 | return { address, port }; 912 | } 913 | } 914 | 915 | (async () => { 916 | createThreads(); 917 | const http_server = createHttpServer(); 918 | const ws_server = createWebSocketServer(ms_port); 919 | await waitSeconds(getNextBaseTimeDelay()); 920 | const restart = false; 921 | while (restart) { 922 | createThreads(); 923 | await waitSeconds(getNextBaseTimeDelay()); 924 | } 925 | closeServer(http_server); 926 | await closeServer(ws_server); 927 | process.exit(); 928 | })(); 929 | 930 | setForceInterval(); 931 | setPipesInterval(); 932 | 933 | --------------------------------------------------------------------------------