@sberpay-cli/cli-diagnostic 0.0.0-stage → 0.4.0-dev.4
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 +4 -2
- package/THIRD_PARTY_NOTICES.md +6028 -0
- package/bin/sberpay-cli-diagnostic.mjs +17 -0
- package/build-info.json +9 -0
- package/dist/npm-entry.mjs +22456 -0
- package/manifest.json +22 -0
- package/package/README.md +64 -0
- package/package/SHA256SUMS +14 -0
- package/package/THIRD_PARTY_NOTICES.md +5917 -0
- package/package/bin/sberpay-cli.mjs +17 -0
- package/package/config/logging.yaml +53 -0
- package/package/dist/binding-worker.mjs +31932 -0
- package/package/dist/cli.mjs +39867 -0
- package/package/helpers/darwin/sberpay-se +0 -0
- package/package/helpers/linux-arm64/sberpay-tpm +0 -0
- package/package/helpers/linux-x64/sberpay-tpm +0 -0
- package/package/package.json +34 -0
- package/package/skill/sberpay/SKILL.md +280 -0
- package/package/skill/sberpay/references/invoice-id.md +106 -0
- package/package/skill/sberpay/scripts/invoice-id.mjs +286 -0
- package/package/skill/sberpay/scripts/page-probe.js +133 -0
- package/package.json +30 -3
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Находит id счёта SberPay (bankInvoiceId) в сетевом журнале браузера после нажатия кнопки SberPay.
|
|
3
|
+
// Правило: берём последний id, пришедший до первого шага подтверждения (Сбер ID, 3-D Secure, OTP, оплата в SDK).
|
|
4
|
+
// Вход — наблюдения по порядку: [{ url, body?, redirect? }], вывод page-probe.js ({ observations }), HAR
|
|
5
|
+
// или текст со ссылками (например, список запросов из Chrome DevTools MCP). Вывод — JSON в stdout.
|
|
6
|
+
// Коды выхода: 0 — id найден, 1 — не найден, 2 — вход не прочитан или не разобран.
|
|
7
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
// Хосты SberPay Web SDK («шторка»): ?orderId=<bankInvoiceId>. Хост выбирает runtime-config SDK.
|
|
11
|
+
const SDK_HOSTS = ["platiecom.ru", "ekompay.ru", "emoplati.ru"];
|
|
12
|
+
// Платёжные страницы эквайринга Сбера (payeko.ru, *.sberbank.ru): ?orderId= или ?mdOrder=.
|
|
13
|
+
const ACQUIRING_HOSTS = ["payeko.ru", "sberbank.ru"];
|
|
14
|
+
const PAYMENT_HOSTS = [...SDK_HOSTS, ...ACQUIRING_HOSTS, "sber.ru"];
|
|
15
|
+
// Ключи, которые есть только у Сбера (sberPayOrderId — ЮKassa).
|
|
16
|
+
const STRONG_KEYS = ["bankInvoiceId", "sbolBankInvoiceId", "sberPayOrderId"];
|
|
17
|
+
// Так магазин может назвать и свой заказ: вне хостов Сбера такой кандидат слабый.
|
|
18
|
+
const WEAK_KEYS = ["mdOrder", "invoiceId"];
|
|
19
|
+
const ID_VALUE = "[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}";
|
|
20
|
+
const KEY_RE = new RegExp(`["']?\\b(${[...STRONG_KEYS, ...WEAK_KEYS].join("|")})["']?\\s*[:=]\\s*["']?(${ID_VALUE})(?![0-9a-fA-F-])`, "g");
|
|
21
|
+
const SESSION_ORDER_RE = new RegExp(`"orderId"\\s*:\\s*"(${ID_VALUE})"`, "g");
|
|
22
|
+
const DEEPLINK_RE = /sberpay:\/\/[^\s"'<>\\]+/gi;
|
|
23
|
+
const URL_RE = /https?:\/\/[^\s"'<>\\]+/gi;
|
|
24
|
+
const OBS_URL_RE = /(?:https?|sberpay):\/\/[^\s"'<>\\\]]+/i;
|
|
25
|
+
|
|
26
|
+
function parseUrl(raw) {
|
|
27
|
+
try {
|
|
28
|
+
return new URL(raw);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hostIn(hostname, hosts) {
|
|
35
|
+
return hosts.some((h) => hostname === h || hostname.endsWith(`.${h}`));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isPaymentHost = (u) => hostIn(u.hostname, PAYMENT_HOSTS);
|
|
39
|
+
// ACS 3-D Secure: первая метка хоста ровно acs/3ds/tds с цифрами (acs3.bank.ru), но не acsbapp.com.
|
|
40
|
+
const isAcsHost = (u) => /^(acs|3ds|tds)[\d-]*$/i.test(u.hostname.split(".")[0] ?? "");
|
|
41
|
+
|
|
42
|
+
// Шаги, после которых id уже не меняется, а дальше идёт подтверждение человеком. Проверяются только хост и путь.
|
|
43
|
+
// Общие пути (/challenge, /otp) — только на платёжных хостах: у магазина так бывают антибот и вход по SMS.
|
|
44
|
+
const BOUNDARIES = [
|
|
45
|
+
{ reason: "sberid", test: (u) => u.hostname === "id.sber.ru" || /\/CSAFront\/|\/oidc\/sberbank_id\//i.test(u.pathname) },
|
|
46
|
+
{ reason: "sdk-pay", test: (u) => /\/sdk-gateway\/v\d+\/(authWeb|listCardsWeb|payOnlineWeb|payQrWeb|payBinding|fastPayment)\b/i.test(u.pathname) },
|
|
47
|
+
{
|
|
48
|
+
reason: "3ds",
|
|
49
|
+
test: (u) => isAcsHost(u) || /\/(acs|3ds|3dsecure|threeds)(\/|$)/i.test(u.pathname) || (isPaymentHost(u) && /\/challenge(\/|$)/i.test(u.pathname)),
|
|
50
|
+
},
|
|
51
|
+
{ reason: "otp", test: (u) => isPaymentHost(u) && /\/(otp|otp-binding|fraudmon-review-otp)(\/|$)/i.test(u.pathname) },
|
|
52
|
+
{ reason: "card-submit", test: (u) => /\/(paymentOrder|paymentOrderBinding|processform)\.do$/i.test(u.pathname) },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** UUID или 32 hex → 32 hex в нижнем регистре; иначе null. */
|
|
56
|
+
export function normalizeId(raw) {
|
|
57
|
+
const hex = String(raw ?? "").replace(/-/g, "").toLowerCase();
|
|
58
|
+
return /^[0-9a-f]{32}$/.test(hex) && !/^0+$/.test(hex) ? hex : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function toUuid(hex) {
|
|
62
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Снимает экранирование JSON (&, \/), HTML (&) и URL (%26, %3A…, только ASCII).
|
|
66
|
+
function decodeText(text) {
|
|
67
|
+
return text
|
|
68
|
+
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
69
|
+
.replace(/\\\//g, "/")
|
|
70
|
+
.replace(/&/g, "&")
|
|
71
|
+
.replace(/%([2-7][0-9a-fA-F])/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function idsInUrl(raw) {
|
|
75
|
+
const u = parseUrl(raw);
|
|
76
|
+
if (!u) return [];
|
|
77
|
+
if (hostIn(u.hostname, SDK_HOSTS)) return [{ kind: "sdk-frame", raw: u.searchParams.get("orderId") }];
|
|
78
|
+
if (hostIn(u.hostname, ACQUIRING_HOSTS)) {
|
|
79
|
+
return ["mdOrder", "orderId"].map((p) => ({ kind: "acquiring-page", raw: u.searchParams.get(p) }));
|
|
80
|
+
}
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function idsInDeeplink(deeplink) {
|
|
85
|
+
const m = /[?&]bankInvoiceId=([0-9a-fA-F-]{32,36})/.exec(deeplink);
|
|
86
|
+
return m ? [{ kind: "deeplink", raw: m[1], deeplink }] : [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Диплинки и поля-ключи; bankInvoiceId= внутри самого диплинка отдельным полем не считаем.
|
|
90
|
+
function idsInKeysAndDeeplinks(text, weakKeys) {
|
|
91
|
+
const found = [];
|
|
92
|
+
const deeplinks = [...text.matchAll(DEEPLINK_RE)];
|
|
93
|
+
const insideDeeplink = (pos) => deeplinks.some((d) => pos >= d.index && pos < d.index + d[0].length);
|
|
94
|
+
for (const m of deeplinks) found.push({ pos: m.index, items: idsInDeeplink(m[0]) });
|
|
95
|
+
for (const m of text.matchAll(KEY_RE)) {
|
|
96
|
+
if (insideDeeplink(m.index)) continue;
|
|
97
|
+
found.push({ pos: m.index, items: [{ kind: `field:${m[1]}`, raw: m[2], weak: weakKeys && WEAK_KEYS.includes(m[1]) }] });
|
|
98
|
+
}
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function idsInText(text, weakKeys) {
|
|
103
|
+
const found = idsInKeysAndDeeplinks(text, weakKeys);
|
|
104
|
+
for (const m of text.matchAll(URL_RE)) found.push({ pos: m.index, items: idsInUrl(m[0]) });
|
|
105
|
+
return found.sort((a, b) => a.pos - b.pos).flatMap((f) => f.items);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isSessionIdWeb(raw) {
|
|
109
|
+
return /\/sdk-gateway\/v\d+\/sessionIdWeb\b/i.test(parseUrl(raw)?.pathname ?? "");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Адрес хоста Сбера разбираем по параметрам; любой другой — ищем внутри диплинки и ключи (в т. ч. URL-кодированные).
|
|
113
|
+
function idsInAddress(raw) {
|
|
114
|
+
const text = String(raw);
|
|
115
|
+
if (text.startsWith("sberpay:")) return idsInDeeplink(decodeText(text));
|
|
116
|
+
const direct = idsInUrl(text).filter((c) => c.raw);
|
|
117
|
+
return direct.length > 0 ? direct : idsInKeysAndDeeplinks(decodeText(text), true).flatMap((f) => f.items);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Кандидаты из одного наблюдения в порядке появления. Тело запроса не смотрим — только ответы и адреса. */
|
|
121
|
+
export function candidatesOf(obs) {
|
|
122
|
+
const host = parseUrl(obs.url);
|
|
123
|
+
const weakKeys = !(host && isPaymentHost(host));
|
|
124
|
+
const items = [obs.url, obs.redirect].filter(Boolean).flatMap(idsInAddress);
|
|
125
|
+
if (typeof obs.body === "string" && obs.body.length > 0) {
|
|
126
|
+
const text = decodeText(obs.body);
|
|
127
|
+
items.push(...idsInText(text, weakKeys));
|
|
128
|
+
if (isSessionIdWeb(obs.url)) {
|
|
129
|
+
for (const m of text.matchAll(SESSION_ORDER_RE)) items.push({ kind: "sdk-session", raw: m[1] });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return items.flatMap((c) => {
|
|
133
|
+
const value = normalizeId(c.raw);
|
|
134
|
+
if (!value) return [];
|
|
135
|
+
return [{ kind: c.kind, value, ...(c.deeplink ? { deeplink: c.deeplink } : {}), ...(c.weak ? { weak: true } : {}) }];
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function boundaryOf(raw) {
|
|
140
|
+
const u = parseUrl(raw);
|
|
141
|
+
const b = u && BOUNDARIES.find((x) => x.test(u));
|
|
142
|
+
return b ? b.reason : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function shortUrl(raw) {
|
|
146
|
+
const u = parseUrl(raw);
|
|
147
|
+
return u ? `${u.origin}${u.pathname}` : String(raw).slice(0, 120);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function payArgs(winner, seen) {
|
|
151
|
+
const link = seen.findLast((c) => c.value === winner.value && c.deeplink)?.deeplink;
|
|
152
|
+
return { deeplink: link ?? null, pay: ["--md-order", winner.value] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function alternativesOf(winner, seen) {
|
|
156
|
+
const byValue = new Map();
|
|
157
|
+
for (const c of seen) if (c.value !== winner.value) byValue.set(c.value, { value: c.value, kind: c.kind, index: c.index });
|
|
158
|
+
return [...byValue.values()];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Последний id счёта до первого шага подтверждения. Граница засчитывается только после первого кандидата:
|
|
163
|
+
* вход через Сбер ID на самом сайте (до нажатия SberPay) не обрезает журнал. Слабые кандидаты (invoiceId и
|
|
164
|
+
* mdOrder с хоста магазина) выигрывают, только если сильных нет.
|
|
165
|
+
*/
|
|
166
|
+
export function findInvoiceId(observations) {
|
|
167
|
+
const seen = [];
|
|
168
|
+
let cutoff = null;
|
|
169
|
+
for (const [index, obs] of observations.entries()) {
|
|
170
|
+
const reason = boundaryOf(obs.url);
|
|
171
|
+
if (reason && seen.length > 0) {
|
|
172
|
+
cutoff = { index, reason, url: shortUrl(obs.url) };
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
seen.push(...candidatesOf(obs).map((c) => ({ ...c, index, url: obs.url })));
|
|
176
|
+
}
|
|
177
|
+
if (seen.length === 0) {
|
|
178
|
+
return {
|
|
179
|
+
found: false,
|
|
180
|
+
cutoff,
|
|
181
|
+
hint: "id счёта не найден. Проверь, что журнал снят после нажатия SberPay и до входа в Сбер ID; если на экране только QR — возьми ссылку sberpay:// из QR или спроси клиента",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const strong = seen.filter((c) => !c.weak);
|
|
185
|
+
const winner = (strong.length > 0 ? strong : seen).at(-1);
|
|
186
|
+
const kinds = [...new Set(seen.filter((c) => c.value === winner.value).map((c) => c.kind))];
|
|
187
|
+
return {
|
|
188
|
+
found: true,
|
|
189
|
+
bankInvoiceId: winner.value,
|
|
190
|
+
uuid: toUuid(winner.value),
|
|
191
|
+
...payArgs(winner, seen),
|
|
192
|
+
source: { index: winner.index, kind: winner.kind, url: shortUrl(winner.url) },
|
|
193
|
+
confirmedBy: kinds,
|
|
194
|
+
alternatives: alternativesOf(winner, seen),
|
|
195
|
+
cutoff,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function fromHar(har) {
|
|
200
|
+
return [...har.log.entries]
|
|
201
|
+
.sort((a, b) => String(a.startedDateTime).localeCompare(String(b.startedDateTime)))
|
|
202
|
+
.map((e) => {
|
|
203
|
+
const content = e.response?.content ?? {};
|
|
204
|
+
const body = content.encoding === "base64" && content.text ? Buffer.from(content.text, "base64").toString("utf8") : content.text;
|
|
205
|
+
return { url: e.request?.url, body, redirect: e.response?.redirectURL || undefined };
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function fromText(text) {
|
|
210
|
+
return text.split(/\r?\n/).flatMap((line) => {
|
|
211
|
+
const m = OBS_URL_RE.exec(line);
|
|
212
|
+
return m ? [{ url: m[0] }] : [];
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const MAX_WRAP_DEPTH = 4;
|
|
217
|
+
|
|
218
|
+
function isUrlList(arr) {
|
|
219
|
+
return arr.length > 0 && arr.every((x) => (x && typeof x.url === "string") || (typeof x === "string" && OBS_URL_RE.test(x)));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Вывод инструментов браузера бывает обёрнут: { result: … }, { requests: [...] }, JSON-строка внутри JSON.
|
|
223
|
+
function findObservations(data, depth) {
|
|
224
|
+
if (depth > MAX_WRAP_DEPTH || data == null) return null;
|
|
225
|
+
if (typeof data === "string") {
|
|
226
|
+
try {
|
|
227
|
+
return findObservations(JSON.parse(data), depth + 1);
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (Array.isArray(data)) return isUrlList(data) ? data.map((x) => (typeof x === "string" ? { url: x } : x)) : null;
|
|
233
|
+
if (typeof data !== "object") return null;
|
|
234
|
+
if (Array.isArray(data.log?.entries)) return fromHar(data);
|
|
235
|
+
if (Array.isArray(data.observations)) return data.observations;
|
|
236
|
+
for (const value of Object.values(data)) {
|
|
237
|
+
const found = findObservations(value, depth + 1);
|
|
238
|
+
if (found) return found;
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Разбирает вход: массив наблюдений, { observations }, HAR, их обёртки из инструментов браузера или текст со ссылками. */
|
|
244
|
+
export function parseInput(text) {
|
|
245
|
+
let data;
|
|
246
|
+
try {
|
|
247
|
+
data = JSON.parse(text);
|
|
248
|
+
} catch {
|
|
249
|
+
return fromText(text);
|
|
250
|
+
}
|
|
251
|
+
if (Array.isArray(data) && data.length === 0) return [];
|
|
252
|
+
const found = findObservations(data, 0);
|
|
253
|
+
if (found) return found;
|
|
254
|
+
throw new Error("ожидается массив наблюдений, { observations }, HAR или текст со ссылками");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function badInput(message) {
|
|
258
|
+
process.stdout.write(`${JSON.stringify({ found: false, error: { code: "BAD_INPUT", message } })}\n`);
|
|
259
|
+
return 2;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function main(argv) {
|
|
263
|
+
const file = argv[2];
|
|
264
|
+
let observations;
|
|
265
|
+
try {
|
|
266
|
+
observations = parseInput(file && file !== "-" ? readFileSync(file, "utf8") : readFileSync(0, "utf8"));
|
|
267
|
+
} catch (e) {
|
|
268
|
+
return badInput(e instanceof Error ? e.message : String(e));
|
|
269
|
+
}
|
|
270
|
+
const result = findInvoiceId(observations.filter((o) => o && typeof o.url === "string"));
|
|
271
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
272
|
+
return result.found ? 0 : 1;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Сравниваем реальные пути: скилл обычно подключён симлинком в ~/.claude/skills.
|
|
276
|
+
function isEntryPoint() {
|
|
277
|
+
try {
|
|
278
|
+
return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
279
|
+
} catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (isEntryPoint()) {
|
|
285
|
+
process.exitCode = main(process.argv);
|
|
286
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
() => {
|
|
2
|
+
// Снимок страницы для поиска id счёта SberPay, когда у браузера нет журнала сети с телами ответов.
|
|
3
|
+
// Передай файл целиком как функцию в evaluate своего браузера. Вызови ДО нажатия SberPay — функция поставит
|
|
4
|
+
// перехват fetch/XHR и наблюдатель ресурсов во всех фреймах того же origin — и ПОСЛЕ, когда появились шторка
|
|
5
|
+
// или QR SberPay. Вернёт { observations }: адреса фреймов и ресурсов, ссылки sberpay:// и найденные в ответах id.
|
|
6
|
+
// Из адресов вырезаны все параметры, кроме orderId, mdOrder и bankInvoiceId; тела ответов не сохраняются.
|
|
7
|
+
// Результат сохрани в файл и передай в invoice-id.mjs. Внутрь кросс-доменных iframe функция не заглядывает
|
|
8
|
+
// (их адрес виден); после перехода на другую страницу перехват пропадает, остаётся снимок фреймов и ресурсов.
|
|
9
|
+
const KEY = "__sberpayProbeV1";
|
|
10
|
+
const URL_HINT = /orderId=|mdOrder=|bankInvoiceId=|sberpay:|id\.sber\.ru|\/CSAFront\/|sdk-gateway/i;
|
|
11
|
+
const TEXT_TYPE = /json|text\/plain|text\/html/i;
|
|
12
|
+
const KEEP_PARAMS = ["orderId", "mdOrder", "bankInvoiceId"];
|
|
13
|
+
const EXTRACT = [
|
|
14
|
+
/sberpay:\/\/[^\s"'<>\\]+/gi,
|
|
15
|
+
/["']?\b(bankInvoiceId|sbolBankInvoiceId|sberPayOrderId|mdOrder|invoiceId|orderId)["']?\s*[:=]\s*["']?[0-9a-fA-F-]{32,36}/g,
|
|
16
|
+
/https?:\/\/[^\s"'<>\\]*(platiecom|ekompay|emoplati|payeko|sberbank)\.ru[^\s"'<>\\]*/gi,
|
|
17
|
+
];
|
|
18
|
+
const MAX_BODY = 500000;
|
|
19
|
+
const MAX_EXTRACT = 4000;
|
|
20
|
+
|
|
21
|
+
const trimUrl = (raw) => {
|
|
22
|
+
try {
|
|
23
|
+
const u = new URL(raw);
|
|
24
|
+
if (u.protocol === "sberpay:") return raw;
|
|
25
|
+
const kept = KEEP_PARAMS.filter((p) => u.searchParams.has(p)).map((p) => `${p}=${encodeURIComponent(u.searchParams.get(p))}`);
|
|
26
|
+
return `${u.origin}${u.pathname}${kept.length ? `?${kept.join("&")}` : ""}`;
|
|
27
|
+
} catch {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Только сами id, диплинки и адреса Сбера — без соседних полей ответа.
|
|
33
|
+
const extract = (text) => {
|
|
34
|
+
const decoded = text.replace(/\\u0026/gi, "&").replace(/\\\//g, "/").replace(/&/g, "&");
|
|
35
|
+
const hits = [];
|
|
36
|
+
for (const re of EXTRACT) {
|
|
37
|
+
for (const m of decoded.matchAll(re)) hits.push([m.index, m.index + m[0].length, m[0].startsWith("http") ? trimUrl(m[0]) : m[0]]);
|
|
38
|
+
}
|
|
39
|
+
const inner = (h) => hits.some((o) => o !== h && o[0] <= h[0] && h[1] <= o[1]);
|
|
40
|
+
return hits.filter((h) => !inner(h)).sort((a, b) => a[0] - b[0]).map((h) => h[2]).join("\n").slice(0, MAX_EXTRACT);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const sameOriginFrames = (win, path, out) => {
|
|
44
|
+
try {
|
|
45
|
+
void win.document.body;
|
|
46
|
+
} catch {
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
out.push({ win, path });
|
|
50
|
+
for (let i = 0; i < win.frames.length; i++) sameOriginFrames(win.frames[i], `${path}/${i}`, out);
|
|
51
|
+
return out;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const watchResources = (win, state) => {
|
|
55
|
+
try {
|
|
56
|
+
win.performance.setResourceTimingBufferSize(5000);
|
|
57
|
+
} catch {}
|
|
58
|
+
try {
|
|
59
|
+
new win.PerformanceObserver((list) => {
|
|
60
|
+
for (const e of list.getEntries()) state.resources.push(e);
|
|
61
|
+
}).observe({ type: "resource", buffered: true });
|
|
62
|
+
} catch {}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const hookFetch = (win, record) => {
|
|
66
|
+
const origFetch = win.fetch;
|
|
67
|
+
if (typeof origFetch !== "function") return;
|
|
68
|
+
win.fetch = async function (...args) {
|
|
69
|
+
const res = await origFetch.apply(this, args);
|
|
70
|
+
try {
|
|
71
|
+
const type = res.headers.get("content-type") || "";
|
|
72
|
+
const length = Number(res.headers.get("content-length") || 0);
|
|
73
|
+
if (TEXT_TYPE.test(type) && length <= MAX_BODY) {
|
|
74
|
+
const url = res.url || (typeof args[0] === "string" ? args[0] : args[0]?.url) || "";
|
|
75
|
+
res.clone().text().then((t) => record(url, res.status, t), () => {});
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
return res;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const hookXhr = (win, record) => {
|
|
83
|
+
const origOpen = win.XMLHttpRequest.prototype.open;
|
|
84
|
+
win.XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
|
85
|
+
this.addEventListener("load", () => {
|
|
86
|
+
try {
|
|
87
|
+
if (!TEXT_TYPE.test(this.getResponseHeader("content-type") || "")) return;
|
|
88
|
+
const t = this.responseType === "" || this.responseType === "text" ? this.responseText
|
|
89
|
+
: this.responseType === "json" ? JSON.stringify(this.response) : null;
|
|
90
|
+
record(this.responseURL || String(url), this.status, t);
|
|
91
|
+
} catch {}
|
|
92
|
+
});
|
|
93
|
+
return origOpen.call(this, method, url, ...rest);
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const arm = (win) => {
|
|
98
|
+
if (win[KEY]) return win[KEY];
|
|
99
|
+
const state = { responses: [], resources: [] };
|
|
100
|
+
Object.defineProperty(win, KEY, { value: state, enumerable: false });
|
|
101
|
+
const record = (url, status, text) => {
|
|
102
|
+
if (typeof text !== "string" || text.length > MAX_BODY) return;
|
|
103
|
+
const body = extract(text);
|
|
104
|
+
if (body) state.responses.push({ t: win.performance.timeOrigin + win.performance.now(), kind: "response", url: trimUrl(url), status, body });
|
|
105
|
+
};
|
|
106
|
+
watchResources(win, state);
|
|
107
|
+
hookFetch(win, record);
|
|
108
|
+
hookXhr(win, record);
|
|
109
|
+
return state;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const observations = [];
|
|
113
|
+
for (const { win, path } of sameOriginFrames(window, "top", [])) {
|
|
114
|
+
const state = arm(win);
|
|
115
|
+
const base = win.performance.timeOrigin;
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
for (const e of [...win.performance.getEntriesByType("resource"), ...state.resources]) {
|
|
118
|
+
const id = `${e.name}@${e.startTime}`;
|
|
119
|
+
if (seen.has(id) || !URL_HINT.test(e.name)) continue;
|
|
120
|
+
seen.add(id);
|
|
121
|
+
observations.push({ t: base + e.startTime, kind: `resource:${e.initiatorType}`, url: trimUrl(e.name), frame: path });
|
|
122
|
+
}
|
|
123
|
+
for (const r of state.responses) observations.push({ ...r, frame: path });
|
|
124
|
+
// Время появления элементов DOM неизвестно — ставим их в начало, чтобы граница их не отрезала.
|
|
125
|
+
observations.push({ t: 0, kind: "location", url: trimUrl(win.location.href), frame: path });
|
|
126
|
+
for (const f of win.document.querySelectorAll("iframe[src]")) observations.push({ t: 0, kind: "iframe", url: trimUrl(f.src), frame: path });
|
|
127
|
+
for (const a of win.document.querySelectorAll("a[href]")) {
|
|
128
|
+
if (/^sberpay:|bankInvoiceId=/i.test(a.href)) observations.push({ t: 0, kind: "link", url: a.href.startsWith("sberpay:") ? a.href : trimUrl(a.href), frame: path });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
observations.sort((a, b) => a.t - b.t);
|
|
132
|
+
return { observations: observations.map(({ t, ...rest }) => rest).filter((o) => o.url) };
|
|
133
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sberpay-cli/cli-diagnostic",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.4.0-dev.4",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"sberpay-cli-diagnostic": "bin/sberpay-cli-diagnostic.mjs"
|
|
7
|
+
},
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": "^22.19.0 || >=24.5.0"
|
|
10
|
+
},
|
|
11
|
+
"os": [
|
|
12
|
+
"darwin",
|
|
13
|
+
"linux"
|
|
14
|
+
],
|
|
15
|
+
"cpu": [
|
|
16
|
+
"x64",
|
|
17
|
+
"arm64"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
"bin/",
|
|
21
|
+
"dist/",
|
|
22
|
+
"package/",
|
|
23
|
+
"manifest.json",
|
|
24
|
+
"build-info.json",
|
|
25
|
+
"THIRD_PARTY_NOTICES.md"
|
|
26
|
+
],
|
|
27
|
+
"license": "UNLICENSED",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"tag": "pilot",
|
|
31
|
+
"registry": "https://registry.npmjs.org"
|
|
32
|
+
}
|
|
6
33
|
}
|