mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
feat(playlist): auto-detect import method that parses pasted provider messages (#1445)
Adds an "Auto-detect" method to the Add playlist dialog: paste the message a provider sent — links, Xtream credentials, a MAC address with device identity — and a deterministic parser recognizes the source(s) and prefills the matching import form. - detectProviderImportCandidates (libs/shared/interfaces) extracts URLs, MAC addresses and labeled fields, classifies each finding as Xtream, Stalker or an M3U link/body, and returns ranked candidates. Pure and synchronous. - Built against a corpus of 19 real reseller handouts kept verbatim in the spec: Unicode "font" labels, arrow/dingbat separators, separator-less hex serials, dual device IDs, multi-MAC lists, bare three-line handouts, and a guard so a parental PIN is never read as the account password. - Detection only proposes: the target form's own validation and behavioral probes remain the sole path into the store, and no pasted text leaves the app. Passwords are masked on candidate cards, including query and HTTP Basic userinfo forms. - Covered by parser, component and dialog unit tests plus two web E2E specs for the paste → pick → prefilled form workflow; i18n for all 19 languages.
This commit is contained in:
1 parent
03719ba51d
commit
bee7df1e02
38 files changed
+3432
-49
No files matched your search
@@ -37,6 +37,7 @@ export * from './lib/portal-activity-item.interface';
|
||||
export * from './lib/portal-debug.interface';
|
||||
export * from './lib/playlist-display-label.util';
|
||||
export * from './lib/portal-playback.interface';
|
||||
export * from './lib/provider-import-detection.util';
|
||||
export * from './lib/provider-overview.util';
|
||||
export * from './lib/random-id.util';
|
||||
export * from './lib/security-policy-error.utils';
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,469 @@
|
||||
import {
|
||||
DetectedUrl,
|
||||
extractLabeledFields,
|
||||
extractMacAddresses,
|
||||
extractUrls,
|
||||
foldDecorativeAlphabets,
|
||||
LabeledFields,
|
||||
labeledHostUrl,
|
||||
} from './provider-import-scan.util';
|
||||
import {
|
||||
extractXtreamCredentialsFromUrl,
|
||||
normalizeXtreamServerUrl,
|
||||
} from './xtream-portal.utils';
|
||||
|
||||
/**
|
||||
* Deterministic detection of playlist sources inside a pasted provider
|
||||
* message ("here are your credentials" mails, reseller chat messages, etc.).
|
||||
*
|
||||
* Design constraints, in order:
|
||||
*
|
||||
* 1. **Every value is a verbatim substring of the NFKC-normalized input**
|
||||
* (or a canonical form produced by an existing shared normalizer such as
|
||||
* `normalizeStalkerMacAddress`). Nothing here invents, completes, or
|
||||
* "fixes" a credential — a password that is almost right is wrong. NFKC
|
||||
* is applied once up front because reseller messages dress labels in
|
||||
* Unicode math alphabets (`𝚄𝚂𝙴𝚁➤`, `𝙿𝙰𝚂𝚂➤`) to slip past chat spam
|
||||
* filters; the folding turns them back into the ASCII they denote, and
|
||||
* real credentials/URLs are ASCII already, so values pass through it
|
||||
* unchanged. The one deliberate exception is `labeledHostUrl`, which
|
||||
* completes a scheme-less labeled host with `http://` and a separately
|
||||
* labeled port — an assembled value, landing in an editable form field.
|
||||
* 2. **Detection proposes, it never decides.** The output prefills the
|
||||
* existing import forms; classification authority stays with the
|
||||
* behavioral probes those forms already run (portal discovery, Xtream
|
||||
* status check). URL-shape guessing shipped broken Stalker configs three
|
||||
* times (#850/#686/#755) — this util must not become a fourth copy.
|
||||
* 3. Heuristics are ranked by strength: a MAC address is the strongest
|
||||
* signal (→ Stalker), a `get.php`/`player_api.php` URL is next
|
||||
* (→ Xtream, credentials ride in the query), a labeled
|
||||
* username+password pair without a MAC leans Xtream, and `.m3u`
|
||||
* extensions mean a plain playlist URL.
|
||||
*
|
||||
* The low-level scanners live in `provider-import-scan.util.ts`.
|
||||
*/
|
||||
export type ProviderImportKind = 'xtream' | 'stalker' | 'm3u-url' | 'm3u-text';
|
||||
|
||||
export type ProviderImportConfidence = 'high' | 'medium' | 'low';
|
||||
|
||||
export interface ProviderImportCandidate {
|
||||
kind: ProviderImportKind;
|
||||
confidence: ProviderImportConfidence;
|
||||
/** Suggested playlist title — the hostname of the detected source. */
|
||||
suggestedTitle?: string;
|
||||
/** `m3u-url`: the playlist link exactly as found in the text. */
|
||||
url?: string;
|
||||
/** `m3u-text`: the pasted playlist body itself. */
|
||||
text?: string;
|
||||
/** `xtream`: server base URL (API suffix stripped). */
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
/** `stalker` */
|
||||
portalUrl?: string;
|
||||
macAddress?: string;
|
||||
serialNumber?: string;
|
||||
deviceId1?: string;
|
||||
deviceId2?: string;
|
||||
signature1?: string;
|
||||
signature2?: string;
|
||||
}
|
||||
|
||||
const GET_PHP_PATH_PATTERN = /\/get\.php$/i;
|
||||
const EXTM3U_HEADER_PATTERN = /^\s*#EXTM3U/i;
|
||||
// DOM-safety guard against pathological pastes, not a business limit: sized
|
||||
// above the scanners' combined caps (64 MACs + 16 URLs), so every candidate
|
||||
// a real multi-account handout produces survives the final slice.
|
||||
const MAX_CANDIDATES = 96;
|
||||
|
||||
/**
|
||||
* Scans a pasted provider message and returns ranked import candidates.
|
||||
* Pure and synchronous — safe to run on every keystroke.
|
||||
*/
|
||||
export function detectProviderImportCandidates(
|
||||
text: string
|
||||
): ProviderImportCandidate[] {
|
||||
const raw = typeof text === 'string' ? text : '';
|
||||
if (!raw.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// A raw playlist body pasted wholesale: every URL inside is a channel
|
||||
// stream, not a source, so URL mining would only produce noise. Route the
|
||||
// whole paste to the raw-text import instead — the ORIGINAL bytes, since
|
||||
// a playlist body is content, not decoration to be folded.
|
||||
if (EXTM3U_HEADER_PATTERN.test(raw)) {
|
||||
return [{ kind: 'm3u-text', confidence: 'high', text: raw }];
|
||||
}
|
||||
|
||||
// All scanning runs on one consistently normalized string so label
|
||||
// positions, URL spans and masking agree with each other. NFKC folds the
|
||||
// math alphabets; the extra pass folds the squared/negative-circled/
|
||||
// regional-indicator "font" letters and circled digits NFKC leaves alone.
|
||||
// Both only touch decorative glyphs — ASCII credentials and URLs, hence
|
||||
// every emitted value, pass through unchanged.
|
||||
const normalized = foldDecorativeAlphabets(raw.normalize('NFKC'));
|
||||
const urls = extractUrls(normalized);
|
||||
const labeled = extractLabeledFields(normalized);
|
||||
const macs = extractMacAddresses(normalized, labeled.macAddress);
|
||||
const candidates: ProviderImportCandidate[] = [];
|
||||
|
||||
// ── Stalker: a MAC address is the strongest signal there is. Labeled
|
||||
// credentials attach here (Stalker portals can require a login too)
|
||||
// rather than seeding a competing Xtream guess.
|
||||
//
|
||||
// Identity fields attach ONLY when the message names exactly one account:
|
||||
// labeled extraction is first-match-wins over the whole text, so with
|
||||
// several MACs there is no reliable way to tell whose serial/device ID a
|
||||
// label belongs to — and a device ID submitted with the wrong MAC is
|
||||
// pinned by the portal permanently. Multi-MAC candidates therefore carry
|
||||
// portal + MAC only, and the user supplies identity per account.
|
||||
// Several MACs meeting portal candidates on DIFFERENT hosts is two
|
||||
// accounts from two panels in one paste — there is no deterministic way
|
||||
// to say which MAC belongs to which portal, so those candidates carry the
|
||||
// MAC alone instead of prefilling half of them with the wrong panel.
|
||||
// The ambiguity is judged over the POOL `pickStalkerPortalUrl` would
|
||||
// actually draw from: portal-shaped URLs when any exist, otherwise the
|
||||
// generic-URL fallback — a guard on the shaped pool alone would let two
|
||||
// root-URL panels slip through the fallback door. A single labeled
|
||||
// "Portal:" line, same-host Real/Panel pairs, and one portal shared by a
|
||||
// whole MAC list stay unambiguous.
|
||||
const shapedPortals = urls.filter((url) => url.role === 'stalker');
|
||||
const portalPool =
|
||||
shapedPortals.length > 0
|
||||
? shapedPortals
|
||||
: labeledHostUrl(labeled) !== undefined
|
||||
? []
|
||||
: urls.filter((url) => url.role === 'generic');
|
||||
const portalAmbiguous =
|
||||
macs.length > 1 &&
|
||||
new Set(
|
||||
portalPool.map((url) => portalInstallationKey(url, labeled.port))
|
||||
).size > 1;
|
||||
const portal = portalAmbiguous
|
||||
? null
|
||||
: pickStalkerPortalUrl(urls, labeled);
|
||||
const identity: LabeledFields = macs.length === 1 ? labeled : {};
|
||||
for (const macAddress of macs) {
|
||||
candidates.push(
|
||||
compact({
|
||||
kind: 'stalker',
|
||||
confidence: portal?.confidence ?? 'low',
|
||||
portalUrl: portal?.url,
|
||||
macAddress,
|
||||
serialNumber: identity.serialNumber,
|
||||
deviceId1: identity.deviceId1,
|
||||
deviceId2: identity.deviceId2,
|
||||
signature1: identity.signature1,
|
||||
signature2: identity.signature2,
|
||||
username: identity.username,
|
||||
password: identity.password,
|
||||
suggestedTitle: hostnameOf(portal?.url),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── Xtream from URLs: an API-shaped path, or credentials in the query of
|
||||
// any non-playlist URL. A `.m3u` link with query credentials stays an M3U
|
||||
// candidate — its path would corrupt the derived server URL.
|
||||
for (const url of urls) {
|
||||
if (url.role === 'm3u' || url.role === 'stalker') {
|
||||
continue;
|
||||
}
|
||||
const creds = extractXtreamCredentialsFromUrl(url.raw);
|
||||
if (url.role !== 'xtream-api' && !creds) {
|
||||
continue;
|
||||
}
|
||||
const username =
|
||||
creds?.username ??
|
||||
(macs.length === 0 ? labeled.username : undefined);
|
||||
const password =
|
||||
creds?.password ??
|
||||
(macs.length === 0 ? labeled.password : undefined);
|
||||
// A separately labeled "Port:" completes a port-less API URL, same
|
||||
// as the labeled-host fallback already does.
|
||||
const completedRaw = completeWithLabeledPort(url, labeled.port);
|
||||
candidates.push(
|
||||
compact({
|
||||
kind: 'xtream',
|
||||
confidence: username && password ? 'high' : 'medium',
|
||||
serverUrl: safeXtreamServerUrl(completedRaw),
|
||||
username,
|
||||
password,
|
||||
suggestedTitle: url.parsed.hostname,
|
||||
})
|
||||
);
|
||||
// `get.php` doubles as the M3U download endpoint, so the same link is
|
||||
// honestly offered as a plain playlist too — ranked low because the
|
||||
// Xtream import unlocks VOD/series where the flat M3U does not.
|
||||
if (creds && GET_PHP_PATH_PATTERN.test(url.parsed.pathname)) {
|
||||
candidates.push({
|
||||
kind: 'm3u-url',
|
||||
confidence: 'low',
|
||||
url: completedRaw,
|
||||
suggestedTitle: url.parsed.hostname,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Xtream from labeled credentials alone (username + password with no
|
||||
// MAC in sight leans Xtream), attached to the best available server URL.
|
||||
if (
|
||||
macs.length === 0 &&
|
||||
labeled.username &&
|
||||
labeled.password &&
|
||||
!candidates.some((candidate) => candidate.kind === 'xtream')
|
||||
) {
|
||||
const serverUrl =
|
||||
labeledHostUrl(labeled) ??
|
||||
urls.find((url) => url.role === 'generic')?.raw;
|
||||
candidates.push(
|
||||
compact({
|
||||
kind: 'xtream',
|
||||
confidence: serverUrl ? 'medium' : 'low',
|
||||
serverUrl: serverUrl
|
||||
? safeXtreamServerUrl(serverUrl)
|
||||
: undefined,
|
||||
username: labeled.username,
|
||||
password: labeled.password,
|
||||
suggestedTitle: hostnameOf(serverUrl),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bare handout: exactly "URL, token, token" and nothing else readable.
|
||||
// "http://host\nVictoria89\nVictoria89" carries no labels at all — the
|
||||
// shape IS the message (server, then username, then password, in reading
|
||||
// order). Deliberately the last resort and ranked low: it only fires when
|
||||
// no label, MAC or API URL produced anything, the single URL is generic,
|
||||
// and the message contains nothing beyond those three lines, so prose
|
||||
// cannot be misread as credentials.
|
||||
if (
|
||||
macs.length === 0 &&
|
||||
!labeled.username &&
|
||||
!labeled.password &&
|
||||
candidates.length === 0 &&
|
||||
urls.length === 1 &&
|
||||
urls[0].role === 'generic'
|
||||
) {
|
||||
const tokens = bareCredentialTokens(normalized, urls[0]);
|
||||
if (tokens) {
|
||||
candidates.push(
|
||||
compact({
|
||||
kind: 'xtream',
|
||||
confidence: 'low',
|
||||
serverUrl: safeXtreamServerUrl(urls[0].raw),
|
||||
username: tokens[0],
|
||||
password: tokens[1],
|
||||
suggestedTitle: urls[0].parsed.hostname,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plain playlist links.
|
||||
for (const url of urls) {
|
||||
if (url.role !== 'm3u') {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
kind: 'm3u-url',
|
||||
confidence: 'high',
|
||||
url: url.raw,
|
||||
suggestedTitle: url.parsed.hostname,
|
||||
});
|
||||
}
|
||||
|
||||
return sortByConfidence(dedupe(candidates)).slice(0, MAX_CANDIDATES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambiguity key for a portal candidate: origin plus the installation's base
|
||||
* path. Origin (not hostname) separates panels on different ports while URL
|
||||
* normalization drops default ports, so the Real/Panel `:80`-vs-bare pairs
|
||||
* of scanner dumps compare equal. The base path separates tenant installs
|
||||
* sharing one origin (`/a/stalker_portal/c/` vs `/b/…`), while the known
|
||||
* endpoint suffixes of ONE install (`/c/`, `portal.php`, `server/load.php`)
|
||||
* are stripped so its alternate endpoints reduce to the same key. The key is
|
||||
* derived AFTER labeled-port completion, so `http://panel:8080/c/` next to
|
||||
* `http://panel/c/` plus a `Port: 8080` line reads as one panel, exactly as
|
||||
* the picker would complete it.
|
||||
*/
|
||||
function portalInstallationKey(
|
||||
url: DetectedUrl,
|
||||
labeledPort: string | undefined
|
||||
): string {
|
||||
let parsed = url.parsed;
|
||||
const completed = completeWithLabeledPort(url, labeledPort);
|
||||
if (completed !== url.raw) {
|
||||
try {
|
||||
parsed = new URL(completed);
|
||||
} catch {
|
||||
parsed = url.parsed;
|
||||
}
|
||||
}
|
||||
const base = parsed.pathname
|
||||
.replace(/\/(?:stalker_portal\/)?c\/?$/i, '')
|
||||
.replace(/\/stalker_portal\/?$/i, '')
|
||||
.replace(/\/portal\.php$/i, '')
|
||||
.replace(/\/(?:stalker_portal\/)?server\/load\.php$/i, '')
|
||||
.replace(/\/+$/, '');
|
||||
return `${parsed.origin}${base}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes a found URL with a separately labeled port — "Portal: http://x/…"
|
||||
* plus "Port: 8080" on its own line is a real handout shape (corpus #5).
|
||||
* Applied only when the URL itself states no explicit port, so a written
|
||||
* `:80` is never overridden by the label.
|
||||
*/
|
||||
/**
|
||||
* Whether the URL states a port itself. Read off the RAW authority rather
|
||||
* than `parsed.port`, which the WHATWG parser blanks for a protocol's default
|
||||
* (`http://host:80` reports no port) — a written `:80` is still the user's
|
||||
* explicit choice and must not be overwritten by a labeled one. An IPv6
|
||||
* literal's own colons are skipped by cutting the bracketed address first.
|
||||
*/
|
||||
function hasExplicitPort(raw: string): boolean {
|
||||
const afterScheme = raw.slice(raw.indexOf('://') + 3);
|
||||
const authority = afterScheme.split(/[/?#]/, 1)[0];
|
||||
const afterAddress = authority.startsWith('[')
|
||||
? authority.slice(authority.indexOf(']') + 1)
|
||||
: authority;
|
||||
return /:\d+$/.test(afterAddress);
|
||||
}
|
||||
|
||||
function completeWithLabeledPort(
|
||||
url: DetectedUrl,
|
||||
port: string | undefined
|
||||
): string {
|
||||
if (!port || hasExplicitPort(url.raw)) {
|
||||
return url.raw;
|
||||
}
|
||||
const { protocol, hostname, pathname, search } = url.parsed;
|
||||
return `${protocol}//${hostname}:${port}${pathname}${search}`;
|
||||
}
|
||||
|
||||
function pickStalkerPortalUrl(
|
||||
urls: DetectedUrl[],
|
||||
labeled: LabeledFields
|
||||
): { url: string; confidence: ProviderImportConfidence } | null {
|
||||
const shaped = urls.find((url) => url.role === 'stalker');
|
||||
if (shaped) {
|
||||
return {
|
||||
url: completeWithLabeledPort(shaped, labeled.port),
|
||||
confidence: 'high',
|
||||
};
|
||||
}
|
||||
const labeledUrl = labeledHostUrl(labeled);
|
||||
if (labeledUrl) {
|
||||
return { url: labeledUrl, confidence: 'high' };
|
||||
}
|
||||
const generic = urls.find((url) => url.role === 'generic');
|
||||
if (generic) {
|
||||
return {
|
||||
url: completeWithLabeledPort(generic, labeled.port),
|
||||
confidence: 'medium',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* For the bare "URL, token, token" shape: returns the two credential-shaped
|
||||
* tokens when the message consists of exactly three non-empty lines — the
|
||||
* URL and two single tokens — and nothing else.
|
||||
*/
|
||||
function bareCredentialTokens(
|
||||
text: string,
|
||||
url: DetectedUrl
|
||||
): [string, string] | null {
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
if (lines.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
const tokens = lines.filter((line) => !line.includes(url.raw));
|
||||
if (tokens.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
const credentialShaped = /^[A-Za-z0-9._@-]{3,64}$/;
|
||||
return credentialShaped.test(tokens[0]) && credentialShaped.test(tokens[1])
|
||||
? [tokens[0], tokens[1]]
|
||||
: null;
|
||||
}
|
||||
|
||||
function safeXtreamServerUrl(raw: string): string | undefined {
|
||||
try {
|
||||
return normalizeXtreamServerUrl(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function hostnameOf(url: string | undefined): string | undefined {
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops undefined/empty fields so candidates stay clean to render and diff. */
|
||||
function compact(candidate: ProviderImportCandidate): ProviderImportCandidate {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(candidate)) {
|
||||
if (value !== undefined && value !== '') {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result as unknown as ProviderImportCandidate;
|
||||
}
|
||||
|
||||
function dedupe(
|
||||
candidates: ProviderImportCandidate[]
|
||||
): ProviderImportCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
return candidates.filter((candidate) => {
|
||||
// JSON rather than a joined string: a credential may legitimately
|
||||
// contain the separator (URL-encoded in the query), and two different
|
||||
// accounts must never collapse into one key because of it. Same
|
||||
// server + username with a DIFFERENT password is a distinct account
|
||||
// (e.g. a rotation message), so the password is part of the key.
|
||||
const key = JSON.stringify([
|
||||
candidate.kind,
|
||||
candidate.url ?? '',
|
||||
candidate.serverUrl ?? '',
|
||||
candidate.username ?? '',
|
||||
candidate.password ?? '',
|
||||
candidate.portalUrl ?? '',
|
||||
candidate.macAddress ?? '',
|
||||
]);
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const CONFIDENCE_RANK: Record<ProviderImportConfidence, number> = {
|
||||
high: 0,
|
||||
medium: 1,
|
||||
low: 2,
|
||||
};
|
||||
|
||||
function sortByConfidence(
|
||||
candidates: ProviderImportCandidate[]
|
||||
): ProviderImportCandidate[] {
|
||||
// Array.prototype.sort is stable, so equal-confidence candidates keep
|
||||
// their assembly order: Stalker (strongest signal) → Xtream → M3U.
|
||||
return [...candidates].sort(
|
||||
(a, b) => CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
import { normalizeStalkerMacAddress } from './stalker-mac-address.util';
|
||||
import { isFullStalkerPortalUrl } from './stalker-portal-mode.util';
|
||||
|
||||
/**
|
||||
* Low-level scanners for the provider-message auto-detect
|
||||
* (`provider-import-detection.util.ts` assembles their findings into
|
||||
* candidates). Everything here returns verbatim substrings of the input, or
|
||||
* canonical forms produced by the existing shared normalizers.
|
||||
*/
|
||||
|
||||
export type UrlRole = 'xtream-api' | 'm3u' | 'stalker' | 'generic';
|
||||
|
||||
export interface DetectedUrl {
|
||||
/** Exact substring as found, trailing punctuation stripped. */
|
||||
raw: string;
|
||||
parsed: URL;
|
||||
role: UrlRole;
|
||||
}
|
||||
|
||||
export interface LabeledFields {
|
||||
username?: string;
|
||||
password?: string;
|
||||
/** Raw value of a server/portal/host/url label — URL- or host-shaped. */
|
||||
host?: string;
|
||||
port?: string;
|
||||
macAddress?: string;
|
||||
serialNumber?: string;
|
||||
deviceId1?: string;
|
||||
deviceId2?: string;
|
||||
signature1?: string;
|
||||
signature2?: string;
|
||||
}
|
||||
|
||||
// `|` ends a URL: it is not a legal URL character, but it IS the inline field
|
||||
// delimiter of one-line handouts ("Server: http://host|User: alice"). Letting
|
||||
// the span run through it would mask the very next label and lose that
|
||||
// account entirely.
|
||||
const URL_PATTERN = /https?:\/\/[^\s<>"'`|]+/gi;
|
||||
const TRAILING_PUNCTUATION = /[),.;:!?\]»›]+$/;
|
||||
|
||||
/**
|
||||
* Strips sentence punctuation a URL picked up from prose ("see http://x/y.")
|
||||
* — unless doing so breaks the address itself. `]` is both a closing quote in
|
||||
* prose and the structural end of an IPv6 authority (`http://[2001:db8::1]`),
|
||||
* and only parsing can tell the two apart: the stripped form wins whenever it
|
||||
* still parses, otherwise the original stands. Values with no scheme parse
|
||||
* either way, so they keep the plain stripped form.
|
||||
*/
|
||||
function stripTrailingProsePunctuation(value: string): string {
|
||||
const stripped = value.replace(TRAILING_PUNCTUATION, '');
|
||||
if (stripped === value || parsesAsUrl(stripped) || !parsesAsUrl(value)) {
|
||||
return stripped;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsesAsUrl(value: string): boolean {
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const M3U_PATH_PATTERN = /\.m3u8?$/i;
|
||||
const XTREAM_API_PATH_PATTERN = /\/(?:get|player_api|panel_api)\.php$/i;
|
||||
const STALKER_PATH_PATTERN = /\/(?:portal\.php|c\/?)$/i;
|
||||
|
||||
// Uniform-separator MAC (00:1A:79:AA:BB:CC, hyphens or dots alike). The
|
||||
// backreference rejects mixed separators, and because `\b` never sits between
|
||||
// two word characters a MAC-shaped window inside a longer hex blob (a 64-hex
|
||||
// device ID) cannot match.
|
||||
const SEPARATED_MAC_PATTERN = /\b[0-9A-F]{2}([:.-])[0-9A-F]{2}(?:\1[0-9A-F]{2}){4}\b/gi;
|
||||
// A bare 12-hex run is only trusted as a MAC when it carries Infomir's OUI —
|
||||
// any 12 hex digits would false-positive on ids, tokens and hashes.
|
||||
const BARE_INFOMIR_MAC_PATTERN = /\b001A79[0-9A-F]{6}\b/gi;
|
||||
|
||||
/** Hex-shaped identity values (device IDs / signatures are SHA-256 hex). */
|
||||
const HEX_IDENTITY_PATTERN = /^[0-9A-Fa-f]{16,64}$/;
|
||||
const HOSTISH_PATTERN = /^[a-z0-9][a-z0-9.-]*(?::\d{2,5})?$/i;
|
||||
|
||||
const WRAPPING_CHARS = /^["'`«<([]+|["'`»>)\]]+$/g;
|
||||
|
||||
// Both caps are DOM-safety guards against pathological pastes (a log file
|
||||
// full of MAC-shaped hex would otherwise render thousands of cards), sized
|
||||
// far beyond any real handout — reseller and scanner dumps top out around a
|
||||
// couple dozen accounts. The assembler's candidate cap leaves headroom for
|
||||
// both scanners together, so neither truncates real input before assembly.
|
||||
const MAX_URLS = 16;
|
||||
const MAX_MACS = 64;
|
||||
|
||||
interface FieldMatcher {
|
||||
field: keyof LabeledFields;
|
||||
pattern: RegExp;
|
||||
validate?: (value: string) => boolean;
|
||||
}
|
||||
|
||||
/** A label must not continue a word ("sublogin" is not a login label). */
|
||||
const LABEL_LOOKBEHIND = String.raw`(?<![\p{L}\p{N}_])`;
|
||||
|
||||
/**
|
||||
* What may stand between a label and its value. Reseller messages decorate
|
||||
* this freely — `USER➤ abc`, `PASS ► abc`, `Login → abc` — so besides plain
|
||||
* `:`/`=`/`>` any run of arrow/geometric/dingbat glyphs (U+2190–21FF,
|
||||
* U+25A0–25FF, U+2600–27BF, U+2B00–2BFF) counts. A dash counts only when it
|
||||
* stands alone between spaces: "PASS - abc" is a separator, "user-friendly"
|
||||
* is prose. Some symbol IS still required — a bare "login example" in prose
|
||||
* must keep failing to match.
|
||||
*/
|
||||
const LABEL_SEPARATOR = String.raw`(?:\s*(?:[:=>]|[←-⇿■-◿☀-➿⬀-⯿])+\s*|\s+[-–—]+\s+)`;
|
||||
|
||||
const DEFAULT_VALUE = String.raw`[^\s,;|]+`;
|
||||
|
||||
function labeledPattern(labels: string, value = DEFAULT_VALUE): RegExp {
|
||||
return new RegExp(
|
||||
`${LABEL_LOOKBEHIND}(?:${labels})${LABEL_SEPARATOR}(${value})`,
|
||||
'iu'
|
||||
);
|
||||
}
|
||||
|
||||
const isHexIdentity = (value: string) => HEX_IDENTITY_PATTERN.test(value);
|
||||
|
||||
/**
|
||||
* Folds decorative Unicode "font" alphabets that NFKC deliberately leaves
|
||||
* alone — squared (🄰), negative circled (🅐), negative squared (🅰) and
|
||||
* regional-indicator (🇦) capitals, plus the dingbat circled digits (➀ ❶ ➌)
|
||||
* and ⓿ — back to the ASCII they depict. NFKC already handles the math
|
||||
* alphabets (`𝚄𝚂𝙴𝚁`) and plain circled letters (Ⓐ); these blocks carry no
|
||||
* compatibility decomposition because Unicode treats them as symbols, but in
|
||||
* a reseller message `🅟🅐🅢🅢➤` is just PASS in a costume. Callers run this
|
||||
* AFTER `normalize('NFKC')`, and the label matchers below then see plain
|
||||
* ASCII.
|
||||
*/
|
||||
export function foldDecorativeAlphabets(text: string): string {
|
||||
const chars = Array.from(text);
|
||||
let result = '';
|
||||
for (let index = 0; index < chars.length; ) {
|
||||
const code = chars[index].codePointAt(0) as number;
|
||||
if (isRegionalIndicator(code)) {
|
||||
// Regional indicators are ambiguous: a run of EXACTLY two is a
|
||||
// real flag emoji (🇳🇱) that must survive untouched — folding it
|
||||
// would glue "NL" onto whatever follows and break an adjacent
|
||||
// label's word boundary. Fancy-text words (🇺🇸🇪🇷, 🇺🇷🇱) are runs
|
||||
// of three or more, so only those fold to letters.
|
||||
let end = index;
|
||||
while (
|
||||
end < chars.length &&
|
||||
isRegionalIndicator(chars[end].codePointAt(0) as number)
|
||||
) {
|
||||
end += 1;
|
||||
}
|
||||
const run = chars.slice(index, end);
|
||||
result +=
|
||||
run.length >= 3
|
||||
? run
|
||||
.map((char) =>
|
||||
String.fromCharCode(
|
||||
0x41 +
|
||||
((char.codePointAt(0) as number) -
|
||||
REGIONAL_INDICATOR_A)
|
||||
)
|
||||
)
|
||||
.join('')
|
||||
: run.join('');
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
result += foldDecorativeCodePoint(code) ?? chars[index];
|
||||
index += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const REGIONAL_INDICATOR_A = 0x1f1e6;
|
||||
const REGIONAL_INDICATOR_Z = 0x1f1ff;
|
||||
|
||||
function isRegionalIndicator(code: number): boolean {
|
||||
return code >= REGIONAL_INDICATOR_A && code <= REGIONAL_INDICATOR_Z;
|
||||
}
|
||||
|
||||
const DECORATIVE_LATIN_RANGES: Array<[number, number]> = [
|
||||
[0x1f130, 0x1f149], // 🄰 squared A–Z
|
||||
[0x1f150, 0x1f169], // 🅐 negative circled A–Z
|
||||
[0x1f170, 0x1f189], // 🅰 negative squared A–Z
|
||||
];
|
||||
|
||||
const DECORATIVE_DIGIT_RANGES: Array<[number, number]> = [
|
||||
[0x2776, 0x277e], // ❶ negative circled 1–9
|
||||
[0x2780, 0x2788], // ➀ circled sans-serif 1–9
|
||||
[0x278a, 0x2792], // ➊ negative circled sans-serif 1–9
|
||||
];
|
||||
|
||||
function foldDecorativeCodePoint(code: number): string | null {
|
||||
for (const [start, end] of DECORATIVE_LATIN_RANGES) {
|
||||
if (code >= start && code <= end) {
|
||||
return String.fromCharCode(0x41 + (code - start));
|
||||
}
|
||||
}
|
||||
for (const [start, end] of DECORATIVE_DIGIT_RANGES) {
|
||||
if (code >= start && code <= end) {
|
||||
return String.fromCharCode(0x31 + (code - start));
|
||||
}
|
||||
}
|
||||
return code === 0x24ff ? '0' : null; // ⓿
|
||||
}
|
||||
|
||||
// Order matters only for readability — each matcher targets its own field and
|
||||
// the numbered variants ("device id 2") are structured so the unnumbered
|
||||
// pattern cannot swallow them (its optional `1` never matches a literal `2`,
|
||||
// and the required separator then fails on the digit).
|
||||
//
|
||||
// The label vocabulary is deliberately small (English + Russian) until a
|
||||
// corpus of real provider messages justifies growing it. Callers hand this
|
||||
// table NFKC-normalized text, which is what folds the decorative
|
||||
// math-alphabet labels (`𝚄𝚂𝙴𝚁`, `𝙿𝙰𝚂𝚂`) into the plain ASCII matched here.
|
||||
const FIELD_MATCHERS: FieldMatcher[] = [
|
||||
{
|
||||
field: 'deviceId2',
|
||||
pattern: labeledPattern(String.raw`device[\s_-]*id[\s_-]*2`),
|
||||
validate: isHexIdentity,
|
||||
},
|
||||
{
|
||||
field: 'deviceId1',
|
||||
pattern: labeledPattern(String.raw`device[\s_-]*id[\s_-]*1?`),
|
||||
validate: isHexIdentity,
|
||||
},
|
||||
{
|
||||
field: 'signature2',
|
||||
pattern: labeledPattern(String.raw`sig(?:nature)?[\s_-]*2`),
|
||||
validate: isHexIdentity,
|
||||
},
|
||||
{
|
||||
field: 'signature1',
|
||||
pattern: labeledPattern(String.raw`sig(?:nature)?[\s_-]*1?`),
|
||||
validate: isHexIdentity,
|
||||
},
|
||||
{
|
||||
field: 'serialNumber',
|
||||
// `s[\s._/-]?n` covers SN, S/N, S.N and the spaced "S N" handouts use.
|
||||
pattern: labeledPattern(
|
||||
String.raw`serial[\s_-]*(?:number|no)?|s[\s._/-]?n|серийный(?:[\s_-]*номер)?`
|
||||
),
|
||||
validate: (value) => value.length <= 64,
|
||||
},
|
||||
{
|
||||
field: 'serialNumber',
|
||||
// Separator-less handout ("SN 38415545307A3"): allowed only because
|
||||
// the hex-shaped value cannot be mistaken for prose.
|
||||
pattern: new RegExp(
|
||||
LABEL_LOOKBEHIND +
|
||||
String.raw`(?:serial|s[\s._/-]?n)\s+([0-9A-Fa-f]{8,64})\b`,
|
||||
'iu'
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'macAddress',
|
||||
// Turkish panels label it "MAC ADRESİ" — the trailing İ/ı/i is listed
|
||||
// in a class because JS case-insensitive matching does not fold the
|
||||
// Turkish dotted/dotless I to ASCII `i`.
|
||||
pattern: labeledPattern(
|
||||
String.raw`mac(?:[\s_-]*(?:address|adres[iİıI]?))?|мак(?:[\s_-]*адрес)?`,
|
||||
String.raw`[0-9A-Fa-f:. -]{12,23}`
|
||||
),
|
||||
validate: (value) => normalizeStalkerMacAddress(value) !== null,
|
||||
},
|
||||
{
|
||||
field: 'username',
|
||||
pattern: labeledPattern(
|
||||
String.raw`user[\s_-]*name|login|user|логин|пользователь`
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'password',
|
||||
// "ADULT PASS" / "PARENTAL PASS" is the parental-control PIN, not the
|
||||
// account password — the variable-length lookbehinds skip those
|
||||
// occurrences and let the engine find an unqualified PASS elsewhere.
|
||||
pattern: new RegExp(
|
||||
`${LABEL_LOOKBEHIND}(?<!adult[\\s_-]{0,4})(?<!parent(?:al)?[\\s_-]{0,4})` +
|
||||
String.raw`(?:pass[\s_-]*word|pass|pwd|пароль)` +
|
||||
`${LABEL_SEPARATOR}(${DEFAULT_VALUE})`,
|
||||
'iu'
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'host',
|
||||
pattern: labeledPattern(
|
||||
String.raw`(?:server|portal|host|dns|url|сервер|портал|хост|адрес)(?:[\s_-]*(?:url|address|адрес))?`
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'port',
|
||||
pattern: labeledPattern(String.raw`port|порт`, String.raw`\d{2,5}\b`),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Fresh stateful matcher for a module-level global pattern, so `lastIndex`
|
||||
* never leaks between calls. Callers drive it lazily with `exec` and stop at
|
||||
* their cap — detection runs on every keystroke, and materializing every
|
||||
* match of a pathological paste up front would defeat the caps' purpose.
|
||||
* (Also avoids `String.prototype.matchAll`, which the web build's TS lib
|
||||
* target predates.)
|
||||
*/
|
||||
function freshMatcher(pattern: RegExp): RegExp {
|
||||
return new RegExp(pattern.source, pattern.flags);
|
||||
}
|
||||
|
||||
export function extractUrls(text: string): DetectedUrl[] {
|
||||
const detected: DetectedUrl[] = [];
|
||||
const seen = new Set<string>();
|
||||
const matcher = freshMatcher(URL_PATTERN);
|
||||
let match: RegExpExecArray | null;
|
||||
while (
|
||||
detected.length < MAX_URLS &&
|
||||
(match = matcher.exec(text)) !== null
|
||||
) {
|
||||
const raw = stripTrailingProsePunctuation(match[0]);
|
||||
if (seen.has(raw)) {
|
||||
continue;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
seen.add(raw);
|
||||
detected.push({ raw, parsed, role: classifyUrl(raw, parsed) });
|
||||
}
|
||||
return detected;
|
||||
}
|
||||
|
||||
function classifyUrl(raw: string, parsed: URL): UrlRole {
|
||||
if (M3U_PATH_PATTERN.test(parsed.pathname)) {
|
||||
return 'm3u';
|
||||
}
|
||||
if (XTREAM_API_PATH_PATTERN.test(parsed.pathname)) {
|
||||
return 'xtream-api';
|
||||
}
|
||||
if (
|
||||
isFullStalkerPortalUrl(raw) ||
|
||||
STALKER_PATH_PATTERN.test(parsed.pathname)
|
||||
) {
|
||||
return 'stalker';
|
||||
}
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
/**
|
||||
* The "both device IDs" marker: 1 and 2 joined by any symbol run ("1&2",
|
||||
* "1/2", "1💥2" — superscript ¹² are already folded to 1/2 by NFKC) or by
|
||||
* plain whitespace. Bare "12" stays a number, not a marker.
|
||||
*/
|
||||
const DUAL_MARKER = String.raw`1(?:\s*[^\p{L}\p{N}\s]+\s*|\s+)2`;
|
||||
|
||||
/**
|
||||
* "DEVICE ID=> 1&2 <hex>" — the marker may sit before or after the
|
||||
* separator, and the separator itself may be plain whitespace ("DeviceID
|
||||
* 1💥2 <hex>"), which is safe here only because the 16–64-hex value shape
|
||||
* cannot be prose.
|
||||
*/
|
||||
const DUAL_DEVICE_ID_PATTERN = new RegExp(
|
||||
LABEL_LOOKBEHIND +
|
||||
String.raw`device[\s_-]*ids?` +
|
||||
`(?:[\\s_-]*${DUAL_MARKER}(?:${LABEL_SEPARATOR}|\\s+)|(?:${LABEL_SEPARATOR}|\\s+)${DUAL_MARKER}\\s+)` +
|
||||
String.raw`([0-9A-Fa-f]{16,64})\b`,
|
||||
'iu'
|
||||
);
|
||||
|
||||
export function extractLabeledFields(text: string): LabeledFields {
|
||||
// Query strings ARE label-shaped (`?username=u&password=p`), so labeled
|
||||
// extraction must never look inside a URL: credentials that ride in a
|
||||
// query are mined by `extractXtreamCredentialsFromUrl` with proper URL
|
||||
// decoding, and reading them as plain labels would truncate at `&`. Every
|
||||
// matcher therefore runs against a copy with URL spans blanked out — with
|
||||
// one exception: the host/server label legitimately points AT a URL
|
||||
// ("Portal: http://…"), so it reads the raw text, accepting only matches
|
||||
// whose label starts outside every URL span (`&url=…` inside a query
|
||||
// stays rejected).
|
||||
const spans = urlSpans(text);
|
||||
const masked = maskSpans(text, spans);
|
||||
const fields: LabeledFields = {};
|
||||
// "DEVICE ID=> 1&2 <hex>" (or "DEVICE ID 1&2: <hex>") hands one value to
|
||||
// both slots; the per-field matchers below cannot express that, and their
|
||||
// hex validation would otherwise discard the line entirely.
|
||||
const dual = DUAL_DEVICE_ID_PATTERN.exec(masked);
|
||||
if (dual) {
|
||||
fields.deviceId1 = dual[1];
|
||||
fields.deviceId2 = dual[1];
|
||||
}
|
||||
for (const { field, pattern, validate } of FIELD_MATCHERS) {
|
||||
if (fields[field] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
// The host label is the one matcher that reads the RAW text, because
|
||||
// it legitimately points at a URL ("Portal: http://…"). That means it
|
||||
// also meets host-shaped query keys inside unrelated links
|
||||
// (`…/setup?url=guide`), so it keeps scanning past those rather than
|
||||
// giving up — a real `Server:` line further down must still win.
|
||||
const match =
|
||||
field === 'host'
|
||||
? firstMatchOutsideSpans(pattern, text, spans)
|
||||
: pattern.exec(masked);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const value = stripWrapping(match[1] ?? '');
|
||||
if (!value || (validate && !validate(value))) {
|
||||
continue;
|
||||
}
|
||||
fields[field] = value;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function extractMacAddresses(
|
||||
text: string,
|
||||
labeledMac: string | undefined
|
||||
): string[] {
|
||||
const macs: string[] = [];
|
||||
const push = (value: string | null) => {
|
||||
if (value && !macs.includes(value)) {
|
||||
macs.push(value);
|
||||
}
|
||||
};
|
||||
push(normalizeStalkerMacAddress(labeledMac));
|
||||
for (const pattern of [SEPARATED_MAC_PATTERN, BARE_INFOMIR_MAC_PATTERN]) {
|
||||
const matcher = freshMatcher(pattern);
|
||||
let match: RegExpExecArray | null;
|
||||
while (
|
||||
macs.length < MAX_MACS &&
|
||||
(match = matcher.exec(text)) !== null
|
||||
) {
|
||||
push(normalizeStalkerMacAddress(match[0]));
|
||||
}
|
||||
}
|
||||
return macs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a labeled server/host value into an http URL. A scheme-less host is
|
||||
* completed with `http://` and a separately labeled port — the one place a
|
||||
* value is assembled rather than found verbatim, and it lands in an editable
|
||||
* form field, never on the wire directly.
|
||||
*/
|
||||
export function labeledHostUrl(labeled: LabeledFields): string | undefined {
|
||||
// Same sentence-punctuation cleanup the URL scanner applies: a labeled
|
||||
// value takes precedence over the scanned one, so leaving `Server:
|
||||
// http://host!` uncleaned here would hand the forms a hostname DNS can
|
||||
// never resolve while the sanitized scanned URL sat right beside it.
|
||||
const host = labeled.host
|
||||
? stripTrailingProsePunctuation(labeled.host)
|
||||
: undefined;
|
||||
if (!host) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^https?:\/\//i.test(host)) {
|
||||
// Panels often hand out "Portal: http://host" and "Port: 8080" as two
|
||||
// separate lines — a separately labeled port completes a port-less URL.
|
||||
if (labeled.port) {
|
||||
try {
|
||||
const url = new URL(host);
|
||||
if (!url.port) {
|
||||
url.port = labeled.port;
|
||||
return url.toString();
|
||||
}
|
||||
} catch {
|
||||
return host;
|
||||
}
|
||||
}
|
||||
return host;
|
||||
}
|
||||
if (!HOSTISH_PATTERN.test(host) || !host.includes('.')) {
|
||||
return undefined;
|
||||
}
|
||||
const portSuffix =
|
||||
host.includes(':') || !labeled.port ? '' : `:${labeled.port}`;
|
||||
return `http://${host}${portSuffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spans of EVERY URL-shaped run in the text — deliberately not derived from
|
||||
* the capped `extractUrls` result. A URL past that cap still carries a query
|
||||
* that is label-shaped (`?username=…&password=…`), and an unmasked one would
|
||||
* feed the label matchers credentials belonging to a source that never became
|
||||
* a candidate. Spans are cheap index pairs, so the cap that bounds candidate
|
||||
* assembly does not apply here.
|
||||
*/
|
||||
function urlSpans(text: string): Array<[number, number]> {
|
||||
const spans: Array<[number, number]> = [];
|
||||
const matcher = freshMatcher(URL_PATTERN);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = matcher.exec(text)) !== null) {
|
||||
spans.push([match.index, match.index + match[0].length]);
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
function maskSpans(text: string, spans: Array<[number, number]>): string {
|
||||
if (spans.length === 0) {
|
||||
return text;
|
||||
}
|
||||
const chars = text.split('');
|
||||
for (const [start, end] of spans) {
|
||||
for (let index = start; index < end; index += 1) {
|
||||
chars[index] = ' ';
|
||||
}
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* First match of `pattern` whose label does not start inside a URL span.
|
||||
*
|
||||
* Both sequences are produced left to right and the spans never overlap, so
|
||||
* a single forward cursor is enough — rescanning the span list per match
|
||||
* would be quadratic on a paste full of host-shaped query keys, and this
|
||||
* runs synchronously on every keystroke.
|
||||
*/
|
||||
function firstMatchOutsideSpans(
|
||||
pattern: RegExp,
|
||||
text: string,
|
||||
spans: Array<[number, number]>
|
||||
): RegExpExecArray | null {
|
||||
const matcher = freshMatcher(
|
||||
pattern.flags.includes('g')
|
||||
? pattern
|
||||
: new RegExp(pattern.source, `${pattern.flags}g`)
|
||||
);
|
||||
let cursor = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = matcher.exec(text)) !== null) {
|
||||
while (cursor < spans.length && spans[cursor][1] <= match.index) {
|
||||
cursor += 1;
|
||||
}
|
||||
const span = spans[cursor];
|
||||
if (!span || !(match.index > span[0] && match.index < span[1])) {
|
||||
return match;
|
||||
}
|
||||
if (match[0] === '') {
|
||||
matcher.lastIndex += 1;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripWrapping(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const stripped = trimmed.replace(WRAPPING_CHARS, '').trim();
|
||||
// Same hazard as the prose-punctuation cleanup: `]` closes both a bracket
|
||||
// quote and an IPv6 authority, so a strip that breaks the address loses.
|
||||
if (stripped === trimmed || parsesAsUrl(stripped) || !parsesAsUrl(trimmed)) {
|
||||
return stripped;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
@@ -14,7 +14,10 @@ export interface XtreamCredentialsFromUrl {
|
||||
username: string;
|
||||
}
|
||||
|
||||
const XTREAM_API_ENDPOINT_PATTERN = /\/(?:get|player_api)\.php$/i;
|
||||
// panel_api.php is the legacy admin-flavored endpoint some panels hand out;
|
||||
// it identifies an Xtream server just like the other two and must be
|
||||
// stripped the same way, or the transport would append /player_api.php to it.
|
||||
const XTREAM_API_ENDPOINT_PATTERN = /\/(?:get|player_api|panel_api)\.php$/i;
|
||||
|
||||
// Some Xtream panels challenge generic Node HTTP clients while allowing
|
||||
// established IPTV players. Keep API, probe, and download requests aligned.
|
||||
|
||||
Reference in new issue
Block a user