padavan 2.0.0 → 2.0.2
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/package.json +2 -2
- package/src/index.js +1 -6
- package/src/transport/github.js +269 -0
- package/src/transport/http.js +119 -0
- package/src/utils/formatting.js +27 -0
- package/src/utils/parsers.js +193 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "padavan",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The core library for interacting with routers running Padavan firmware. Provides a programmatic API for local control via HTTP.",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
18
|
"./bin/",
|
|
19
|
-
"./src
|
|
19
|
+
"./src/"
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"jszip": "^3.10.1",
|
package/src/index.js
CHANGED
|
@@ -94,7 +94,7 @@ export default class Padavan {
|
|
|
94
94
|
* @type {Promise<any>}
|
|
95
95
|
*/
|
|
96
96
|
#commandQueue = Promise.resolve();
|
|
97
|
-
|
|
97
|
+
|
|
98
98
|
/**
|
|
99
99
|
* Кэшированный промис запроса NVRAM.
|
|
100
100
|
* @type {Promise<Record<string, string>>|null}
|
|
@@ -596,11 +596,6 @@ export default class Padavan {
|
|
|
596
596
|
const artifact = await this.#github.getLatestArtifact();
|
|
597
597
|
const toIdMatch = artifact.name.match(/-([0-9a-f]{7,})$/);
|
|
598
598
|
const toId = toIdMatch ? toIdMatch[1]?.substring(0, 7) : null;
|
|
599
|
-
console.log({
|
|
600
|
-
currentFirmware, fromId,
|
|
601
|
-
artifact,
|
|
602
|
-
toIdMatch, toId
|
|
603
|
-
});
|
|
604
599
|
|
|
605
600
|
if (!fromId || !toId)
|
|
606
601
|
throw new Error(`Could not determine firmware versions (current: ${fromId}, latest: ${toId})`);
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} Config
|
|
3
|
+
* @property {string} [repo] Репозиторий с прошивками.
|
|
4
|
+
* @property {string} [branch] Ветка репозитория для отслеживания обновлений.
|
|
5
|
+
* @property {string} [token] Персональный токен доступа (PAT) для API GitHub/GitLab.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Класс для работы с GitHub API.
|
|
10
|
+
* Используется для проверки, скачивания и управления сборками прошивок.
|
|
11
|
+
*/
|
|
12
|
+
export default class GitHubClient {
|
|
13
|
+
/**
|
|
14
|
+
* Конфигурация для подключения.
|
|
15
|
+
* @type {Config}
|
|
16
|
+
*/
|
|
17
|
+
#config = {};
|
|
18
|
+
|
|
19
|
+
#logger;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {Config} config
|
|
23
|
+
* @param {function(string, ...any): void} logger
|
|
24
|
+
*/
|
|
25
|
+
constructor(config, logger) {
|
|
26
|
+
this.#config = {
|
|
27
|
+
branch: 'main',
|
|
28
|
+
...config
|
|
29
|
+
};
|
|
30
|
+
this.#logger = logger;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Возвращает заголовки для запросов к API.
|
|
35
|
+
* @returns {HeadersInit}
|
|
36
|
+
*/
|
|
37
|
+
get #headers() {
|
|
38
|
+
const /** @type {Record<string, string>} */ headers = {
|
|
39
|
+
accept: 'application/vnd.github.v3+json'
|
|
40
|
+
};
|
|
41
|
+
if (this.#config.token)
|
|
42
|
+
headers.authorization = `Bearer ${this.#config.token}`;
|
|
43
|
+
return headers;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Выполняет запрос к GitHub API.
|
|
48
|
+
* @param {string} path Путь API или полный URL.
|
|
49
|
+
* @param {RequestInit} [options] - Дополнительные опции для fetch.
|
|
50
|
+
* @returns {Promise<any>} JSON ответ.
|
|
51
|
+
*/
|
|
52
|
+
async request(path, options = {}) {
|
|
53
|
+
const { repo } = this.#config;
|
|
54
|
+
if (!repo)
|
|
55
|
+
throw new Error('Repository not configured');
|
|
56
|
+
const url = path.startsWith('http') ? path : `https://api.github.com/repos/${repo}/${path}`;
|
|
57
|
+
const res = await fetch(url, {
|
|
58
|
+
...options,
|
|
59
|
+
headers: { ...this.#headers, ...options?.headers }
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const errorBody = await res.text();
|
|
64
|
+
this.#logger('error', `GitHub API request failed to ${url} with status ${res.status}:`, errorBody);
|
|
65
|
+
throw new Error(`GitHub API Error: ${res.status}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (res.headers.get('content-type')?.includes('application/json'))
|
|
69
|
+
return res.json();
|
|
70
|
+
return res;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Получает сырое содержимое файла из репозитория.
|
|
75
|
+
* @param {string} path Путь к файлу (например 'build.conf')
|
|
76
|
+
* @returns {Promise<string>}
|
|
77
|
+
*/
|
|
78
|
+
async getFileContent(path) {
|
|
79
|
+
const res = await this.request(`contents/${path}`, {
|
|
80
|
+
headers: {
|
|
81
|
+
accept: 'application/vnd.github.raw'
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
return res.text();
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Находит ID workflow по его имени.
|
|
89
|
+
* @param {string} workflowName - Имя файла (e.g., 'build.yml').
|
|
90
|
+
* @returns {Promise<number>}
|
|
91
|
+
*/
|
|
92
|
+
async #getWorkflowId(workflowName) {
|
|
93
|
+
const { workflows } = await this.request('actions/workflows');
|
|
94
|
+
const workflow = workflows.find((/** @type {any} */ w) => w.path.endsWith(workflowName));
|
|
95
|
+
if (!workflow)
|
|
96
|
+
throw new Error(`Workflow '${workflowName}' not found`);
|
|
97
|
+
return workflow.id;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Запускает сборку прошивки через GitHub Actions.
|
|
102
|
+
* @param {string} [workflowName='build.yml'] - Имя файла workflow.
|
|
103
|
+
*/
|
|
104
|
+
async startBuild(workflowName = 'build.yml') {
|
|
105
|
+
const workflowId = await this.#getWorkflowId(workflowName);
|
|
106
|
+
const { branch } = this.#config;
|
|
107
|
+
await this.request(`actions/workflows/${workflowId}/dispatches`, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
body: JSON.stringify({ ref: branch })
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Находит последний успешный артефакт сборки.
|
|
115
|
+
* @param {string} [workflowName='build.yml'] Имя workflow файла.
|
|
116
|
+
* @returns {Promise<any>} Объект артефакта.
|
|
117
|
+
*/
|
|
118
|
+
async getLatestArtifact(workflowName = 'build.yml') {
|
|
119
|
+
const { branch } = this.#config;
|
|
120
|
+
const workflowId = await this.#getWorkflowId(workflowName);
|
|
121
|
+
|
|
122
|
+
let runsUrl = `actions/workflows/${workflowId}/runs?status=success&per_page=1&branch=${branch}`;
|
|
123
|
+
|
|
124
|
+
const { workflow_runs } = await this.request(runsUrl);
|
|
125
|
+
const run = workflow_runs[0];
|
|
126
|
+
if (!run)
|
|
127
|
+
throw new Error('No successful runs found');
|
|
128
|
+
|
|
129
|
+
const { artifacts } = await this.request(run.artifacts_url);
|
|
130
|
+
const firmware = artifacts.find((/** @type {any} */ a) => !a.expired);
|
|
131
|
+
if (!firmware)
|
|
132
|
+
throw new Error('No valid artifacts found in the latest run');
|
|
133
|
+
|
|
134
|
+
return firmware;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Скачивает артефакт.
|
|
139
|
+
* @param {number} artifactId ID артефакта.
|
|
140
|
+
* @returns {Promise<ArrayBuffer>} Бинарные данные архива.
|
|
141
|
+
*/
|
|
142
|
+
async downloadArtifact(artifactId) {
|
|
143
|
+
const { repo } = this.#config;
|
|
144
|
+
const url = `https://api.github.com/repos/${repo}/actions/artifacts/${artifactId}/zip`;
|
|
145
|
+
const res = await fetch(url, { headers: this.#headers });
|
|
146
|
+
if (!res.ok)
|
|
147
|
+
throw new Error('Download failed');
|
|
148
|
+
return res.arrayBuffer();
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Получает список коммитов между двумя хэшами из внешнего репозитория.
|
|
153
|
+
* @param {string} repoUrl URL репозитория из build.conf.
|
|
154
|
+
* @param {string} fromId Текущий хэш.
|
|
155
|
+
* @param {string} toId Целевой хэш.
|
|
156
|
+
* @returns {Promise<string[]>} Список коммитов.
|
|
157
|
+
*/
|
|
158
|
+
async getCommitsBetween(repoUrl, fromId, toId) {
|
|
159
|
+
const cleanUrl = repoUrl.replace(/\.git$/, '').replace(/\/$/, '');
|
|
160
|
+
try {
|
|
161
|
+
if (cleanUrl.includes('gitlab.com'))
|
|
162
|
+
return await this.#fetchGitLabCommits(cleanUrl, fromId, toId);
|
|
163
|
+
else if (cleanUrl.includes('github.com'))
|
|
164
|
+
return await this.#fetchGitHubCommits(cleanUrl, fromId, toId);
|
|
165
|
+
else {
|
|
166
|
+
this.#logger('warn', `Unsupported repository host: ${cleanUrl}`);
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
} catch (e) {
|
|
170
|
+
this.#logger('error', `Failed to fetch external commits`, e);
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Получает список коммитов из репозитория GitLab.
|
|
177
|
+
* Парсит URL для получения ID проекта и запрашивает историю через API.
|
|
178
|
+
* @param {string} url URL репозитория (например, https://gitlab.com/user/project).
|
|
179
|
+
* @param {string} fromId Хэш начального коммита.
|
|
180
|
+
* @param {string} toId Хэш конечного коммита.
|
|
181
|
+
* @returns {Promise<string[]>} Список заголовков коммитов в диапазоне.
|
|
182
|
+
*/
|
|
183
|
+
async #fetchGitLabCommits(url, fromId, toId) {
|
|
184
|
+
const repoPath = url.split('gitlab.com/')[1];
|
|
185
|
+
const projectEncoded = encodeURIComponent(repoPath);
|
|
186
|
+
|
|
187
|
+
const apiUrl = `https://gitlab.com/api/v4/projects/${projectEncoded}/repository/commits?per_page=100`;
|
|
188
|
+
const res = await fetch(apiUrl);
|
|
189
|
+
if (!res.ok)
|
|
190
|
+
throw new Error(`GitLab API error: ${res.status}`);
|
|
191
|
+
|
|
192
|
+
const commits = await res.json();
|
|
193
|
+
const messages = [];
|
|
194
|
+
|
|
195
|
+
for (const commit of commits) {
|
|
196
|
+
const sha = commit.id;
|
|
197
|
+
const shortSha = sha.substring(0, 7);
|
|
198
|
+
if (sha.startsWith(fromId) || shortSha === fromId)
|
|
199
|
+
break;
|
|
200
|
+
if (sha.startsWith(toId) || shortSha === toId || messages.length > 0)
|
|
201
|
+
messages.push(commit.title);
|
|
202
|
+
}
|
|
203
|
+
return messages;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Получает список коммитов из репозитория GitHub.
|
|
208
|
+
* Использует Compare API для получения разницы между двумя ревизиями.
|
|
209
|
+
* @param {string} url URL репозитория (например, https://github.com/user/project).
|
|
210
|
+
* @param {string} fromId Хэш начального коммита (старый).
|
|
211
|
+
* @param {string} toId Хэш конечного коммита (новый).
|
|
212
|
+
* @returns {Promise<string[]>} Список сообщений коммитов.
|
|
213
|
+
*/
|
|
214
|
+
async #fetchGitHubCommits(url, fromId, toId) {
|
|
215
|
+
const repoPath = url.split('github.com/')[1];
|
|
216
|
+
const apiUrl = `https://api.github.com/repos/${repoPath}/compare/${fromId}...${toId}`;
|
|
217
|
+
const { commits } = await this.request(apiUrl);
|
|
218
|
+
return commits.map((/** @type {any} */ c) => c.commit.message);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Получает метаданные репозитория (нужно, чтобы найти parent/source при поиске).
|
|
223
|
+
* @param {string} [repoName] Имя репозитория (owner/repo). Если null - берется из конфига.
|
|
224
|
+
* @returns {Promise<any>}
|
|
225
|
+
*/
|
|
226
|
+
async getRepoInfo(repoName) {
|
|
227
|
+
const target = repoName || this.#config.repo;
|
|
228
|
+
if (!target)
|
|
229
|
+
return null;
|
|
230
|
+
return this.request(target.startsWith('http') ? target : `https://api.github.com/repos/${target}`);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Рекурсивно получает список всех форков.
|
|
235
|
+
* @param {string} repoUrl URL API репозитория.
|
|
236
|
+
* @param {string} repoUrl API URL для получения форков.
|
|
237
|
+
* @param {any[]} [currentList=[]] Аккумулятор результатов.
|
|
238
|
+
* @param {number} [page=1] Текущая страница.
|
|
239
|
+
* @returns {Promise<any[]>} Список форков.
|
|
240
|
+
*/
|
|
241
|
+
async getForks(repoUrl, currentList = [], page = 1) {
|
|
242
|
+
const url = `${repoUrl}/forks?per_page=100&page=${page}&sort=stargazers`;
|
|
243
|
+
try {
|
|
244
|
+
const forks = await this.request(url);
|
|
245
|
+
if (!Array.isArray(forks) || forks.length === 0)
|
|
246
|
+
return currentList;
|
|
247
|
+
const newList = currentList.concat(forks);
|
|
248
|
+
if (forks.length === 100 && page < 5)
|
|
249
|
+
return this.getForks(repoUrl, newList, page + 1);
|
|
250
|
+
return newList;
|
|
251
|
+
} catch (e) {
|
|
252
|
+
return currentList;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Получает список артефактов для конкретного репозитория.
|
|
258
|
+
* @param {string} repoFullName Полное имя репозитория (owner/repo).
|
|
259
|
+
* @returns {Promise<any[]>} Список артефактов.
|
|
260
|
+
*/
|
|
261
|
+
async getRepoArtifacts(repoFullName) {
|
|
262
|
+
try {
|
|
263
|
+
const { artifacts } = await this.request(`https://api.github.com/repos/${repoFullName}/actions/artifacts?per_page=20`);
|
|
264
|
+
return artifacts || [];
|
|
265
|
+
} catch (e) {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
import { DEFAULT_HTTP_CONFIG } from '../constants.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {Object} Config
|
|
6
|
+
* @property {string} [host] IP-адрес или хостнейм роутера.
|
|
7
|
+
* @property {number} [port] Порт веб-интерфейса.
|
|
8
|
+
* @property {string} [username] Имя пользователя для входа в роутер.
|
|
9
|
+
* @property {string} [password] Пароль администратора.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Класс для HTTP взаимодействия с роутером.
|
|
14
|
+
* Реализует Basic Authentication.
|
|
15
|
+
*/
|
|
16
|
+
export default class HttpClient {
|
|
17
|
+
/**
|
|
18
|
+
* Конфигурация для подключения.
|
|
19
|
+
* @type {Config}
|
|
20
|
+
*/
|
|
21
|
+
#config = {};
|
|
22
|
+
|
|
23
|
+
#logger;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {Config} config
|
|
27
|
+
* @param {function(string, ...any): void} logger
|
|
28
|
+
*/
|
|
29
|
+
constructor(config, logger) {
|
|
30
|
+
this.#config = { ...DEFAULT_HTTP_CONFIG, ...config };
|
|
31
|
+
this.#logger = logger;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Генерирует заголовок авторизации.
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
get #authHeader() {
|
|
39
|
+
const { username, password } = this.#config;
|
|
40
|
+
return 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Базовый URL.
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
get #baseUrl() {
|
|
48
|
+
const { host, port } = this.#config;
|
|
49
|
+
const portSuffix = port === 80 ? '' : `:${port}`;
|
|
50
|
+
return `http://${host}${portSuffix}`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Выполняет HTTP запрос к роутеру.
|
|
55
|
+
* @param {string} path Путь относительно хоста.
|
|
56
|
+
* @param {RequestInit} [options] Опции для fetch.
|
|
57
|
+
* @returns {Promise<string>} Текстовый ответ сервера.
|
|
58
|
+
*/
|
|
59
|
+
async request(path, options = {}) {
|
|
60
|
+
const url = `${this.#baseUrl}/${path}`;
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(url, {
|
|
63
|
+
...options,
|
|
64
|
+
headers: {
|
|
65
|
+
'Authorization': this.#authHeader,
|
|
66
|
+
...options.headers
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
if (res.status === 401)
|
|
71
|
+
throw new Error('Authentication failed');
|
|
72
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
73
|
+
}
|
|
74
|
+
return await res.text();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const method = options.method || 'GET';
|
|
77
|
+
this.#logger('error', `HTTP ${method} ${path} failed:`, err);
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Выполняет GET запрос.
|
|
84
|
+
* @param {string} path Путь относительно хоста (например 'device-map/clients.asp').
|
|
85
|
+
* @returns {Promise<string>} Текстовый ответ сервера.
|
|
86
|
+
*/
|
|
87
|
+
async get(path) {
|
|
88
|
+
return this.request(path);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Выполняет POST запрос с данными формы (application/x-www-form-urlencoded).
|
|
93
|
+
* @param {string} path Путь относительно хоста.
|
|
94
|
+
* @param {Record<string, any>} data Объект с данными для отправки.
|
|
95
|
+
* @returns {Promise<string>} Текстовый ответ сервера.
|
|
96
|
+
*/
|
|
97
|
+
async post(path, data) {
|
|
98
|
+
return this.request(path, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: {
|
|
101
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
102
|
+
},
|
|
103
|
+
body: new URLSearchParams(data)
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Выполняет загрузку файла (multipart/form-data).
|
|
109
|
+
* @param {string} path Путь относительно хоста.
|
|
110
|
+
* @param {FormData} formData Объект FormData с файлом.
|
|
111
|
+
* @returns {Promise<string>} Текстовый ответ сервера.
|
|
112
|
+
*/
|
|
113
|
+
async postFile(path, formData) {
|
|
114
|
+
return this.request(path, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
body: formData
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Форматирует число байт в читаемую строку (KB, MB, GB).
|
|
3
|
+
* @param {number|string} bytes Число байт.
|
|
4
|
+
* @param {number} [decimals=2] Количество знаков после запятой.
|
|
5
|
+
* @returns {string} Строка вида "10.5 MB".
|
|
6
|
+
*/
|
|
7
|
+
export function formatBytes(bytes, decimals = 2) {
|
|
8
|
+
const b = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes;
|
|
9
|
+
if (b === 0 || isNaN(b))
|
|
10
|
+
return '0 B';
|
|
11
|
+
const k = 1024;
|
|
12
|
+
const dm = decimals < 0 ? 0 : decimals;
|
|
13
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
14
|
+
const i = Math.floor(Math.log(b) / Math.log(k));
|
|
15
|
+
return parseFloat((b / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Форматирует объект uptime в строку.
|
|
20
|
+
* @param {{days: number, hours: number, minutes: number}} uptime
|
|
21
|
+
* @returns {string} Строка вида "5d 12h 30m"
|
|
22
|
+
*/
|
|
23
|
+
export function formatUptime(uptime) {
|
|
24
|
+
if (!uptime)
|
|
25
|
+
return 'N/A';
|
|
26
|
+
return `${uptime.days}d ${uptime.hours}h ${uptime.minutes}m`;
|
|
27
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Парсит HTML страницу и извлекает значения всех полей ввода.
|
|
3
|
+
* Поддерживает:
|
|
4
|
+
* <input name="..." value="...">
|
|
5
|
+
* <textarea name="...">value</textarea>
|
|
6
|
+
* <select name="...">...<option selected>value</option>...</select>
|
|
7
|
+
* @param {string} html Исходный HTML код страницы.
|
|
8
|
+
* @returns {Record<string, string>} Словарь параметров { имя_поля: значение }.
|
|
9
|
+
*/
|
|
10
|
+
export function parsePageInputs(html) {
|
|
11
|
+
const /** @type {Record<string, string>} */ params = {};
|
|
12
|
+
|
|
13
|
+
// 1. Поиск <input name="..." value="...">
|
|
14
|
+
const inputRegex = /<input[^>]+name=["']([^"']+)["'][^>]*value=["'](.*?)["']/gi;
|
|
15
|
+
let match;
|
|
16
|
+
while ((match = inputRegex.exec(html)) !== null) {
|
|
17
|
+
params[match[1]] = match[2];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 2. Поиск <input value="..." name="..."> (обратный порядок атрибутов)
|
|
21
|
+
const inputRevRegex = /<input[^>]+value=["'](.*?)["'][^>]*name=["']([^"']+)["']/gi;
|
|
22
|
+
while ((match = inputRevRegex.exec(html)) !== null) {
|
|
23
|
+
params[match[2]] = match[1];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 3. Поиск <textarea name="...">value</textarea>
|
|
27
|
+
const textareaRegex = /<textarea[^>]+name=["']([^"']+)["'][^>]*>([\s\S]*?)<\/textarea>/gi;
|
|
28
|
+
while ((match = textareaRegex.exec(html)) !== null) {
|
|
29
|
+
params[match[1]] = match[2];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 4. Поиск <select name="...">...</select>
|
|
33
|
+
const selectRegex = /<select[^>]+name=["']([^"']+)["'][^>]*>([\s\S]*?)<\/select>/gi;
|
|
34
|
+
while ((match = selectRegex.exec(html)) !== null) {
|
|
35
|
+
const name = match[1];
|
|
36
|
+
const content = match[2];
|
|
37
|
+
const optionMatch = content.match(/<option[^>]+value=["']([^"']+)["'][^>]*selected/i);
|
|
38
|
+
if (optionMatch)
|
|
39
|
+
params[name] = optionMatch[1];
|
|
40
|
+
else {
|
|
41
|
+
const firstOption = content.match(/<option[^>]+value=["']([^"']+)["']/i);
|
|
42
|
+
if (firstOption)
|
|
43
|
+
params[name] = firstOption[1];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return params;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Извлекает значение JS переменной из исходного кода страницы.
|
|
51
|
+
* Обрабатывает особенности синтаксиса Padavan (отсутствие кавычек у ключей, hex-числа).
|
|
52
|
+
* @param {string} html Исходный HTML.
|
|
53
|
+
* @param {string} varName Имя переменной (например, 'ipmonitor').
|
|
54
|
+
* @returns {any} Распаршенный объект/массив или null.
|
|
55
|
+
*/
|
|
56
|
+
export function extractJsVariable(html, varName) {
|
|
57
|
+
const regex = new RegExp(`(?:var\\s+)?${varName}\\s*=\\s*([\\{\\[][\\s\\S]*?[\\}\\]]);`);
|
|
58
|
+
const match = html.match(regex);
|
|
59
|
+
if (!match)
|
|
60
|
+
return null;
|
|
61
|
+
|
|
62
|
+
let jsonStr = match[1];
|
|
63
|
+
try {
|
|
64
|
+
jsonStr = jsonStr.replace(/'/g, '"');
|
|
65
|
+
jsonStr = jsonStr.replace(/0x([0-9a-fA-F]+)/g, (_match, hex) => parseInt(hex, 16).toString());
|
|
66
|
+
return JSON.parse(jsonStr);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Извлекает содержимое тега textarea из HTML.
|
|
74
|
+
* @param {string} html Исходный HTML.
|
|
75
|
+
* @returns {string} Содержимое textarea или пустая строка.
|
|
76
|
+
*/
|
|
77
|
+
export function extractTextareaValue(html) {
|
|
78
|
+
const match = html.match(/<textarea[^>]*>([\s\S]*?)<\/textarea>/i);
|
|
79
|
+
return match ? match[1].trim() : '';
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Извлекает MAC-адреса из содержимого textarea в HTML.
|
|
84
|
+
* @param {string} html Исходный HTML.
|
|
85
|
+
* @returns {string[]} Список MAC-адресов в верхнем регистре.
|
|
86
|
+
*/
|
|
87
|
+
export function extractMacsFromTextarea(html) {
|
|
88
|
+
const macs = [];
|
|
89
|
+
const content = extractTextareaValue(html);
|
|
90
|
+
if (!content)
|
|
91
|
+
return macs;
|
|
92
|
+
const macRegex = /([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}/g;
|
|
93
|
+
let match;
|
|
94
|
+
while ((match = macRegex.exec(content)) !== null) {
|
|
95
|
+
macs.push(match[0].toUpperCase());
|
|
96
|
+
}
|
|
97
|
+
return macs;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Извлекает текущий канал из текстового лога Wireless Status.
|
|
102
|
+
* Поддерживает форматы:
|
|
103
|
+
* - "Channel : 13"
|
|
104
|
+
* - "Channel Main : 13"
|
|
105
|
+
* - "Central Channel : 36"
|
|
106
|
+
* - "Primary Channel : 36"
|
|
107
|
+
*
|
|
108
|
+
* @param {string} text Содержимое лога.
|
|
109
|
+
* @returns {number} Номер канала или 0.
|
|
110
|
+
*/
|
|
111
|
+
export function extractCurrentChannel(text) {
|
|
112
|
+
if (!text)
|
|
113
|
+
return 0;
|
|
114
|
+
const regex = /(?:Central|Primary)?\s*Channel(?:\s+Main)?\s*:\s*(\d+)/i;
|
|
115
|
+
const match = text.match(regex);
|
|
116
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Парсит вывод команды `nvram show`.
|
|
121
|
+
* @param {string} text Текстовый вывод команды.
|
|
122
|
+
* @returns {Record<string, string>} Словарь параметров.
|
|
123
|
+
*/
|
|
124
|
+
export function parseNvramOutput(text) {
|
|
125
|
+
const /** @type {Record<string, string>} */ params = {};
|
|
126
|
+
if (!text)
|
|
127
|
+
return params;
|
|
128
|
+
const lines = text.split('\n');
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
const eqIndex = line.indexOf('=');
|
|
131
|
+
if (eqIndex > 0) {
|
|
132
|
+
const key = line.substring(0, eqIndex);
|
|
133
|
+
const value = line.substring(eqIndex + 1).replace(/\r$/, '');
|
|
134
|
+
params[key] = value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return params;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Декодирует дату из формата Padavan Traffic Monitor.
|
|
142
|
+
* Формат: биты кодируют (Year << 16) | (Month << 8) | Day.
|
|
143
|
+
* @param {number} n Закодированная дата.
|
|
144
|
+
* @returns {number[]} Массив [Год, Месяц(0-11), День].
|
|
145
|
+
*/
|
|
146
|
+
function decodeTrafficDate(n) {
|
|
147
|
+
return [
|
|
148
|
+
((n >> 16) & 0xFF) + 1900,
|
|
149
|
+
(n >>> 8) & 0xFF,
|
|
150
|
+
n & 0xFF
|
|
151
|
+
];
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Нормализует массив истории трафика в удобный формат объектов.
|
|
156
|
+
* @param {Array[]} historyArray Массив вида [[dateInt, downBytes, upBytes], ...].
|
|
157
|
+
* @returns {{date: Date, dateStr: string, download: number, upload: number}[]} Массив объектов истории.
|
|
158
|
+
*/
|
|
159
|
+
export function normalizeTrafficHistory(historyArray) {
|
|
160
|
+
if (!Array.isArray(historyArray))
|
|
161
|
+
return [];
|
|
162
|
+
return historyArray.map(item => {
|
|
163
|
+
const dateParts = decodeTrafficDate(item[0]);
|
|
164
|
+
return {
|
|
165
|
+
date: new Date(dateParts[0], dateParts[1], dateParts[2]),
|
|
166
|
+
dateStr: `${dateParts[0]}-${String(dateParts[1] + 1).padStart(2, '0')}-${String(dateParts[2]).padStart(2, '0')}`,
|
|
167
|
+
download: item[1],
|
|
168
|
+
upload: item[2]
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Парсит "грязный" JSON-подобный объект JavaScript, который отдает Padavan.
|
|
175
|
+
* Особенности: ключи без кавычек, использование HEX чисел.
|
|
176
|
+
* @param {string} str Строка JS кода (например "var si_new = { ... };").
|
|
177
|
+
* @returns {any} Распаршенный объект или null в случае ошибки.
|
|
178
|
+
*/
|
|
179
|
+
export function parseLooseJson(str) {
|
|
180
|
+
try {
|
|
181
|
+
// Убираем объявление переменной и точку с запятой
|
|
182
|
+
let clean = str.replace(/^var\s+\w+\s*=\s*/, '').replace(/;$/, '');
|
|
183
|
+
// Оборачиваем ключи в кавычки
|
|
184
|
+
clean = clean.replace(/([a-zA-Z0-9_]+)\s*:/g, '"$1":');
|
|
185
|
+
// Одинарные кавычки в двойные
|
|
186
|
+
clean = clean.replace(/'/g, '"');
|
|
187
|
+
// HEX в десятичные
|
|
188
|
+
clean = clean.replace(/0x([0-9a-fA-F]+)/g, (_match, hex) => parseInt(hex, 16).toString());
|
|
189
|
+
return JSON.parse(clean);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
};
|