mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
feat(playlist): fold negative-circled labels and parse dual device IDs in auto-detect
Extends the deterministic detector for three real reseller message shapes: - Negative-circled / squared / regional-indicator 'font' capitals and circled digits (🅤🅢🅔🅡, 🅜➌🅤) that NFKC leaves alone are folded to the ASCII they depict before scanning, so decorated USER/PASS/URL labels are recognized. - 'DEVICE ID=> 1&2 <hex>' hands one hex value to both device-ID slots. - 'S N', 'S/N', 'S.N' serial labels, the Turkish 'MAC ADRESİ' label, and a guard so 'ADULT PASS' (the parental PIN) is not read as the account password. All three pasted messages are covered verbatim by new parser tests. 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
+158
-5
No files matched your search
@@ -165,6 +165,28 @@ describe('detectProviderImportCandidates', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('folds negative-circled font labels (🅤🅢🅔🅡 / 🅟🅐🅢🅢) and reads the get.php line', () => {
|
||||
// Verbatim reseller message: negative-circled capitals NFKC does
|
||||
// not fold, ➤➤ separators, and a "🅜➌🅤 🅛🅘🅢🅣" (M3U LIST) get.php
|
||||
// line whose query is the authoritative credential source.
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'╭● 🅤🅡🅛 ➤➤ http://raztv.online:80',
|
||||
'├● 🅤🅢🅔🅡 ➤➤ 94U19T3EAQ68DAP',
|
||||
'├● 🅟🅐🅢🅢 ➤➤ 6uU3DX7OP5',
|
||||
'╰● 🅜➌🅤 🅛🅘🅢🅣 ➤➤ http://raztv.online:80/get.php?username=94U19T3EAQ68DAP&password=6uU3DX7OP5&type=m3u_plus',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0]).toMatchObject({
|
||||
serverUrl: 'http://raztv.online',
|
||||
username: '94U19T3EAQ68DAP',
|
||||
password: '6uU3DX7OP5',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not read a hyphenated word as a labeled value', () => {
|
||||
expect(
|
||||
detectProviderImportCandidates(
|
||||
@@ -331,6 +353,56 @@ describe('detectProviderImportCandidates', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads a dual "DEVICE ID=> 1&2 <hex>" line into both device slots and "S N" as serial', () => {
|
||||
const deviceHex =
|
||||
'FCE5A3B00FA5E2B9207E23FC205536314200BEE0FB2FCF0608FB97B6C7337E7F';
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'http://tv.saartv.cc/stalker_portal/c',
|
||||
'MAC=> 00:1A:79:00:E3:C9',
|
||||
'EXP=> 2030-01-13',
|
||||
'MAX => 10000',
|
||||
'ADULT PASS=> 7700',
|
||||
'S N=> F2DD20855EE0C',
|
||||
`DEVICE ID=> 1&2 ${deviceHex}`,
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(1);
|
||||
expect(stalker[0]).toMatchObject({
|
||||
portalUrl: 'http://tv.saartv.cc/stalker_portal/c',
|
||||
macAddress: '00:1A:79:00:E3:C9',
|
||||
serialNumber: 'F2DD20855EE0C',
|
||||
deviceId1: deviceHex,
|
||||
deviceId2: deviceHex,
|
||||
});
|
||||
// The parental-control PIN is not the account password.
|
||||
expect(stalker[0].password).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads a Turkish-labeled decorated portal message', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'⚙️➤System : MediaHack Pro v7.7 (MAC)',
|
||||
'🔌➤Portal Type : Stalker / XUI (/c/)',
|
||||
'✅➤Tarama Zamanı: 2026-03-05 13:28:23',
|
||||
'🌍➤Portal : http://b1.jinbox.nl:80/c/',
|
||||
'🆔➤MAC ADRESİ: 00:1A:79:B4:EB:EB',
|
||||
'📆➤Son Tarih : Süresiz',
|
||||
'🖥➤Plan : MAG/MAC',
|
||||
'🔊➤ɢᴏʀᴜɴᴛᴜ: Var 👍',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(1);
|
||||
expect(stalker[0]).toMatchObject({
|
||||
portalUrl: 'http://b1.jinbox.nl:80/c/',
|
||||
macAddress: '00:1A:79:B4:EB:EB',
|
||||
});
|
||||
});
|
||||
|
||||
it('gives labeled credentials to the stalker candidate, not a competing xtream guess', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
extractLabeledFields,
|
||||
extractMacAddresses,
|
||||
extractUrls,
|
||||
foldDecorativeAlphabets,
|
||||
LabeledFields,
|
||||
labeledHostUrl,
|
||||
} from './provider-import-scan.util';
|
||||
@@ -91,8 +92,12 @@ export function detectProviderImportCandidates(
|
||||
}
|
||||
|
||||
// All scanning runs on one consistently normalized string so label
|
||||
// positions, URL spans and masking agree with each other.
|
||||
const normalized = raw.normalize('NFKC');
|
||||
// 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, urls);
|
||||
const macs = extractMacAddresses(normalized, labeled.macAddress);
|
||||
|
||||
@@ -86,6 +86,53 @@ function labeledPattern(labels: string, value = DEFAULT_VALUE): RegExp {
|
||||
|
||||
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 {
|
||||
let result = '';
|
||||
for (const char of text) {
|
||||
result +=
|
||||
foldDecorativeCodePoint(char.codePointAt(0) as number) ?? char;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const DECORATIVE_LATIN_RANGES: Array<[number, number]> = [
|
||||
[0x1f130, 0x1f149], // 🄰 squared A–Z
|
||||
[0x1f150, 0x1f169], // 🅐 negative circled A–Z
|
||||
[0x1f170, 0x1f189], // 🅰 negative squared A–Z
|
||||
[0x1f1e6, 0x1f1ff], // 🇦 regional indicator 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`,
|
||||
@@ -118,15 +165,19 @@ const FIELD_MATCHERS: FieldMatcher[] = [
|
||||
},
|
||||
{
|
||||
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\/n|sn|серийный(?:[\s_-]*номер)?`
|
||||
String.raw`serial[\s_-]*(?:number|no)?|s[\s._/-]?n|серийный(?:[\s_-]*номер)?`
|
||||
),
|
||||
validate: (value) => value.length <= 64,
|
||||
},
|
||||
{
|
||||
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)?|мак(?:[\s_-]*адрес)?`,
|
||||
String.raw`mac(?:[\s_-]*(?:address|adres[iİıI]?))?|мак(?:[\s_-]*адрес)?`,
|
||||
String.raw`[0-9A-Fa-f:. -]{12,23}`
|
||||
),
|
||||
validate: (value) => normalizeStalkerMacAddress(value) !== null,
|
||||
@@ -139,7 +190,15 @@ const FIELD_MATCHERS: FieldMatcher[] = [
|
||||
},
|
||||
{
|
||||
field: 'password',
|
||||
pattern: labeledPattern(String.raw`pass[\s_-]*word|pass|pwd|пароль`),
|
||||
// "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',
|
||||
@@ -210,6 +269,15 @@ function classifyUrl(raw: string, parsed: URL): UrlRole {
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
/** "DEVICE ID=> 1&2 <hex>" — the 1&2 marker may sit before or after the separator. */
|
||||
const DUAL_DEVICE_ID_PATTERN = new RegExp(
|
||||
LABEL_LOOKBEHIND +
|
||||
String.raw`device[\s_-]*ids?` +
|
||||
`(?:[\\s_-]*1\\s*[&/+]\\s*2${LABEL_SEPARATOR}|${LABEL_SEPARATOR}1\\s*[&/+]\\s*2\\s+)` +
|
||||
String.raw`([0-9A-Fa-f]{16,64})\b`,
|
||||
'iu'
|
||||
);
|
||||
|
||||
export function extractLabeledFields(
|
||||
text: string,
|
||||
urls: DetectedUrl[]
|
||||
@@ -226,6 +294,14 @@ export function extractLabeledFields(
|
||||
const spans = urlSpans(text, urls);
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user