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:
Claude committed 2026-08-11 20:38:58 +00:00
1 parent de6d012a57
commit 7b57b22e88
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;