mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
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:
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`),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user