feat(playlist): recognize decorated reseller labels in auto-detect import

Reseller messages dress labels in Unicode math alphabets and arrow
separators to slip past chat spam filters. The detector now NFKC-normalizes
the pasted text before scanning, so decorated labels fold back to the plain
ASCII the vocabulary matches, and accepts arrow/geometric/dingbat glyphs,
'>' and a space-surrounded dash as label separators alongside ':' and '='.
A hyphen glued inside a word stays prose, so hyphenated words are still not
read as labeled values.

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 20:19:14 +00:00
1 parent c26596abb2
commit de6d012a57
3 files changed
+116 -36

No files matched your search

@@ -126,6 +126,53 @@ describe('detectProviderImportCandidates', () => {
});
});
it('reads decorated reseller messages (math-alphabet labels, arrow separators)', () => {
// Real-world shape: labels dressed in Unicode math monospace to
// slip past chat spam filters, ➤ as the separator, ├◉ decoration.
const candidates = detectProviderImportCandidates(
[
'◉𝙿𝙾𝚁𝚃𝙰𝙻➤ http://tv.example.nl:80/',
'├◉𝚄𝚂𝙴𝚁➤ 6b13aa64d3',
'├◉𝙿𝙰𝚂𝚂➤ 602bb462ab75',
].join('\n')
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(1);
expect(xtream[0]).toMatchObject({
serverUrl: 'http://tv.example.nl',
username: '6b13aa64d3',
password: '602bb462ab75',
});
});
it.each([
['arrow', 'USER ➤ carol\nPASS ➤ pa55'],
['triangle', 'User ► carol\nPass ► pa55'],
['unicode arrow', 'login → carol\npassword → pa55'],
['ascii gt', 'USER > carol\nPASS > pa55'],
['spaced dash', 'User - carol\nPass - pa55'],
])(
'accepts the %s label separator',
(_variant, message) => {
const xtream = only(
detectProviderImportCandidates(message),
'xtream'
);
expect(xtream).toHaveLength(1);
expect(xtream[0].username).toBe('carol');
expect(xtream[0].password).toBe('pa55');
}
);
it('does not read a hyphenated word as a labeled value', () => {
expect(
detectProviderImportCandidates(
'Our user-friendly setup takes a minute.'
)
).toEqual([]);
});
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'
@@ -17,10 +17,15 @@ import {
*
* 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
* 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.
* "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.
* 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
@@ -79,14 +84,18 @@ export function detectProviderImportCandidates(
// 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.
// 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 }];
}
const urls = extractUrls(raw);
const labeled = extractLabeledFields(raw, urls);
const macs = extractMacAddresses(raw, labeled.macAddress);
// All scanning runs on one consistently normalized string so label
// positions, URL spans and masking agree with each other.
const normalized = raw.normalize('NFKC');
const urls = extractUrls(normalized);
const labeled = extractLabeledFields(normalized, urls);
const macs = extractMacAddresses(normalized, labeled.macAddress);
const candidates: ProviderImportCandidate[] = [];
// ── Stalker: a MAC address is the strongest signal there is. Labeled
@@ -61,71 +61,95 @@ interface FieldMatcher {
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);
// 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).
// 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.
// 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:
/(?<![\p{L}\p{N}_])device[\s_-]*id[\s_-]*2\s*[:=]\s*([^\s,;|]+)/iu,
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
pattern: labeledPattern(String.raw`device[\s_-]*id[\s_-]*2`),
validate: isHexIdentity,
},
{
field: 'deviceId1',
pattern:
/(?<![\p{L}\p{N}_])device[\s_-]*id[\s_-]*1?\s*[:=]\s*([^\s,;|]+)/iu,
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
pattern: labeledPattern(String.raw`device[\s_-]*id[\s_-]*1?`),
validate: isHexIdentity,
},
{
field: 'signature2',
pattern:
/(?<![\p{L}\p{N}_])sig(?:nature)?[\s_-]*2\s*[:=]\s*([^\s,;|]+)/iu,
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
pattern: labeledPattern(String.raw`sig(?:nature)?[\s_-]*2`),
validate: isHexIdentity,
},
{
field: 'signature1',
pattern:
/(?<![\p{L}\p{N}_])sig(?:nature)?[\s_-]*1?\s*[:=]\s*([^\s,;|]+)/iu,
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
pattern: labeledPattern(String.raw`sig(?:nature)?[\s_-]*1?`),
validate: isHexIdentity,
},
{
field: 'serialNumber',
pattern:
/(?<![\p{L}\p{N}_])(?:serial[\s_-]*(?:number|no)?|s\/n|sn|серийный(?:[\s_-]*номер)?)\s*[:=]\s*([^\s,;|]+)/iu,
pattern: labeledPattern(
String.raw`serial[\s_-]*(?:number|no)?|s\/n|sn|серийный(?:[\s_-]*номер)?`
),
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,
pattern: labeledPattern(
String.raw`mac(?:[\s_-]*address)?|мак(?:[\s_-]*адрес)?`,
String.raw`[0-9A-Fa-f:. -]{12,23}`
),
validate: (value) => normalizeStalkerMacAddress(value) !== null,
},
{
field: 'username',
pattern:
/(?<![\p{L}\p{N}_])(?:user[\s_-]*name|login|user|логин|пользователь)\s*[:=]\s*([^\s,;|]+)/iu,
pattern: labeledPattern(
String.raw`user[\s_-]*name|login|user|логин|пользователь`
),
},
{
field: 'password',
pattern:
/(?<![\p{L}\p{N}_])(?:pass[\s_-]*word|pass|pwd|пароль)\s*[:=]\s*([^\s,;|]+)/iu,
pattern: labeledPattern(String.raw`pass[\s_-]*word|pass|pwd|пароль`),
},
{
field: 'host',
pattern:
/(?<![\p{L}\p{N}_])(?:server|portal|host|dns|url|сервер|портал|хост|адрес)(?:[\s_-]*(?:url|address|адрес))?\s*[:=]\s*([^\s,;|]+)/iu,
pattern: labeledPattern(
String.raw`(?:server|portal|host|dns|url|сервер|портал|хост|адрес)(?:[\s_-]*(?:url|address|адрес))?`
),
},
{
field: 'port',
pattern: /(?<![\p{L}\p{N}_])(?:port|порт)\s*[:=]\s*(\d{2,5})\b/iu,
pattern: labeledPattern(String.raw`port|порт`, String.raw`\d{2,5}\b`),
},
];