feat(playlist): auto-detect import method that parses pasted provider messages

Adds a deterministic parser (detectProviderImportCandidates in
libs/shared/interfaces) that scans a pasted provider message for URLs, MAC
addresses, and labeled credentials/identity fields (username, password,
serial, device IDs, signatures), classifies the source as Xtream, Stalker,
or an M3U link/body, and returns ranked candidates. Every extracted value is
a verbatim substring or a canonical form from an existing shared normalizer;
detection only proposes — the existing forms and their behavioral probes
(portal discovery, connection test) stay authoritative.

The Add playlist dialog gains a sixth "Auto-detect" method with a textarea
that runs the parser on every edit and renders candidate cards; picking one
switches to the matching import form with the fields prefilled.

Detection runs fully locally; pasted text never leaves the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4
This commit is contained in:
Claude committed 2026-08-11 11:41:47 +00:00
1 parent 5c9411869a
commit 3dee1978f6
35 files changed
+1883 -48

No files matched your search

+1
View File
@@ -35,6 +35,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';
@@ -0,0 +1,387 @@
import {
detectProviderImportCandidates,
ProviderImportCandidate,
} from './provider-import-detection.util';
describe('detectProviderImportCandidates', () => {
const only = (
candidates: ProviderImportCandidate[],
kind: ProviderImportCandidate['kind']
) => candidates.filter((candidate) => candidate.kind === kind);
it('returns nothing for empty or blank input', () => {
expect(detectProviderImportCandidates('')).toEqual([]);
expect(detectProviderImportCandidates(' \n\t ')).toEqual([]);
expect(
detectProviderImportCandidates(undefined as unknown as string)
).toEqual([]);
});
it('returns nothing for prose without credentials or links', () => {
expect(
detectProviderImportCandidates(
'Hello! Thanks for your purchase, enjoy the service.'
)
).toEqual([]);
});
describe('xtream detection', () => {
it('detects a get.php URL and mines the credentials from its query', () => {
const candidates = detectProviderImportCandidates(
'Your playlist: http://tv.example.com:8080/get.php?username=alice&password=s3cret&type=m3u_plus&output=ts'
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(1);
expect(xtream[0]).toMatchObject({
confidence: 'high',
serverUrl: 'http://tv.example.com:8080',
username: 'alice',
password: 's3cret',
suggestedTitle: 'tv.example.com',
});
});
it('offers a get.php link with credentials as a low-ranked plain M3U too', () => {
const candidates = detectProviderImportCandidates(
'http://tv.example.com:8080/get.php?username=alice&password=s3cret&type=m3u_plus'
);
const m3u = only(candidates, 'm3u-url');
expect(m3u).toHaveLength(1);
expect(m3u[0].confidence).toBe('low');
expect(m3u[0].url).toBe(
'http://tv.example.com:8080/get.php?username=alice&password=s3cret&type=m3u_plus'
);
});
it('combines a bare player_api.php URL with labeled credentials', () => {
const candidates = detectProviderImportCandidates(
[
'Server: http://panel.example.org/player_api.php',
'Username: bob',
'Password: hunter2',
].join('\n')
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(1);
expect(xtream[0]).toMatchObject({
confidence: 'high',
serverUrl: 'http://panel.example.org',
username: 'bob',
password: 'hunter2',
});
});
it('leans xtream for labeled username+password with a labeled host and port', () => {
const candidates = detectProviderImportCandidates(
[
'Host: super.tv',
'Port: 8080',
'User: carol',
'Pass: pa55w0rd',
].join('\n')
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(1);
expect(xtream[0]).toMatchObject({
confidence: 'medium',
serverUrl: 'http://super.tv:8080',
username: 'carol',
password: 'pa55w0rd',
suggestedTitle: 'super.tv',
});
});
it('still proposes xtream from a credential pair with no server at all', () => {
const candidates = detectProviderImportCandidates(
'login: dave\nпароль: qwerty123'
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(1);
expect(xtream[0].confidence).toBe('low');
expect(xtream[0].username).toBe('dave');
expect(xtream[0].password).toBe('qwerty123');
expect(xtream[0].serverUrl).toBeUndefined();
});
it('understands Russian labels', () => {
const candidates = detectProviderImportCandidates(
[
'Сервер: http://ru.example.net:2500',
'Логин: ivan',
'Пароль: secret42',
].join('\n')
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(1);
expect(xtream[0]).toMatchObject({
serverUrl: 'http://ru.example.net:2500',
username: 'ivan',
password: 'secret42',
});
});
it('treats credentials in the query of a generic URL as xtream', () => {
const candidates = detectProviderImportCandidates(
'http://portal.example.io/api?username=eve&password=pw&foo=bar'
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(1);
expect(xtream[0].username).toBe('eve');
expect(xtream[0].password).toBe('pw');
});
});
describe('stalker detection', () => {
it('pairs a MAC with a portal-shaped URL at high confidence', () => {
const candidates = detectProviderImportCandidates(
[
'Portal: http://stb.example.com/stalker_portal/c/',
'MAC: 00:1A:79:12:34:56',
].join('\n')
);
const stalker = only(candidates, 'stalker');
expect(stalker).toHaveLength(1);
expect(stalker[0]).toMatchObject({
confidence: 'high',
portalUrl: 'http://stb.example.com/stalker_portal/c/',
macAddress: '00:1A:79:12:34:56',
suggestedTitle: 'stb.example.com',
});
});
it('accepts /c/ and portal.php shaped URLs as portals', () => {
for (const url of [
'http://p.example.com/c/',
'http://p.example.com/portal.php',
'http://p.example.com/server/load.php',
]) {
const candidates = detectProviderImportCandidates(
`${url}\n00-1A-79-AB-CD-EF`
);
const stalker = only(candidates, 'stalker');
expect(stalker[0]?.portalUrl).toBe(url);
expect(stalker[0]?.confidence).toBe('high');
}
});
it('falls back to a generic URL at medium confidence', () => {
const candidates = detectProviderImportCandidates(
'http://somehost.example.net/tv\nmac: 00:1a:79:aa:bb:cc'
);
const stalker = only(candidates, 'stalker');
expect(stalker).toHaveLength(1);
expect(stalker[0].confidence).toBe('medium');
expect(stalker[0].portalUrl).toBe('http://somehost.example.net/tv');
expect(stalker[0].macAddress).toBe('00:1A:79:AA:BB:CC');
});
it('proposes a low-confidence stalker candidate for a lone MAC', () => {
const candidates = detectProviderImportCandidates(
'Ваш MAC 00:1A:79:00:11:22 активирован'
);
const stalker = only(candidates, 'stalker');
expect(stalker).toHaveLength(1);
expect(stalker[0].confidence).toBe('low');
expect(stalker[0].portalUrl).toBeUndefined();
});
it('recognizes a bare Infomir MAC but not a bare foreign 12-hex run', () => {
const infomir = detectProviderImportCandidates('mac 001a79deadbe');
expect(only(infomir, 'stalker')).toHaveLength(1);
expect(only(infomir, 'stalker')[0].macAddress).toBe(
'00:1A:79:DE:AD:BE'
);
const foreign = detectProviderImportCandidates(
'token aabbccddeeff issued'
);
expect(only(foreign, 'stalker')).toHaveLength(0);
});
it('accepts a labeled non-Infomir MAC', () => {
const candidates = detectProviderImportCandidates(
'MAC: A0:B1:C2:D3:E4:F5\nhttp://p.example.com/c/'
);
const stalker = only(candidates, 'stalker');
expect(stalker).toHaveLength(1);
expect(stalker[0].macAddress).toBe('A0:B1:C2:D3:E4:F5');
});
it('captures serial, device IDs and signatures', () => {
const deviceId1 = 'a'.repeat(64);
const deviceId2 = 'b'.repeat(64);
const signature1 = 'c'.repeat(64);
const signature2 = 'd'.repeat(64);
const candidates = detectProviderImportCandidates(
[
'Portal: http://stb.example.com/c/',
'MAC: 00:1A:79:12:34:56',
'Serial Number: 0123456789AB',
`Device ID: ${deviceId1}`,
`Device ID 2: ${deviceId2}`,
`Signature: ${signature1}`,
`Signature 2: ${signature2}`,
].join('\n')
);
const stalker = only(candidates, 'stalker');
expect(stalker[0]).toMatchObject({
serialNumber: '0123456789AB',
deviceId1,
deviceId2,
signature1,
signature2,
});
});
it('rejects non-hex device IDs and signatures instead of guessing', () => {
const candidates = detectProviderImportCandidates(
[
'MAC: 00:1A:79:12:34:56',
'Device ID: see-attachment',
'Signature: n/a',
].join('\n')
);
const stalker = only(candidates, 'stalker');
expect(stalker[0].deviceId1).toBeUndefined();
expect(stalker[0].signature1).toBeUndefined();
});
it('does not read a MAC out of a 64-hex device ID blob', () => {
const candidates = detectProviderImportCandidates(
`device id: ${'001a79'.repeat(10)}aabb`
);
expect(only(candidates, 'stalker')).toHaveLength(0);
});
it('produces one candidate per MAC when several are handed out', () => {
const candidates = detectProviderImportCandidates(
[
'http://multi.example.com/c/',
'00:1A:79:11:11:11',
'00:1A:79:22:22:22',
].join('\n')
);
const stalker = only(candidates, 'stalker');
expect(stalker).toHaveLength(2);
expect(stalker.map((candidate) => candidate.macAddress)).toEqual([
'00:1A:79:11:11:11',
'00:1A:79:22:22:22',
]);
});
it('gives labeled credentials to the stalker candidate, not a competing xtream guess', () => {
const candidates = detectProviderImportCandidates(
[
'Portal: http://stb.example.com/c/',
'MAC: 00:1A:79:12:34:56',
'Login: stbuser',
'Password: stbpass',
].join('\n')
);
expect(only(candidates, 'xtream')).toHaveLength(0);
const stalker = only(candidates, 'stalker');
expect(stalker[0].username).toBe('stbuser');
expect(stalker[0].password).toBe('stbpass');
});
});
describe('m3u detection', () => {
it('detects .m3u and .m3u8 links at high confidence', () => {
const candidates = detectProviderImportCandidates(
'List 1: https://lists.example.com/main.m3u\nList 2: https://lists.example.com/backup.m3u8'
);
const m3u = only(candidates, 'm3u-url');
expect(m3u).toHaveLength(2);
expect(m3u.every((c) => c.confidence === 'high')).toBe(true);
expect(m3u[0].url).toBe('https://lists.example.com/main.m3u');
expect(m3u[0].suggestedTitle).toBe('lists.example.com');
});
it('strips trailing punctuation from a link found in prose', () => {
const candidates = detectProviderImportCandidates(
'Your playlist (https://lists.example.com/main.m3u).'
);
expect(only(candidates, 'm3u-url')[0].url).toBe(
'https://lists.example.com/main.m3u'
);
});
it('does not turn an .m3u link with query credentials into an xtream server', () => {
const candidates = detectProviderImportCandidates(
'https://lists.example.com/files/list.m3u8?username=u&password=p'
);
expect(only(candidates, 'xtream')).toHaveLength(0);
expect(only(candidates, 'm3u-url')).toHaveLength(1);
});
it('routes a pasted playlist body to the raw-text import', () => {
const body =
'#EXTM3U\n#EXTINF:-1 tvg-id="one",Channel One\nhttp://streams.example.com/1.ts';
const candidates = detectProviderImportCandidates(body);
expect(candidates).toEqual([
{ kind: 'm3u-text', confidence: 'high', text: body },
]);
});
});
describe('mixed messages and ranking', () => {
it('surfaces every source found in a combined provider message', () => {
const candidates = detectProviderImportCandidates(
[
'Xtream: http://x.example.com:8080/get.php?username=u1&password=p1',
'Backup M3U: http://x.example.com:8080/files/backup.m3u',
'Stalker portal: http://s.example.com/c/',
'MAC: 00:1A:79:99:88:77',
].join('\n')
);
expect(only(candidates, 'stalker')).toHaveLength(1);
expect(only(candidates, 'xtream')).toHaveLength(1);
expect(only(candidates, 'm3u-url').length).toBeGreaterThanOrEqual(1);
// High-confidence candidates sort ahead of low-confidence ones.
const ranks = candidates.map((candidate) => candidate.confidence);
const firstLow = ranks.indexOf('low');
const lastHigh = ranks.lastIndexOf('high');
expect(firstLow === -1 || lastHigh < firstLow).toBe(true);
});
it('dedupes an URL pasted twice', () => {
const candidates = detectProviderImportCandidates(
'https://lists.example.com/main.m3u\nhttps://lists.example.com/main.m3u'
);
expect(only(candidates, 'm3u-url')).toHaveLength(1);
});
it('caps the number of returned candidates', () => {
const urls = Array.from(
{ length: 12 },
(_, index) => `https://lists.example.com/list-${index}.m3u`
).join('\n');
expect(
detectProviderImportCandidates(urls).length
).toBeLessThanOrEqual(6);
});
});
});
@@ -0,0 +1,280 @@
import {
DetectedUrl,
extractLabeledFields,
extractMacAddresses,
extractUrls,
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 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.
* 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;
const MAX_CANDIDATES = 6;
/**
* 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.
if (EXTM3U_HEADER_PATTERN.test(raw)) {
return [{ kind: 'm3u-text', confidence: 'high', text: raw }];
}
const urls = extractUrls(raw);
const labeled = extractLabeledFields(raw, urls);
const macs = extractMacAddresses(raw, 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.
const portal = pickStalkerPortalUrl(urls, labeled);
for (const macAddress of macs) {
candidates.push(
compact({
kind: 'stalker',
confidence: portal?.confidence ?? 'low',
portalUrl: portal?.url,
macAddress,
serialNumber: labeled.serialNumber,
deviceId1: labeled.deviceId1,
deviceId2: labeled.deviceId2,
signature1: labeled.signature1,
signature2: labeled.signature2,
username: labeled.username,
password: labeled.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);
candidates.push(
compact({
kind: 'xtream',
confidence: username && password ? 'high' : 'medium',
serverUrl: safeXtreamServerUrl(url.raw),
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: url.raw,
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),
})
);
}
// ── 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);
}
function pickStalkerPortalUrl(
urls: DetectedUrl[],
labeled: LabeledFields
): { url: string; confidence: ProviderImportConfidence } | null {
const shaped = urls.find((url) => url.role === 'stalker');
if (shaped) {
return { url: shaped.raw, 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: generic.raw, confidence: 'medium' };
}
return 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) => {
const key = [
candidate.kind,
candidate.url ?? '',
candidate.serverUrl ?? '',
candidate.username ?? '',
candidate.portalUrl ?? '',
candidate.macAddress ?? '',
].join('|');
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,308 @@
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;
}
const URL_PATTERN = /https?:\/\/[^\s<>"'`]+/gi;
const TRAILING_PUNCTUATION = /[),.;:!?\]»›]+$/;
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;
const MAX_URLS = 8;
const MAX_MACS = 4;
interface FieldMatcher {
field: keyof LabeledFields;
pattern: RegExp;
validate?: (value: string) => boolean;
}
// 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).
//
// All matchers require an explicit `:` or `=` separator. Provider messages
// overwhelmingly use "Label: value"; a bare "login example" in prose is
// exactly the false positive the separator rule exists to reject. The label
// vocabulary is deliberately small (English + Russian) until a corpus of
// real provider messages justifies growing it.
const FIELD_MATCHERS: FieldMatcher[] = [
{
field: 'deviceId2',
pattern:
/(?<![\p{L}\p{N}_])device[\s_-]*id[\s_-]*2\s*[:=]\s*([^\s,;|]+)/iu,
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
},
{
field: 'deviceId1',
pattern:
/(?<![\p{L}\p{N}_])device[\s_-]*id[\s_-]*1?\s*[:=]\s*([^\s,;|]+)/iu,
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
},
{
field: 'signature2',
pattern:
/(?<![\p{L}\p{N}_])sig(?:nature)?[\s_-]*2\s*[:=]\s*([^\s,;|]+)/iu,
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
},
{
field: 'signature1',
pattern:
/(?<![\p{L}\p{N}_])sig(?:nature)?[\s_-]*1?\s*[:=]\s*([^\s,;|]+)/iu,
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
},
{
field: 'serialNumber',
pattern:
/(?<![\p{L}\p{N}_])(?:serial[\s_-]*(?:number|no)?|s\/n|sn|серийный(?:[\s_-]*номер)?)\s*[:=]\s*([^\s,;|]+)/iu,
validate: (value) => value.length <= 64,
},
{
field: 'macAddress',
pattern:
/(?<![\p{L}\p{N}_])(?:mac(?:[\s_-]*address)?|мак(?:[\s_-]*адрес)?)\s*[:=]\s*([0-9A-Fa-f:. -]{12,23})/iu,
validate: (value) => normalizeStalkerMacAddress(value) !== null,
},
{
field: 'username',
pattern:
/(?<![\p{L}\p{N}_])(?:user[\s_-]*name|login|user|логин|пользователь)\s*[:=]\s*([^\s,;|]+)/iu,
},
{
field: 'password',
pattern:
/(?<![\p{L}\p{N}_])(?:pass[\s_-]*word|pass|pwd|пароль)\s*[:=]\s*([^\s,;|]+)/iu,
},
{
field: 'host',
pattern:
/(?<![\p{L}\p{N}_])(?:server|portal|host|dns|url|сервер|портал|хост|адрес)(?:[\s_-]*(?:url|address|адрес))?\s*[:=]\s*([^\s,;|]+)/iu,
},
{
field: 'port',
pattern: /(?<![\p{L}\p{N}_])(?:port|порт)\s*[:=]\s*(\d{2,5})\b/iu,
},
];
/**
* `String.prototype.matchAll` equivalent — the web build's TS lib target
* predates it. Works on a fresh RegExp copy so the module-level patterns
* never carry `lastIndex` state between calls.
*/
function allMatches(pattern: RegExp, text: string): RegExpExecArray[] {
const matcher = new RegExp(pattern.source, pattern.flags);
const matches: RegExpExecArray[] = [];
let match: RegExpExecArray | null;
while ((match = matcher.exec(text)) !== null) {
matches.push(match);
if (match[0] === '') {
matcher.lastIndex += 1;
}
}
return matches;
}
export function extractUrls(text: string): DetectedUrl[] {
const detected: DetectedUrl[] = [];
const seen = new Set<string>();
for (const match of allMatches(URL_PATTERN, text)) {
const raw = match[0].replace(TRAILING_PUNCTUATION, '');
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) });
if (detected.length >= MAX_URLS) {
break;
}
}
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';
}
export function extractLabeledFields(
text: string,
urls: DetectedUrl[]
): 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, urls);
const masked = maskSpans(text, spans);
const fields: LabeledFields = {};
for (const { field, pattern, validate } of FIELD_MATCHERS) {
if (fields[field] !== undefined) {
continue;
}
const match = pattern.exec(field === 'host' ? text : masked);
if (!match) {
continue;
}
if (field === 'host' && isInsideSpan(spans, match.index)) {
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 match of allMatches(SEPARATED_MAC_PATTERN, text)) {
push(normalizeStalkerMacAddress(match[0]));
}
for (const match of allMatches(BARE_INFOMIR_MAC_PATTERN, text)) {
push(normalizeStalkerMacAddress(match[0]));
}
return macs.slice(0, MAX_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 {
const host = labeled.host;
if (!host) {
return undefined;
}
if (/^https?:\/\//i.test(host)) {
return host;
}
if (!HOSTISH_PATTERN.test(host) || !host.includes('.')) {
return undefined;
}
const portSuffix =
host.includes(':') || !labeled.port ? '' : `:${labeled.port}`;
return `http://${host}${portSuffix}`;
}
function urlSpans(
text: string,
urls: DetectedUrl[]
): Array<[number, number]> {
const spans: Array<[number, number]> = [];
for (const url of urls) {
let from = 0;
for (;;) {
const index = text.indexOf(url.raw, from);
if (index === -1) {
break;
}
spans.push([index, index + url.raw.length]);
from = index + url.raw.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('');
}
function isInsideSpan(
spans: Array<[number, number]>,
index: number
): boolean {
return spans.some(([start, end]) => index > start && index < end);
}
function stripWrapping(value: string): string {
return value.trim().replace(WRAPPING_CHARS, '').trim();
}