From 7b57b22e8886f20c4871d7a32e07bfa1bccac873 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 20:38:58 +0000 Subject: [PATCH] feat(playlist): fold negative-circled labels and parse dual device IDs in auto-detect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' 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 Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4 --- .../provider-import-detection.util.spec.ts | 72 ++++++++++++++++ .../src/lib/provider-import-detection.util.ts | 9 +- .../src/lib/provider-import-scan.util.ts | 82 ++++++++++++++++++- 3 files changed, 158 insertions(+), 5 deletions(-) diff --git a/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts b/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts index bdda3516c..08ca2562e 100644 --- a/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts @@ -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 " 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( [ diff --git a/libs/shared/interfaces/src/lib/provider-import-detection.util.ts b/libs/shared/interfaces/src/lib/provider-import-detection.util.ts index 220089107..f4697b6bf 100644 --- a/libs/shared/interfaces/src/lib/provider-import-detection.util.ts +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.ts @@ -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); diff --git a/libs/shared/interfaces/src/lib/provider-import-scan.util.ts b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts index 8a73e6eef..f45ffa4f8 100644 --- a/libs/shared/interfaces/src/lib/provider-import-scan.util.ts +++ b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts @@ -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}(? 1&2 " — 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 " (or "DEVICE ID 1&2: ") 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;