hotel-tvn 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -0
- package/dist/cjs/clis/sgen.d.ts +3 -0
- package/dist/cjs/clis/sgen.d.ts.map +1 -0
- package/dist/cjs/clis/sgen.js +13 -0
- package/dist/cjs/clis/tvn.d.ts +3 -0
- package/dist/cjs/clis/tvn.d.ts.map +1 -0
- package/dist/cjs/clis/tvn.js +29 -0
- package/dist/cjs/index.d.ts +4 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +19 -0
- package/dist/cjs/lib/utils.d.ts +24 -0
- package/dist/cjs/lib/utils.d.ts.map +1 -0
- package/dist/cjs/lib/utils.js +241 -0
- package/dist/cjs/main.d.ts +2 -0
- package/dist/cjs/main.d.ts.map +1 -0
- package/dist/cjs/main.js +7 -0
- package/dist/cjs/scripts/check-data-json.d.ts +3 -0
- package/dist/cjs/scripts/check-data-json.d.ts.map +1 -0
- package/dist/cjs/scripts/check-data-json.js +151 -0
- package/dist/cjs/scripts/gen-service-json.d.ts +3 -0
- package/dist/cjs/scripts/gen-service-json.d.ts.map +1 -0
- package/dist/cjs/scripts/gen-service-json.js +86 -0
- package/dist/cjs/types.d.ts +41 -0
- package/dist/cjs/types.d.ts.map +1 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/vitest.config.d.ts +3 -0
- package/dist/cjs/vitest.config.d.ts.map +1 -0
- package/dist/cjs/vitest.config.js +18 -0
- package/dist/esm/clis/sgen.d.ts +3 -0
- package/dist/esm/clis/sgen.d.ts.map +1 -0
- package/dist/esm/clis/sgen.js +13 -0
- package/dist/esm/clis/tvn.d.ts +3 -0
- package/dist/esm/clis/tvn.d.ts.map +1 -0
- package/dist/esm/clis/tvn.js +29 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +19 -0
- package/dist/esm/lib/utils.d.ts +24 -0
- package/dist/esm/lib/utils.d.ts.map +1 -0
- package/dist/esm/lib/utils.js +241 -0
- package/dist/esm/main.d.ts +2 -0
- package/dist/esm/main.d.ts.map +1 -0
- package/dist/esm/main.js +7 -0
- package/dist/esm/scripts/check-data-json.d.ts +3 -0
- package/dist/esm/scripts/check-data-json.d.ts.map +1 -0
- package/dist/esm/scripts/check-data-json.js +151 -0
- package/dist/esm/scripts/gen-service-json.d.ts +3 -0
- package/dist/esm/scripts/gen-service-json.d.ts.map +1 -0
- package/dist/esm/scripts/gen-service-json.js +86 -0
- package/dist/esm/types.d.ts +41 -0
- package/dist/esm/types.d.ts.map +1 -0
- package/dist/esm/types.js +2 -0
- package/dist/esm/vitest.config.d.ts +3 -0
- package/dist/esm/vitest.config.d.ts.map +1 -0
- package/dist/esm/vitest.config.js +18 -0
- package/package.json +71 -0
package/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# hotel-tvn
|
|
2
|
+
|
|
3
|
+
Generate hotel TV / IPTV channel lists from a data JSON. The tool reads a list of service URLs, probes JSON endpoints on the local network, parses channel lists, tests stream availability and speed, then writes **lives.txt** and **lives.m3u**.
|
|
4
|
+
|
|
5
|
+
> **Disclaimer:** This project is for learning purposes only. Please delete any generated live source files (e.g. `lives.txt`, `lives.m3u`) within 24 hours.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
1. Reads a data JSON file (e.g. `tv_service.json`) containing service base URLs.
|
|
10
|
+
2. Expands each base URL into candidate JSON URLs (e.g. scanning last octet 1–255).
|
|
11
|
+
3. Checks which JSON URLs are reachable.
|
|
12
|
+
4. Fetches and parses channel lists from each available JSON endpoint.
|
|
13
|
+
5. Tests each channel stream and measures speed.
|
|
14
|
+
6. Writes the playable channels to **lives.txt** and **lives.m3u** in the chosen output directory.
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Node.js (recommended v18+)
|
|
19
|
+
- pnpm (or npm / yarn)
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
Install globally with npm:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g hotel-tvn
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The **tvn** and **sgen** commands will be available on your PATH.
|
|
30
|
+
|
|
31
|
+
## Command-line usage (tvn)
|
|
32
|
+
|
|
33
|
+
### Run with defaults
|
|
34
|
+
|
|
35
|
+
Uses `tv_service.json` in the current directory and default concurrency:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
tvn
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Options**:
|
|
42
|
+
|
|
43
|
+
| Option | Short | Description |
|
|
44
|
+
| -------------------------- | ----- | --------------------------------------------------------------------------- |
|
|
45
|
+
| `--data-json-path <path>` | `-d` | Path to the data JSON file (default: `tv_service.json`). |
|
|
46
|
+
| `--live-result-dir <dir>` | `-o` | Directory for **lives.txt** and **lives.m3u** (default: current directory). |
|
|
47
|
+
| `--concurrency-json <n>` | — | Concurrency for JSON URL checks. |
|
|
48
|
+
| `--concurrency-stream <n>` | — | Concurrency for stream speed tests. |
|
|
49
|
+
|
|
50
|
+
The tv_service.json can be generated from result.json using the **sgen** command.
|
|
51
|
+
|
|
52
|
+
If you have a **result.json** exported from [Censys](https://platform.censys.io/api/search?q=host.services.endpoints.http.body%3A+%22%2Fiptv%2Flive%2F%22+and+host.location.country_code%3A+%22CN%22&_cb=5f3928&_data=routes%2Fapi.search), use **genServiceJson** to parse it and generate **tv_service.json** for **tvn** (each item is `{ baseUrl, province, city }`).
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Use default paths: input dist/result.json, output tv_service.json
|
|
56
|
+
sgen parse-result-json
|
|
57
|
+
|
|
58
|
+
# Specify input and output paths
|
|
59
|
+
sgen parse-result-json -i ./my_result.json -o ./my_tv_service.json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
| Option | Short | Description |
|
|
63
|
+
| --------------------------- | ----- | ------------------------------------------------------------ |
|
|
64
|
+
| `--input-json-path <path>` | `-i` | Path to input result.json (default: `dist/result.json`). |
|
|
65
|
+
| `--output-json-path <path>` | `-o` | Path to output tv_service.json (default: `tv_service.json`). |
|
|
66
|
+
|
|
67
|
+
### More Examples
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Custom data file and output directory
|
|
71
|
+
tvn -d ./my-services.json -o ./output
|
|
72
|
+
|
|
73
|
+
# Limit concurrency
|
|
74
|
+
tvn --concurrency-json 128 --concurrency-stream 32
|
|
75
|
+
|
|
76
|
+
# Help
|
|
77
|
+
tvn --help
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Use as a JavaScript / TypeScript library
|
|
81
|
+
|
|
82
|
+
Install in your project:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pnpm add hotel-tvn
|
|
86
|
+
# or: npm install hotel-tvn
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The package supports both **CommonJS** and **ESM** and exports types.
|
|
90
|
+
|
|
91
|
+
### ESM
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
import { build, GenOptions } from 'hotel-tvn';
|
|
95
|
+
|
|
96
|
+
const options: GenOptions = {
|
|
97
|
+
dataJsonPath: './tv_service.json',
|
|
98
|
+
liveResultDir: './output',
|
|
99
|
+
concurrencyJson: 256,
|
|
100
|
+
concurrencyStream: 64,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
await build(options);
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### CommonJS
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
const { build } = require('hotel-tvn');
|
|
110
|
+
|
|
111
|
+
await build({
|
|
112
|
+
dataJsonPath: './tv_service.json',
|
|
113
|
+
liveResultDir: './output',
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Exported API and types
|
|
118
|
+
|
|
119
|
+
- **`build(options?: GenOptions): Promise<void>`**
|
|
120
|
+
Runs the full pipeline (read data JSON → probe URLs → parse channels → test streams → write lives.txt and lives.m3u). Options match the CLI:
|
|
121
|
+
- `dataJsonPath` – path to the data JSON file
|
|
122
|
+
- `liveResultDir` – directory for **lives.txt** and **lives.m3u**
|
|
123
|
+
- `concurrencyJson` – concurrency for JSON checks
|
|
124
|
+
- `concurrencyStream` – concurrency for stream tests
|
|
125
|
+
|
|
126
|
+
- **Types**: `GenOptions`, `Channel`, `ParsedChannel`, `RegionUrl`, `TvServiceItem` (see `types.ts`).
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sgen.d.ts","sourceRoot":"","sources":["../../../clis/sgen.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const commander_1 = require("commander");
|
|
5
|
+
const gen_service_json_1 = require("../scripts/gen-service-json");
|
|
6
|
+
commander_1.program
|
|
7
|
+
.command('parse-result-json')
|
|
8
|
+
.description('解析 dist/result.json,提取为 { baseUrl, province, city } 并保存为 tv_service.json')
|
|
9
|
+
.option('-i, --input-json-path <path>', '输入 JSON 文件路径(默认为 dist/result.json)')
|
|
10
|
+
.option('-o, --output-json-path <path>', '输出 JSON 文件路径(默认为 tv_service.json)')
|
|
11
|
+
.action((options) => {
|
|
12
|
+
(0, gen_service_json_1.genServiceJson)(options);
|
|
13
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tvn.d.ts","sourceRoot":"","sources":["../../../clis/tvn.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const commander_1 = require("commander");
|
|
5
|
+
const check_data_json_1 = require("../scripts/check-data-json");
|
|
6
|
+
commander_1.program
|
|
7
|
+
.name('tvn')
|
|
8
|
+
.description('酒店 TV 源生成:从 data JSON 检测可用链接并生成 lives.txt / lives.m3u')
|
|
9
|
+
.option('-d, --data-json-path <path>', 'data JSON 文件路径(默认为 tv_service.json)')
|
|
10
|
+
.option('-o, --live-result-dir <dir>', '直播结果输出目录(lives.txt、lives.m3u 写入目录)')
|
|
11
|
+
.option('--concurrency-json <n>', 'JSON 链接检测并发数', (v) => parseInt(v, 10), undefined)
|
|
12
|
+
.option('--concurrency-stream <n>', '流测速并发数', (v) => parseInt(v, 10), undefined)
|
|
13
|
+
.action(async (cliOpts) => {
|
|
14
|
+
const options = {};
|
|
15
|
+
if (cliOpts.dataJsonPath != null) {
|
|
16
|
+
options.dataJsonPath = String(cliOpts.dataJsonPath);
|
|
17
|
+
}
|
|
18
|
+
if (cliOpts.liveResultDir != null) {
|
|
19
|
+
options.liveResultDir = String(cliOpts.liveResultDir);
|
|
20
|
+
}
|
|
21
|
+
if (typeof cliOpts.concurrencyJson === 'number') {
|
|
22
|
+
options.concurrencyJson = cliOpts.concurrencyJson;
|
|
23
|
+
}
|
|
24
|
+
if (typeof cliOpts.concurrencyStream === 'number') {
|
|
25
|
+
options.concurrencyStream = cliOpts.concurrencyStream;
|
|
26
|
+
}
|
|
27
|
+
await (0, check_data_json_1.build)(options);
|
|
28
|
+
});
|
|
29
|
+
commander_1.program.parse();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,2BAA2B,CAAC;AAC1C,cAAc,4BAA4B,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./types"), exports);
|
|
18
|
+
__exportStar(require("./scripts/check-data-json"), exports);
|
|
19
|
+
__exportStar(require("./scripts/gen-service-json"), exports);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ParsedChannel } from '../types';
|
|
2
|
+
import { Channel } from '../types';
|
|
3
|
+
export declare const RESULT_LIMIT_PER_CHANNEL = 1024;
|
|
4
|
+
/**
|
|
5
|
+
* 根据基础 IP 生成 1~255 的所有候选地址
|
|
6
|
+
*/
|
|
7
|
+
export declare function generateModifiedIPs(baseUrl: string): string[];
|
|
8
|
+
/**
|
|
9
|
+
* 检查单个 URL 是否可访问
|
|
10
|
+
*/
|
|
11
|
+
export declare function checkUrlAlive(url: string): Promise<string | null>;
|
|
12
|
+
export declare function getValidJsonUrlsFromLocalUrls(): Promise<string[]>;
|
|
13
|
+
/**
|
|
14
|
+
* 从 JSON 地址获取并解析频道列表
|
|
15
|
+
*/
|
|
16
|
+
export declare function fetchAndParseJson(url: string): Promise<ParsedChannel[]>;
|
|
17
|
+
/**
|
|
18
|
+
* 测试单个直播源的速度
|
|
19
|
+
*/
|
|
20
|
+
export declare function testStreamSpeed(channel: ParsedChannel): Promise<Channel | null>;
|
|
21
|
+
export declare function genLiveFiles(tested: Channel[], liveResultDir?: string): Promise<void>;
|
|
22
|
+
/** 转换:http://A.B.C.D:port -> http://A.B.C.1:port */
|
|
23
|
+
export declare function toBaseUrl(u: string): string | null;
|
|
24
|
+
//# sourceMappingURL=utils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../../lib/utils.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAKnC,eAAO,MAAM,wBAAwB,OAAO,CAAC;AAmB7C;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAW7D;AAED;;GAEG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAYvE;AAED,wBAAsB,6BAA6B,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAWvE;AAED;;GAEG;AACH,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CA8D7E;AAED;;GAEG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CA+BrF;AAUD,wBAAsB,YAAY,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,aAAa,CAAC,EAAE,MAAM,iBAmE3E;AACD,oDAAoD;AAEpD,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGlD"}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.RESULT_LIMIT_PER_CHANNEL = void 0;
|
|
7
|
+
exports.generateModifiedIPs = generateModifiedIPs;
|
|
8
|
+
exports.checkUrlAlive = checkUrlAlive;
|
|
9
|
+
exports.getValidJsonUrlsFromLocalUrls = getValidJsonUrlsFromLocalUrls;
|
|
10
|
+
exports.fetchAndParseJson = fetchAndParseJson;
|
|
11
|
+
exports.testStreamSpeed = testStreamSpeed;
|
|
12
|
+
exports.genLiveFiles = genLiveFiles;
|
|
13
|
+
exports.toBaseUrl = toBaseUrl;
|
|
14
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
15
|
+
const bottleneck_1 = __importDefault(require("bottleneck"));
|
|
16
|
+
const axios_1 = __importDefault(require("axios"));
|
|
17
|
+
const path_1 = __importDefault(require("path"));
|
|
18
|
+
const REQUEST_TIMEOUT = 800;
|
|
19
|
+
const DOWNLOAD_TIMEOUT = 5000;
|
|
20
|
+
exports.RESULT_LIMIT_PER_CHANNEL = 1024;
|
|
21
|
+
// 限流器
|
|
22
|
+
const limiter = new bottleneck_1.default({
|
|
23
|
+
maxConcurrent: 30,
|
|
24
|
+
minTime: 150,
|
|
25
|
+
});
|
|
26
|
+
const jsonUrls = [
|
|
27
|
+
'http://183.223.157.33:9901/iptv/live/1000.json?key=txiptv',
|
|
28
|
+
'http://117.174.99.170:9901/iptv/live/1000.json?key=txiptv',
|
|
29
|
+
];
|
|
30
|
+
/**
|
|
31
|
+
* 根据基础 IP 生成 1~255 的所有候选地址
|
|
32
|
+
*/
|
|
33
|
+
function generateModifiedIPs(baseUrl) {
|
|
34
|
+
const urlObj = new URL(baseUrl);
|
|
35
|
+
const prefix = `${urlObj.protocol}//${urlObj.host.split(':')[0].split('.').slice(0, 3).join('.')}.`;
|
|
36
|
+
const portPath = urlObj.port ? `:${urlObj.port}` : '';
|
|
37
|
+
const suffix = '/iptv/live/1000.json?key=txiptv';
|
|
38
|
+
const ips = [];
|
|
39
|
+
for (let i = 1; i <= 255; i++) {
|
|
40
|
+
ips.push(`${prefix}${i}${portPath}${suffix}`);
|
|
41
|
+
}
|
|
42
|
+
return ips;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 检查单个 URL 是否可访问
|
|
46
|
+
*/
|
|
47
|
+
async function checkUrlAlive(url) {
|
|
48
|
+
try {
|
|
49
|
+
const res = await limiter.schedule(() => axios_1.default.head(url, { timeout: REQUEST_TIMEOUT }));
|
|
50
|
+
if (res.status === 200) {
|
|
51
|
+
return url;
|
|
52
|
+
}
|
|
53
|
+
console.log(`${url} is not alive, status: ${res.status}, ${res.data}`);
|
|
54
|
+
}
|
|
55
|
+
catch (_e) {
|
|
56
|
+
// console.log(`${url} is not alive, error: ${e}`);
|
|
57
|
+
// 静默失败
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
async function getValidJsonUrlsFromLocalUrls() {
|
|
62
|
+
const okUrls = [];
|
|
63
|
+
await Promise.all(jsonUrls.map(async (url) => {
|
|
64
|
+
const res = await checkUrlAlive(url);
|
|
65
|
+
if (res) {
|
|
66
|
+
okUrls.push(res);
|
|
67
|
+
}
|
|
68
|
+
}));
|
|
69
|
+
return okUrls;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 从 JSON 地址获取并解析频道列表
|
|
73
|
+
*/
|
|
74
|
+
async function fetchAndParseJson(url) {
|
|
75
|
+
try {
|
|
76
|
+
const res = await axios_1.default.get(url, { timeout: 2000 });
|
|
77
|
+
if (res.status !== 200 || !res.data?.data)
|
|
78
|
+
return [];
|
|
79
|
+
const base = url.replace(/\/iptv\/live\/.*$/, '/');
|
|
80
|
+
const items = [];
|
|
81
|
+
for (const it of res.data.data || []) {
|
|
82
|
+
let name = String(it.name || '').trim();
|
|
83
|
+
const urlx = String(it.url || '').trim();
|
|
84
|
+
if (!name || !urlx)
|
|
85
|
+
continue;
|
|
86
|
+
if (urlx.includes(','))
|
|
87
|
+
continue;
|
|
88
|
+
// 清洗名称
|
|
89
|
+
name = name
|
|
90
|
+
.replace(/cctv/gi, 'CCTV')
|
|
91
|
+
.replace(/(中央|央视)/g, 'CCTV')
|
|
92
|
+
.replace(/高清|超高|HD|标清|频道|-|—|\s/g, '')
|
|
93
|
+
.replace(/[+()]/g, (s) => (s === '+' ? '+' : ''))
|
|
94
|
+
.replace(/PLUS/gi, '+')
|
|
95
|
+
.replace(/CCTV(\d+)台/, 'CCTV$1')
|
|
96
|
+
.replace(/CCTV5\+体育(?:赛事|赛视)?/, 'CCTV5+')
|
|
97
|
+
.replace('CCTV1综合', 'CCTV1')
|
|
98
|
+
.replace('CCTV2财经', 'CCTV2')
|
|
99
|
+
.replace('CCTV3综艺', 'CCTV3')
|
|
100
|
+
.replace('CCTV4国际', 'CCTV4')
|
|
101
|
+
.replace('CCTV4中文国际', 'CCTV4')
|
|
102
|
+
.replace('CCTV4欧洲', 'CCTV4')
|
|
103
|
+
.replace('CCTV5体育', 'CCTV5')
|
|
104
|
+
.replace('CCTV6电影', 'CCTV6')
|
|
105
|
+
.replace('CCTV7军事', 'CCTV7')
|
|
106
|
+
.replace('CCTV7军农', 'CCTV7')
|
|
107
|
+
.replace('CCTV7农业', 'CCTV7')
|
|
108
|
+
.replace('CCTV7国防军事', 'CCTV7')
|
|
109
|
+
.replace('CCTV8电视剧', 'CCTV8')
|
|
110
|
+
.replace('CCTV9记录', 'CCTV9')
|
|
111
|
+
.replace('CCTV9纪录', 'CCTV9')
|
|
112
|
+
.replace('CCTV10科教', 'CCTV10')
|
|
113
|
+
.replace('CCTV11戏曲', 'CCTV11')
|
|
114
|
+
.replace('CCTV12社会与法', 'CCTV12')
|
|
115
|
+
.replace('CCTV13新闻', 'CCTV13')
|
|
116
|
+
.replace('CCTV新闻', 'CCTV13')
|
|
117
|
+
.replace('CCTV14少儿', 'CCTV14')
|
|
118
|
+
.replace('CCTV少儿', 'CCTV14')
|
|
119
|
+
.replace('CCTV15音乐', 'CCTV15')
|
|
120
|
+
.replace('CCTV音乐', 'CCTV15')
|
|
121
|
+
.replace('CCTV16奥林匹克', 'CCTV16')
|
|
122
|
+
.replace('CCTV17农业农村', 'CCTV17')
|
|
123
|
+
.replace('CCTV17农村农业', 'CCTV17')
|
|
124
|
+
.replace('CCTV17农业', 'CCTV17')
|
|
125
|
+
.replace('上海卫视', '东方卫视');
|
|
126
|
+
const finalUrl = urlx.startsWith('http') ? urlx : base + urlx;
|
|
127
|
+
items.push({ name, url: finalUrl });
|
|
128
|
+
}
|
|
129
|
+
return items;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* 测试单个直播源的速度
|
|
137
|
+
*/
|
|
138
|
+
async function testStreamSpeed(channel) {
|
|
139
|
+
const { name, url } = channel;
|
|
140
|
+
try {
|
|
141
|
+
const m3u8Res = await axios_1.default.get(url, { timeout: 1500 });
|
|
142
|
+
if (m3u8Res.status !== 200)
|
|
143
|
+
throw new Error('m3u8 failed');
|
|
144
|
+
const lines = m3u8Res.data.split('\n').map((l) => l.trim());
|
|
145
|
+
const tsFiles = lines.filter((l) => !l.startsWith('#') && l);
|
|
146
|
+
if (tsFiles.length === 0)
|
|
147
|
+
throw new Error('no ts');
|
|
148
|
+
const base = url.substring(0, url.lastIndexOf('/') + 1);
|
|
149
|
+
const firstTs = base + tsFiles[0];
|
|
150
|
+
const start = Date.now();
|
|
151
|
+
const tsRes = await axios_1.default.get(firstTs, {
|
|
152
|
+
responseType: 'arraybuffer',
|
|
153
|
+
timeout: DOWNLOAD_TIMEOUT,
|
|
154
|
+
});
|
|
155
|
+
const duration = (Date.now() - start) / 1000;
|
|
156
|
+
if (duration < 0.05)
|
|
157
|
+
throw new Error('too fast');
|
|
158
|
+
const sizeKB = tsRes.data.byteLength / 1024;
|
|
159
|
+
const speedMBps = sizeKB / duration / 1024;
|
|
160
|
+
return { name, url, speed: speedMBps };
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function genChannelContent(group, ch) {
|
|
167
|
+
const logo = `https://tv-res.pages.dev/logo/${ch.name}.png`;
|
|
168
|
+
return {
|
|
169
|
+
txt: `${ch.name},${ch.url}\n`,
|
|
170
|
+
m3u8: `#EXTINF:-1 tv-name="${ch.name}" tv-logo="${logo}" group-title="${group}",${ch.name}\n${ch.url}\n`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
async function genLiveFiles(tested, liveResultDir) {
|
|
174
|
+
// 分组
|
|
175
|
+
const groups = {
|
|
176
|
+
CCTV: [],
|
|
177
|
+
卫视: [],
|
|
178
|
+
其他: [],
|
|
179
|
+
};
|
|
180
|
+
const counters = {
|
|
181
|
+
CCTV: 0,
|
|
182
|
+
卫视: 0,
|
|
183
|
+
其他: 0,
|
|
184
|
+
};
|
|
185
|
+
for (const ch of tested) {
|
|
186
|
+
let group = '其他';
|
|
187
|
+
if (ch.name.includes('CCTV'))
|
|
188
|
+
group = 'CCTV';
|
|
189
|
+
else if (ch.name.includes('卫视'))
|
|
190
|
+
group = '卫视';
|
|
191
|
+
if (counters[group] >= exports.RESULT_LIMIT_PER_CHANNEL)
|
|
192
|
+
continue;
|
|
193
|
+
groups[group].push(ch);
|
|
194
|
+
counters[group]++;
|
|
195
|
+
}
|
|
196
|
+
for (const group of Object.keys(groups)) {
|
|
197
|
+
groups[group].sort((a, b) => {
|
|
198
|
+
if (group === 'CCTV') {
|
|
199
|
+
const channelIdA = a.name.match(/\d+/)?.[0] ?? '9999';
|
|
200
|
+
const channelIdB = b.name.match(/\d+/)?.[0] ?? '9999';
|
|
201
|
+
if (channelIdA !== channelIdB) {
|
|
202
|
+
return parseInt(channelIdA, 10) - parseInt(channelIdB, 10);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
const nameCompare = a.name.localeCompare(b.name);
|
|
207
|
+
if (nameCompare !== 0) {
|
|
208
|
+
return nameCompare;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return (b.speed ?? 0) - (a.speed ?? 0);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
// 生成 txt 文件
|
|
215
|
+
let txtContent = '央视频道,#genre#\n';
|
|
216
|
+
let m3u8Content = '#EXTM3U\n';
|
|
217
|
+
for (const ch of groups.CCTV) {
|
|
218
|
+
const { txt, m3u8 } = genChannelContent('央视频道', ch);
|
|
219
|
+
txtContent += txt;
|
|
220
|
+
m3u8Content += m3u8;
|
|
221
|
+
}
|
|
222
|
+
txtContent += '卫视频道,#genre#\n';
|
|
223
|
+
for (const ch of groups.卫视) {
|
|
224
|
+
const { txt, m3u8 } = genChannelContent('卫视频道', ch);
|
|
225
|
+
txtContent += txt;
|
|
226
|
+
m3u8Content += m3u8;
|
|
227
|
+
}
|
|
228
|
+
txtContent += '其他频道,#genre#\n';
|
|
229
|
+
for (const ch of groups.其他) {
|
|
230
|
+
const { txt, m3u8 } = genChannelContent('其他频道', ch);
|
|
231
|
+
txtContent += txt;
|
|
232
|
+
m3u8Content += m3u8;
|
|
233
|
+
}
|
|
234
|
+
await fs_extra_1.default.writeFile(path_1.default.join(liveResultDir || '', 'lives.txt'), txtContent, 'utf-8');
|
|
235
|
+
await fs_extra_1.default.writeFile(path_1.default.join(liveResultDir || '', 'lives.m3u'), m3u8Content, 'utf-8');
|
|
236
|
+
}
|
|
237
|
+
/** 转换:http://A.B.C.D:port -> http://A.B.C.1:port */
|
|
238
|
+
function toBaseUrl(u) {
|
|
239
|
+
const m = u.match(/http:\/\/(\d+\.\d+\.\d+)\.\d+:\d+/);
|
|
240
|
+
return m ? `http://${m[1]}.1${u.match(/:\d+/)[0]}` : null;
|
|
241
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../main.ts"],"names":[],"mappings":""}
|
package/dist/cjs/main.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"check-data-json.d.ts","sourceRoot":"","sources":["../../../scripts/check-data-json.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAW,UAAU,EAAiB,MAAM,UAAU,CAAC;AAM9D,wBAAsB,KAAK,CAAC,OAAO,GAAE,UAAe,iBA4GnD"}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.build = build;
|
|
40
|
+
/**
|
|
41
|
+
* 脚本流程:
|
|
42
|
+
* 1. 读取 data.json,按 utils 中的规则将每个 URL 转为 baseUrl(x.x.x.1:port)
|
|
43
|
+
* 2. 用 generateModifiedIPs 生成内网 JSON 链接(1~255)
|
|
44
|
+
* 3. 用 checkUrlAlive 检测每个 JSON 链接可用性
|
|
45
|
+
* 4. 对可用 JSON 用 fetchAndParseJson 获取频道列表
|
|
46
|
+
* 5. 用 testStreamSpeed 检测每个频道链接是否可访问
|
|
47
|
+
* 6. 生成 lives.txt 和 lives.m3u8
|
|
48
|
+
*/
|
|
49
|
+
const fs = __importStar(require("fs"));
|
|
50
|
+
const path = __importStar(require("path"));
|
|
51
|
+
const p_queue_1 = __importDefault(require("p-queue"));
|
|
52
|
+
const utils_1 = require("../lib/utils");
|
|
53
|
+
const DATA_JSON_PATH = path.join(__dirname, '../tv_service.json');
|
|
54
|
+
const CONCURRENCY_JSON = 256;
|
|
55
|
+
const CONCURRENCY_STREAM = 64;
|
|
56
|
+
async function build(options = {}) {
|
|
57
|
+
const raw = fs.readFileSync(options.dataJsonPath || DATA_JSON_PATH, 'utf-8');
|
|
58
|
+
const urls = JSON.parse(raw);
|
|
59
|
+
if (!Array.isArray(urls) || urls.length === 0) {
|
|
60
|
+
console.log('data.json 为空或格式不正确');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// 1. 转为 baseUrl 并去重
|
|
64
|
+
const baseUrls = [
|
|
65
|
+
...new Set(urls
|
|
66
|
+
.map((item) => (0, utils_1.toBaseUrl)(item.baseUrl))
|
|
67
|
+
.filter((v) => !!v)),
|
|
68
|
+
];
|
|
69
|
+
console.log(`data.json 共 ${urls.length} 条 URL,得到 ${baseUrls.length} 个 baseUrl`);
|
|
70
|
+
// 2. 生成所有候选 JSON 链接
|
|
71
|
+
const allJsonCandidates = [];
|
|
72
|
+
for (const base of baseUrls) {
|
|
73
|
+
allJsonCandidates.push(...(0, utils_1.generateModifiedIPs)(base));
|
|
74
|
+
}
|
|
75
|
+
console.log(`共生成 ${allJsonCandidates.length} 个 JSON 候选链接`);
|
|
76
|
+
// 3. 检测 JSON 链接可用性
|
|
77
|
+
const queueJson = new p_queue_1.default({ concurrency: options.concurrencyJson || CONCURRENCY_JSON });
|
|
78
|
+
const aliveJsonUrls = [];
|
|
79
|
+
let checked = 0;
|
|
80
|
+
await Promise.allSettled(allJsonCandidates.map((url) => queueJson.add(async () => {
|
|
81
|
+
const res = await (0, utils_1.checkUrlAlive)(url);
|
|
82
|
+
if (res) {
|
|
83
|
+
aliveJsonUrls.push(res);
|
|
84
|
+
console.log(`[可用] ${res}`);
|
|
85
|
+
}
|
|
86
|
+
checked++;
|
|
87
|
+
if (checked % 100 === 0) {
|
|
88
|
+
console.log(`检测进度: ${checked}/${allJsonCandidates.length}`, new Date());
|
|
89
|
+
}
|
|
90
|
+
})));
|
|
91
|
+
console.log(`\n可用 JSON 链接数: ${aliveJsonUrls.length}`);
|
|
92
|
+
if (aliveJsonUrls.length === 0) {
|
|
93
|
+
console.log('没有可用的 JSON 链接,结束');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// 4. 获取每个 JSON 的频道列表(去重频道名 + url)
|
|
97
|
+
const channelMap = new Map();
|
|
98
|
+
for (const jsonUrl of aliveJsonUrls) {
|
|
99
|
+
const channels = await (0, utils_1.fetchAndParseJson)(jsonUrl);
|
|
100
|
+
for (const ch of channels) {
|
|
101
|
+
const key = `${ch.name}|${ch.url}`;
|
|
102
|
+
if (!channelMap.has(key))
|
|
103
|
+
channelMap.set(key, ch);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const allChannels = [...channelMap.values()];
|
|
107
|
+
allChannels.sort((a, b) => a.name.localeCompare(b.name));
|
|
108
|
+
console.log(`\n共解析到 ${allChannels.length} 个不重复频道`);
|
|
109
|
+
if (allChannels.length === 0) {
|
|
110
|
+
console.log('没有解析到频道,结束');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// 5. 测速检测频道是否可访问
|
|
114
|
+
const queueStream = new p_queue_1.default({ concurrency: options.concurrencyStream || CONCURRENCY_STREAM });
|
|
115
|
+
const okChannels = [];
|
|
116
|
+
let done = 0;
|
|
117
|
+
await Promise.all(allChannels.map((ch) => queueStream.add(async () => {
|
|
118
|
+
const result = await (0, utils_1.testStreamSpeed)(ch);
|
|
119
|
+
if (result) {
|
|
120
|
+
okChannels.push(result);
|
|
121
|
+
console.log(`[可播] ${result.name} (${(result.speed * 1024).toFixed(2)} MB/s)`);
|
|
122
|
+
}
|
|
123
|
+
done++;
|
|
124
|
+
if (done % 50 === 0) {
|
|
125
|
+
console.log(`测速进度: ${done}/${allChannels.length}`, new Date());
|
|
126
|
+
}
|
|
127
|
+
})));
|
|
128
|
+
console.log(`\n可播放频道数: ${okChannels.length}/${allChannels.length}`);
|
|
129
|
+
// // 可选:把结果写到文件
|
|
130
|
+
// const outPath = path.join(__dirname, '../data-check-result.json');
|
|
131
|
+
// fs.writeFileSync(
|
|
132
|
+
// outPath,
|
|
133
|
+
// JSON.stringify(
|
|
134
|
+
// {
|
|
135
|
+
// aliveJsonUrls,
|
|
136
|
+
// channelCount: allChannels.length,
|
|
137
|
+
// playableCount: okChannels.length,
|
|
138
|
+
// playableChannels: okChannels,
|
|
139
|
+
// },
|
|
140
|
+
// null,
|
|
141
|
+
// 2
|
|
142
|
+
// ),
|
|
143
|
+
// 'utf-8'
|
|
144
|
+
// );
|
|
145
|
+
// console.log(`结果已写入 ${outPath}`);
|
|
146
|
+
await (0, utils_1.genLiveFiles)(okChannels, options.liveResultDir);
|
|
147
|
+
}
|
|
148
|
+
// build().catch((e) => {
|
|
149
|
+
// console.error(e);
|
|
150
|
+
// process.exit(1);
|
|
151
|
+
// });
|