mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
feat(playlist): extend auto-detect with bare handouts, spaced serials, decorated dual device IDs
Second corpus batch (14 real reseller messages; 11 already parsed). New:
- Bare 'URL, token, token' handouts with no labels at all become a
low-confidence Xtream guess — only when the message contains exactly those
three lines, a single generic URL, and no label/MAC produced anything.
- Separator-less serials ('SN 38415545307A3') are accepted when the value is
hex-shaped, so prose cannot match.
- The dual device-ID marker now allows any symbol between 1 and 2 (¹💥², 1/2)
and whitespace-only label separators, again guarded by the hex value shape.
- A separately labeled port completes a port-less 'Portal: http://host' URL.
Nine messages from the batch join the test corpus verbatim.
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
+319
-2
No files matched your search
@@ -218,6 +218,103 @@ describe('detectProviderImportCandidates', () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads an email-shaped username and ignores stats lines (corpus #1)', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'http://lordstreams.live:80',
|
||||
'User=mszala@comcast.net',
|
||||
'Pass=0323910031',
|
||||
'30602 channels',
|
||||
'Validity 27/12/26',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0]).toMatchObject({
|
||||
serverUrl: 'http://lordstreams.live',
|
||||
username: 'mszala@comcast.net',
|
||||
password: '0323910031',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the bare "URL, token, token" handout shape (corpus #2)', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'http://alltvmx.com\nVictoria89\nVictoria89'
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0]).toMatchObject({
|
||||
confidence: 'low',
|
||||
serverUrl: 'http://alltvmx.com',
|
||||
username: 'Victoria89',
|
||||
password: 'Victoria89',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not read prose around a lone URL as bare credentials', () => {
|
||||
expect(
|
||||
detectProviderImportCandidates(
|
||||
'check out http://example.com\nsee the attached guide\nthanks again'
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles indented labels and numeric credentials (corpus #4)', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'http://eliplay.onl:8089\nUser: 111\n Pass: 111'
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0]).toMatchObject({
|
||||
serverUrl: 'http://eliplay.onl:8089',
|
||||
username: '111',
|
||||
password: '111',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the get.php line of a full panel dump (corpus #5)', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'𝙋𝙤𝙧𝙩𝙖𝙡 : http://31.43.191.125',
|
||||
'• 𝘾𝙧𝙚𝙖𝙩𝙚𝙙 : 09/02/2026',
|
||||
'• 𝙀𝙭𝙥 : 10/02/2027',
|
||||
'• 𝙐𝙨𝙚𝙧 : vip6001770656088',
|
||||
'• 𝙋𝙖𝙨𝙨 : c394e34a0504',
|
||||
'• 𝘾𝙤𝙣𝙣 : 1',
|
||||
'• 𝙈𝙖𝙭𝘾𝙤𝙣𝙣 : 1',
|
||||
'• 𝙋𝙤𝙧𝙩 : 8080',
|
||||
'• 𝙎𝙩𝙖𝙩𝙪𝙨 : # OK ✅',
|
||||
'• 𝐌𝟑𝐔 : http://31.43.191.125:8080/get.php?username=vip6001770656088&password=c394e34a0504&type=m3u',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0]).toMatchObject({
|
||||
confidence: 'high',
|
||||
serverUrl: 'http://31.43.191.125:8080',
|
||||
username: 'vip6001770656088',
|
||||
password: 'c394e34a0504',
|
||||
});
|
||||
});
|
||||
|
||||
it('completes a port-less labeled portal URL with the labeled port', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Portal : http://panel.example.io',
|
||||
'Port : 8080',
|
||||
'User : u1',
|
||||
'Pass : p1',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream[0].serverUrl).toBe('http://panel.example.io:8080');
|
||||
});
|
||||
|
||||
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'
|
||||
@@ -426,6 +523,135 @@ describe('detectProviderImportCandidates', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a scanner dump with Real/Panel URLs and bold-italic labels (corpus #3)', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'├○ 𝑹𝒆𝒂𝒍 ➤ http://main.light-ott.net:80/c/',
|
||||
'├○ 𝑷𝒂𝒏𝒆𝒍 ➤ http://main.light-ott.net/c/',
|
||||
'├○ 𝑴𝒂𝒄 ➤ 00:1A:79:f5:cd:04',
|
||||
'├○ 𝑷𝒂𝒏𝒆𝒍 𝑻𝒚𝒑𝒆 ➤ portal.php',
|
||||
'├○ 𝑬𝒙𝒑 ➤ July 26, 2027, 12:41 pm',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(1);
|
||||
expect(stalker[0]).toMatchObject({
|
||||
confidence: 'high',
|
||||
portalUrl: 'http://main.light-ott.net:80/c/',
|
||||
macAddress: '00:1A:79:F5:CD:04',
|
||||
});
|
||||
});
|
||||
|
||||
it('produces one candidate per MAC of a multi-account list (corpus #6)', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'http://mag.tivi-one-iptv.net/c/',
|
||||
'00:1A:79:61:31:34 13/01/2027',
|
||||
'00:1A:79:9C:E8:78 31/12/2027 (513 Days)',
|
||||
'00:1A:79:A0:E3:66 18/08/2027 (378 Days)',
|
||||
'00:1A:79:AD:85:3E 16/08/2027 (377 Days)',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(4);
|
||||
expect(
|
||||
stalker.every(
|
||||
(candidate) =>
|
||||
candidate.portalUrl === 'http://mag.tivi-one-iptv.net/c/'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(stalker.map((candidate) => candidate.macAddress)).toEqual([
|
||||
'00:1A:79:61:31:34',
|
||||
'00:1A:79:9C:E8:78',
|
||||
'00:1A:79:A0:E3:66',
|
||||
'00:1A:79:AD:85:3E',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads separator-less SN and a decorated ¹💥² dual device ID (corpus #8)', () => {
|
||||
const deviceHex =
|
||||
'AD8D35940A3FA14EA07451780224114F276D0F3BB4427E5351436F60ED6BF0BA';
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'http://tv.saartv.cc/stalker_portal/c/',
|
||||
'Mac 00:1a:79:01:07:06',
|
||||
'𝐄𝐗𝐏 𝐃𝐚𝐭𝐞 2026-08-07',
|
||||
'🔥 🆅🅿️🅽 Toronto',
|
||||
'𝐒𝐍 38415545307A3',
|
||||
`𝐃𝐞𝐯𝐢𝐜𝐞𝐈𝐃 ¹💥² ${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:01:07:06',
|
||||
serialNumber: '38415545307A3',
|
||||
deviceId1: deviceHex,
|
||||
deviceId2: deviceHex,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores scanner footers and status prose around a portal (corpus #9)', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'├○ 𝑺𝒄𝒂𝒏𝒏𝒆𝒅 𝑩𝒚 ➤ ★☆★ 𝓜𝓲𝓱𝓪𝓳𝓵𝓸 𝓘𝓿𝓲𝓬 ★☆★',
|
||||
'├○ 𝑺𝒄𝒂𝒏 𝑫𝒂𝒕𝒆 / 𝑻𝒊𝒎𝒆 ➤ 11.08.2026. • 11:38:54',
|
||||
'├○ 𝑹𝒆𝒂𝒍 ➤ http://kamariato.com:80',
|
||||
'├○ 𝑷𝒂𝒏𝒆𝒍 ➤ http://kamariato.com:80/c/',
|
||||
'├○ 𝑴𝒂𝒄 ➤ 00:1A:79:f1:6a:9f',
|
||||
'├○ 𝑷𝒂𝒏𝒆𝒍 𝑻𝒚𝒑𝒆 ➤ portal.php',
|
||||
'├○ 𝑬𝒙𝒑 ➤ July 12, 2026, 12:00 am -30 Days',
|
||||
'├○ 𝑽𝒑𝒏 ➤ 𝑁𝑜 𝐶𝑙𝑖𝑒𝑛𝑡 𝐼𝑃 𝐴𝑑𝑑𝑟𝑒𝑠𝑠',
|
||||
'├○ 𝑰𝒎𝒂𝒈𝒆 ➤ 𝐼𝑛𝑣𝑎𝑙𝑖𝑑 𝑂𝑝𝑝𝑠',
|
||||
'└──➢ 𝑴𝑨𝑪 𝑷𝑹𝑶 𝑨𝑻𝑳𝑨𝑺',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(1);
|
||||
expect(stalker[0]).toMatchObject({
|
||||
portalUrl: 'http://kamariato.com:80/c/',
|
||||
macAddress: '00:1A:79:F1:6A:9F',
|
||||
});
|
||||
expect(only(candidates, 'xtream')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reads a [+]-prefixed dump with serial, device ID and signature (corpus #11)', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'[+] Panel: http://mag.jee-ott.xyz:80/c',
|
||||
'[+] Real: mag.jee-ott.xyz',
|
||||
'[+] Mac: 00:1B:79:48:3A:90',
|
||||
'[+] Exp.: February 27, 2027, 7:33 pm',
|
||||
'[+] ᴍᴀᴄ ꜱᴛᴀᴛᴜꜱ: ᴍᴀᴄ ᴏꜰꜰ🥺',
|
||||
'[+] ᴍ3ᴜ ꜱᴛᴀᴛᴜꜱ: ᴏꜰꜰʟɪɴᴇ😔',
|
||||
'[+] ᴄʜᴀɴɴᴇʟCategories: 684',
|
||||
'[+] m3u_Url: ',
|
||||
'',
|
||||
'[+] Serial: 862E1CDB82415',
|
||||
'[+] Device ID: FA9CBD1857D1D8A57BF1A03542B928CB90779C7CE2673CAC85A5FD4F8B7756E5',
|
||||
'[+] Signature: A48D3EDCCC09B580CD19A07C8B0A9C44F1D00377CEE9913EE2ADCD858776FD67',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(1);
|
||||
expect(stalker[0]).toMatchObject({
|
||||
portalUrl: 'http://mag.jee-ott.xyz:80/c',
|
||||
macAddress: '00:1B:79:48:3A:90',
|
||||
serialNumber: '862E1CDB82415',
|
||||
deviceId1:
|
||||
'FA9CBD1857D1D8A57BF1A03542B928CB90779C7CE2673CAC85A5FD4F8B7756E5',
|
||||
signature1:
|
||||
'A48D3EDCCC09B580CD19A07C8B0A9C44F1D00377CEE9913EE2ADCD858776FD67',
|
||||
});
|
||||
expect(stalker[0].deviceId2).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives labeled credentials to the stalker candidate, not a competing xtream guess', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
|
||||
@@ -191,6 +191,36 @@ export function detectProviderImportCandidates(
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bare handout: exactly "URL, token, token" and nothing else readable.
|
||||
// "http://host\nVictoria89\nVictoria89" carries no labels at all — the
|
||||
// shape IS the message (server, then username, then password, in reading
|
||||
// order). Deliberately the last resort and ranked low: it only fires when
|
||||
// no label, MAC or API URL produced anything, the single URL is generic,
|
||||
// and the message contains nothing beyond those three lines, so prose
|
||||
// cannot be misread as credentials.
|
||||
if (
|
||||
macs.length === 0 &&
|
||||
!labeled.username &&
|
||||
!labeled.password &&
|
||||
candidates.length === 0 &&
|
||||
urls.length === 1 &&
|
||||
urls[0].role === 'generic'
|
||||
) {
|
||||
const tokens = bareCredentialTokens(normalized, urls[0]);
|
||||
if (tokens) {
|
||||
candidates.push(
|
||||
compact({
|
||||
kind: 'xtream',
|
||||
confidence: 'low',
|
||||
serverUrl: safeXtreamServerUrl(urls[0].raw),
|
||||
username: tokens[0],
|
||||
password: tokens[1],
|
||||
suggestedTitle: urls[0].parsed.hostname,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plain playlist links.
|
||||
for (const url of urls) {
|
||||
if (url.role !== 'm3u') {
|
||||
@@ -226,6 +256,32 @@ function pickStalkerPortalUrl(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* For the bare "URL, token, token" shape: returns the two credential-shaped
|
||||
* tokens when the message consists of exactly three non-empty lines — the
|
||||
* URL and two single tokens — and nothing else.
|
||||
*/
|
||||
function bareCredentialTokens(
|
||||
text: string,
|
||||
url: DetectedUrl
|
||||
): [string, string] | null {
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
if (lines.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
const tokens = lines.filter((line) => !line.includes(url.raw));
|
||||
if (tokens.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
const credentialShaped = /^[A-Za-z0-9._@-]{3,64}$/;
|
||||
return credentialShaped.test(tokens[0]) && credentialShaped.test(tokens[1])
|
||||
? [tokens[0], tokens[1]]
|
||||
: null;
|
||||
}
|
||||
|
||||
function safeXtreamServerUrl(raw: string): string | undefined {
|
||||
try {
|
||||
return normalizeXtreamServerUrl(raw);
|
||||
|
||||
@@ -208,6 +208,16 @@ const FIELD_MATCHERS: FieldMatcher[] = [
|
||||
),
|
||||
validate: (value) => value.length <= 64,
|
||||
},
|
||||
{
|
||||
field: 'serialNumber',
|
||||
// Separator-less handout ("SN 38415545307A3"): allowed only because
|
||||
// the hex-shaped value cannot be mistaken for prose.
|
||||
pattern: new RegExp(
|
||||
LABEL_LOOKBEHIND +
|
||||
String.raw`(?:serial|s[\s._/-]?n)\s+([0-9A-Fa-f]{8,64})\b`,
|
||||
'iu'
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'macAddress',
|
||||
// Turkish panels label it "MAC ADRESİ" — the trailing İ/ı/i is listed
|
||||
@@ -306,11 +316,23 @@ 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. */
|
||||
/**
|
||||
* The "both device IDs" marker: 1 and 2 joined by any symbol run ("1&2",
|
||||
* "1/2", "1💥2" — superscript ¹² are already folded to 1/2 by NFKC) or by
|
||||
* plain whitespace. Bare "12" stays a number, not a marker.
|
||||
*/
|
||||
const DUAL_MARKER = String.raw`1(?:\s*[^\p{L}\p{N}\s]+\s*|\s+)2`;
|
||||
|
||||
/**
|
||||
* "DEVICE ID=> 1&2 <hex>" — the marker may sit before or after the
|
||||
* separator, and the separator itself may be plain whitespace ("DeviceID
|
||||
* 1💥2 <hex>"), which is safe here only because the 16–64-hex value shape
|
||||
* cannot be prose.
|
||||
*/
|
||||
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+)` +
|
||||
`(?:[\\s_-]*${DUAL_MARKER}(?:${LABEL_SEPARATOR}|\\s+)|(?:${LABEL_SEPARATOR}|\\s+)${DUAL_MARKER}\\s+)` +
|
||||
String.raw`([0-9A-Fa-f]{16,64})\b`,
|
||||
'iu'
|
||||
);
|
||||
@@ -391,6 +413,19 @@ export function labeledHostUrl(labeled: LabeledFields): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
if (/^https?:\/\//i.test(host)) {
|
||||
// Panels often hand out "Portal: http://host" and "Port: 8080" as two
|
||||
// separate lines — a separately labeled port completes a port-less URL.
|
||||
if (labeled.port) {
|
||||
try {
|
||||
const url = new URL(host);
|
||||
if (!url.port) {
|
||||
url.port = labeled.port;
|
||||
return url.toString();
|
||||
}
|
||||
} catch {
|
||||
return host;
|
||||
}
|
||||
}
|
||||
return host;
|
||||
}
|
||||
if (!HOSTISH_PATTERN.test(host) || !host.includes('.')) {
|
||||
|
||||
Reference in new issue
Block a user