Files

806 lines
27 KiB
JavaScript

(function (root) {
'use strict';
var DEFAULT_MARKERS = [
'доступ ограничен',
'доступ к сайту ограничен',
'сайт заблокирован',
'заблокировано',
'ограничение доступа',
'ресурс заблокирован',
'по решению органов государственной власти',
'роскомнадзор',
'forbidden',
'403 forbidden',
'404 not found',
'bad gateway',
'service unavailable',
'cloudflare host error',
'host error',
'cloudflare ray id',
'ray id',
'cloudflare-ray',
'cf-ray',
'идентификатор луча cloudflare',
'идентификатор ray cloudflare',
'идентификатор cloudflare ray',
'идентификатор луча',
'web server is down',
'origin is unreachable',
'connection timed out',
'время ожидания соединения истекло',
'истекло время ожидания соединения',
'код ошибки 520',
'код ошибки 521',
'код ошибки 522',
'код ошибки 523',
'код ошибки 524',
'код ошибки 525',
'код ошибки 526',
'код ошибки 530',
'ошибка 520',
'ошибка 521',
'ошибка 522',
'ошибка 523',
'ошибка 524',
'ошибка 525',
'ошибка 526',
'ошибка 530',
'первоначальное соединение между сетью cloudflare',
'между сетью cloudflare и исходным веб-сервером',
'исходным веб-сервером истекло',
'веб-страница не может быть отображена',
'a timeout occurred',
'gateway timeout',
'error 520',
'error 521',
'error 522',
'error 523',
'error 524',
'error 525',
'error 526',
'error 530',
'the web server is down',
'the origin web server is not reachable',
'the initial connection between cloudflare',
'cloudflare was unable to establish a connection',
'browser working',
'cloudflare working'
];
var DEFAULT_MARKERS_TEXT = DEFAULT_MARKERS.join('\n');
var DEFAULT_MARKERS_MAP = DEFAULT_MARKERS.reduce(function (result, marker) {
result[String(marker || '').toLowerCase()] = true;
return result;
}, {});
function uid() {
return 'rule-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
}
function createMultclubRule(id) {
return {
id: id || uid(),
name: 'Multclub numeric mirrors',
enabled: true,
hostRegex: '^multclub\\d*\\.lol$',
protocol: 'https',
mode: 'probe',
strategy: 'number',
template: 'multclub{n}.lol',
includeEmpty: true,
numberStart: 1,
numberEnd: 200,
numberStep: 1,
padLength: 0,
letterCharset: 'abcdefghijklmnopqrstuvwxyz',
letterMinLen: 1,
letterMaxLen: 2,
maxCandidates: 300,
customHosts: '',
timeoutMs: 3000,
parallelChecks: 8,
maxRedirects: 220,
cacheTtlHours: 0,
preferCachedHost: true,
blockedMarkers: DEFAULT_MARKERS_TEXT
};
}
function createEmptyRule(id) {
return {
id: id || uid(),
name: 'New mirror rule',
enabled: true,
hostRegex: '^example\\d*\\.com$',
protocol: 'https',
mode: 'probe',
strategy: 'number',
template: 'example{n}.com',
includeEmpty: false,
numberStart: 1,
numberEnd: 100,
numberStep: 1,
padLength: 0,
letterCharset: 'abcdefghijklmnopqrstuvwxyz',
letterMinLen: 1,
letterMaxLen: 2,
maxCandidates: 300,
customHosts: '',
timeoutMs: 3000,
parallelChecks: 8,
maxRedirects: 220,
cacheTtlHours: 0,
preferCachedHost: true,
blockedMarkers: DEFAULT_MARKERS_TEXT
};
}
function getDefaultRules() {
return [createMultclubRule('default-multclub')];
}
function normalizeRule(rule) {
var result = Object.assign(createEmptyRule(), rule || {});
if (!result.id) result.id = uid();
if (!result.strategy) result.strategy = 'number';
if (!result.mode) result.mode = 'step';
if (!result.protocol) result.protocol = 'https';
if (!result.blockedMarkers) result.blockedMarkers = DEFAULT_MARKERS_TEXT;
if (typeof result.preferCachedHost !== 'boolean') result.preferCachedHost = true;
return result;
}
function validateRuleCode(rule) {
if (rule.strategy === 'list') {
if (!String(rule.customHosts || '').trim()) {
return {
code: 'listHostsRequired',
message: 'Для режима списка нужно указать хотя бы один host.'
};
}
return null;
}
if (!String(rule.hostRegex || '').trim()) {
return {
code: 'hostRegexRequired',
message: 'Поле hostRegex не должно быть пустым.'
};
}
try {
new RegExp(rule.hostRegex, 'i');
} catch (e) {
return {
code: 'hostRegexInvalid',
detail: e.message,
message: 'Неверный RegExp в поле hostRegex: ' + e.message
};
}
if (rule.strategy === 'number' && String(rule.template || '').indexOf('{n}') === -1) {
return {
code: 'numberTemplateMissing',
message: 'Для цифрового перебора шаблон host должен содержать {n}.'
};
}
if (rule.strategy === 'letters' && String(rule.template || '').indexOf('{s}') === -1) {
return {
code: 'lettersTemplateMissing',
message: 'Для буквенного перебора шаблон host должен содержать {s}.'
};
}
return null;
}
function validateRule(rule) {
var error = validateRuleCode(rule);
return error ? error.message : null;
}
function safeUrl(rawUrl) {
try {
return new URL(rawUrl);
} catch (e) {
return null;
}
}
function normalizeHost(host) {
return String(host || '').toLowerCase().trim();
}
function escapeRegex(text) {
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function splitHosts(text) {
return String(text || '')
.split(/[\n,;\s]+/)
.map(normalizeHost)
.filter(Boolean);
}
function uniquePush(result, seen, host) {
host = normalizeHost(host);
if (!host || seen[host]) return;
seen[host] = true;
result.push(host);
}
function getPath(urlObj) {
return urlObj.pathname + urlObj.search + urlObj.hash;
}
function getProtocol(rule, sourceUrlObj) {
var protocol = String(rule.protocol || 'https').replace(':', '').toLowerCase();
if (protocol === 'same') {
return sourceUrlObj.protocol.replace(':', '') || 'https';
}
if (protocol !== 'http' && protocol !== 'https') return 'https';
return protocol;
}
function buildTargetUrl(rule, host, sourceUrlObj) {
return getProtocol(rule, sourceUrlObj) + '://' + host + getPath(sourceUrlObj);
}
function urlsReferToSameNavigation(a, b) {
var au = safeUrl(a);
var bu = safeUrl(b);
if (!au || !bu) return false;
return (
au.protocol === bu.protocol &&
normalizeHost(au.hostname) === normalizeHost(bu.hostname) &&
getPath(au) === getPath(bu)
);
}
function padNumber(number, padLength) {
var text = String(number);
padLength = Number(padLength || 0);
while (text.length < padLength) text = '0' + text;
return text;
}
function hostFromNumber(rule, number) {
var template = String(rule.template || 'example{n}.com');
var value = number === null ? '' : padNumber(number, rule.padLength);
return normalizeHost(template.replace(/\{n\}/g, value));
}
function inferNumberFromTemplate(rule, host) {
var template = String(rule && rule.template || '');
if (template.indexOf('{n}') === -1) return null;
var escaped = escapeRegex(template).replace(/\\\{n\\\}/g, '(\\d*)');
var re = new RegExp('^' + escaped + '$', 'i');
var match = normalizeHost(host).match(re);
if (!match) return null;
if (match[1] === '') return '';
var n = Number(match[1]);
return Number.isFinite(n) ? n : null;
}
function numberOrDefault(value, fallback) {
if (value === null || value === undefined || value === '') return fallback;
var n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function getNumberRange(rule) {
var start = numberOrDefault(rule.numberStart, 1);
var end = numberOrDefault(rule.numberEnd, 100);
if (end < start) {
var tmp = start;
start = end;
end = tmp;
}
return { start: start, end: end };
}
function hostPassesStrategyGuard(rule, hostname) {
if (!rule || rule.strategy !== 'number') return true;
if (String(rule.template || '').indexOf('{n}') === -1) return false;
var n = inferNumberFromTemplate(rule, hostname);
if (n === null) return false;
if (n === '') return rule.includeEmpty === true;
var range = getNumberRange(rule);
return n >= range.start && n <= range.end;
}
function matchesRule(rule, hostname, onError) {
if (!rule || rule.enabled === false) return false;
if (rule.strategy === 'list') {
return splitHosts(rule.customHosts).indexOf(normalizeHost(hostname)) !== -1;
}
if (!rule.hostRegex) return false;
try {
if (!new RegExp(rule.hostRegex, 'i').test(hostname)) return false;
return hostPassesStrategyGuard(rule, hostname);
} catch (e) {
if (typeof onError === 'function') onError(e, rule);
return false;
}
}
function findRuleForHost(rules, hostname, onError) {
hostname = normalizeHost(hostname);
for (var i = 0; i < rules.length; i += 1) {
if (matchesRule(rules[i], hostname, onError)) return rules[i];
}
return null;
}
function generateNumberHosts(rule, currentHost, result, seen) {
var range = getNumberRange(rule);
var start = range.start;
var end = range.end;
var step = Math.max(1, Number(rule.numberStep || 1));
var currentNumber = inferNumberFromTemplate(rule, currentHost);
function addNumber(n) {
uniquePush(result, seen, hostFromNumber(rule, n));
}
if (currentNumber !== null && currentNumber !== '') {
for (var up = currentNumber + step; up <= end; up += step) addNumber(up);
if (rule.includeEmpty === true) {
uniquePush(result, seen, hostFromNumber(rule, null));
}
for (var down = start; down < currentNumber; down += step) addNumber(down);
return;
}
if (currentNumber === '' || rule.includeEmpty === true) {
uniquePush(result, seen, hostFromNumber(rule, null));
}
for (var n = start; n <= end; n += step) addNumber(n);
}
function hostFromLetters(rule, token) {
var template = String(rule.template || 'example{s}.com');
return normalizeHost(template.replace(/\{s\}/g, token));
}
function inferLettersFromTemplate(rule, host) {
var template = String(rule.template || '');
if (template.indexOf('{s}') === -1) return null;
var escaped = escapeRegex(template).replace(/\\\{s\\\}/g, '([a-z0-9_-]+)');
var re = new RegExp('^' + escaped + '$', 'i');
var match = normalizeHost(host).match(re);
return match ? match[1] : null;
}
function generateTokens(charset, minLen, maxLen, limit) {
charset = String(charset || 'abcdefghijklmnopqrstuvwxyz');
minLen = Math.max(1, Number(minLen || 1));
maxLen = Math.max(minLen, Number(maxLen || minLen));
limit = Math.max(1, Number(limit || 300));
var tokens = [];
function walk(prefix, targetLen) {
if (tokens.length >= limit) return;
if (prefix.length === targetLen) {
tokens.push(prefix);
return;
}
for (var i = 0; i < charset.length; i += 1) {
walk(prefix + charset[i], targetLen);
if (tokens.length >= limit) return;
}
}
for (var len = minLen; len <= maxLen; len += 1) {
walk('', len);
if (tokens.length >= limit) break;
}
return tokens;
}
function generateLetterHosts(rule, currentHost, result, seen) {
var tokens = generateTokens(rule.letterCharset, rule.letterMinLen, rule.letterMaxLen, rule.maxCandidates);
var currentToken = inferLettersFromTemplate(rule, currentHost);
var index = currentToken === null ? -1 : tokens.indexOf(currentToken);
var ordered = index >= 0 ? tokens.slice(index + 1).concat(tokens.slice(0, index)) : tokens;
for (var i = 0; i < ordered.length; i += 1) {
uniquePush(result, seen, hostFromLetters(rule, ordered[i]));
}
}
function estimateCandidateCount(rule) {
if (!rule) return 0;
if (rule.strategy === 'list') {
return splitHosts(rule.customHosts).length;
}
if (rule.strategy === 'letters') {
var charset = String(rule.letterCharset || 'abcdefghijklmnopqrstuvwxyz');
var minLen = Math.max(1, Number(rule.letterMinLen || 1));
var maxLen = Math.max(minLen, Number(rule.letterMaxLen || minLen));
var limit = Math.max(1, Number(rule.maxCandidates || 300));
var count = 0;
for (var len = minLen; len <= maxLen; len += 1) {
count += Math.pow(charset.length, len);
if (count >= limit) return limit;
}
return count;
}
var range = getNumberRange(rule);
var step = Math.max(1, Number(rule.numberStep || 1));
var numericCount = Math.floor((range.end - range.start) / step) + 1;
return numericCount + (rule.includeEmpty === true ? 1 : 0) + 1;
}
function getEffectiveMaxRedirects(rule) {
var configured = Math.max(1, Number(rule && rule.maxRedirects || 50));
var neededForRange = Math.max(1, estimateCandidateCount(rule) + 5);
return Math.max(configured, neededForRange);
}
function buildCandidates(rule, currentHost, cachedHost) {
var result = [];
var seen = Object.create(null);
if (cachedHost && matchesRule(rule, cachedHost)) uniquePush(result, seen, cachedHost);
if (rule.strategy === 'list') {
splitHosts(rule.customHosts).forEach(function (host) {
uniquePush(result, seen, host);
});
} else if (rule.strategy === 'letters') {
generateLetterHosts(rule, currentHost, result, seen);
} else {
generateNumberHosts(rule, currentHost, result, seen);
}
return result;
}
function getCacheKey(rule) {
return 'lastWorkingHost:' + (rule.id || rule.name || rule.hostRegex || 'unknown');
}
function getMarkers(rule) {
var source = DEFAULT_MARKERS.slice();
var custom = [];
var seen = Object.create(null);
if (rule && Array.isArray(rule.blockedMarkers)) {
custom = rule.blockedMarkers;
} else if (rule && typeof rule.blockedMarkers === 'string') {
custom = rule.blockedMarkers.split(/\n+/);
}
custom.forEach(function (item) {
source.push(item);
});
return source
.map(function (item) { return String(item || '').trim().toLowerCase(); })
.filter(function (item) {
if (!item || seen[item]) return false;
seen[item] = true;
return true;
});
}
function findCloudflareRayMarker(normalizedText) {
var text = String(normalizedText || '').toLowerCase();
var hasCloudflare = text.indexOf('cloudflare') !== -1;
if (text.indexOf('cf-ray') !== -1 || text.indexOf('cloudflare-ray') !== -1) {
return 'cloudflare-ray-id';
}
if (/\bcf[\s_-]*ray(?:[\s_-]*id)?\b/.test(text)) return 'cloudflare-ray-id';
if (/\bray[\s_-]*id\b/.test(text) && hasCloudflare) return 'cloudflare-ray-id';
if (/\bray[\s_-]*identifier\b/.test(text) && hasCloudflare) return 'cloudflare-ray-id';
var hasIdentifier = text.indexOf('идентификатор') !== -1;
var hasRayRu = text.indexOf('луча') !== -1 || text.indexOf('луч') !== -1 || text.indexOf('ray') !== -1;
if (hasCloudflare && hasIdentifier && hasRayRu) return 'cloudflare-ray-id-ru';
if (text.indexOf('идентификатор луча cloudflare') !== -1 ||
text.indexOf('идентификатор cloudflare ray') !== -1 ||
text.indexOf('идентификатор ray cloudflare') !== -1) {
return 'cloudflare-ray-id-ru';
}
if (hasCloudflare && /\bray\b/.test(text) && /\b[a-f0-9]{12,64}\b/i.test(text)) {
return 'cloudflare-ray-id-token';
}
return '';
}
function findBlockedMarkerInText(rule, text) {
var normalized = String(text || '').toLowerCase();
var rayMarker = findCloudflareRayMarker(normalized);
if (rayMarker) return rayMarker;
var markers = getMarkers(rule);
for (var i = 0; i < markers.length; i += 1) {
if (normalized.indexOf(markers[i]) !== -1) return markers[i];
}
var cloudflareCode = normalized.match(/\b(520|521|522|523|524|525|526|530)\b/);
if (cloudflareCode && normalized.indexOf('cloudflare') !== -1) {
return 'cloudflare-status-' + cloudflareCode[1];
}
return '';
}
function collapseText(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function normalizeMarker(marker) {
return String(marker || '').toLowerCase().trim();
}
function isBuiltInMarker(marker) {
marker = normalizeMarker(marker);
return Boolean(DEFAULT_MARKERS_MAP[marker]) ||
marker.indexOf('cloudflare-status-') === 0 ||
marker.indexOf('cloudflare-ray-id') === 0;
}
function isCloudflareLikeMarker(marker) {
marker = normalizeMarker(marker);
return marker.indexOf('cloudflare') !== -1 ||
marker.indexOf('cf-ray') !== -1 ||
marker.indexOf('ray id') !== -1 ||
marker.indexOf('ray-id') !== -1 ||
marker.indexOf('host error') !== -1 ||
marker.indexOf('origin is unreachable') !== -1 ||
marker.indexOf('connection timed out') !== -1 ||
marker.indexOf('web server is down') !== -1 ||
marker.indexOf('gateway timeout') !== -1 ||
/\berror 52[0-6]\b/.test(marker) ||
/\berror 530\b/.test(marker);
}
function isGenericHttpErrorMarker(marker) {
marker = normalizeMarker(marker);
return marker === 'forbidden' ||
marker === '403 forbidden' ||
marker === '404 not found' ||
marker === 'bad gateway' ||
marker === 'service unavailable';
}
function pageLooksFocusedOnGenericHttpError(visibleText, marker) {
var text = collapseText(visibleText).toLowerCase();
if (!text) return false;
if (text.length > 1800) return false;
marker = normalizeMarker(marker);
if (marker === 'forbidden') {
return text === 'forbidden' ||
/\b403\b/.test(text) ||
/\baccess denied\b/.test(text) ||
/\byou do not have permission\b/.test(text) ||
/\byou don't have permission\b/.test(text) ||
/\brequest forbidden\b/.test(text);
}
return true;
}
function countBuiltInErrorSignals(text) {
text = String(text || '').toLowerCase();
var signals = [
'доступ ограничен',
'сайт заблокирован',
'заблокировано',
'ограничение доступа',
'ресурс заблокирован',
'роскомнадзор',
'по решению органов государственной власти',
'403',
'404',
'forbidden',
'access denied',
'not found',
'bad gateway',
'service unavailable',
'host error',
'origin is unreachable',
'connection timed out',
'web server is down',
'gateway timeout',
'cloudflare',
'ray id',
'cf-ray',
'error 520',
'error 521',
'error 522',
'error 523',
'error 524',
'error 525',
'error 526',
'error 530',
'код ошибки 520',
'код ошибки 521',
'код ошибки 522',
'код ошибки 523',
'код ошибки 524',
'код ошибки 525',
'код ошибки 526',
'код ошибки 530',
'ошибка 520',
'ошибка 521',
'ошибка 522',
'ошибка 523',
'ошибка 524',
'ошибка 525',
'ошибка 526',
'ошибка 530'
];
var count = 0;
signals.forEach(function (signal) {
if (text.indexOf(signal) !== -1) count += 1;
});
return count;
}
function pageLooksFocusedOnBuiltInMarker(visibleText, marker) {
var text = collapseText(visibleText).toLowerCase();
if (!text) return false;
if (isGenericHttpErrorMarker(marker)) return pageLooksFocusedOnGenericHttpError(visibleText, marker);
if (isCloudflareLikeMarker(marker)) return text.length <= 4500 && countBuiltInErrorSignals(text.slice(0, 4500)) >= 2;
if (text.length <= 1800) return true;
if (text.length > 4500) return false;
return countBuiltInErrorSignals(text.slice(0, 4500)) >= 2;
}
function pageLooksFocusedOnBlockedError(title, visibleText, marker) {
var titleMarker = findBlockedMarkerInText({}, title);
if (titleMarker) return true;
if (isBuiltInMarker(marker)) return pageLooksFocusedOnBuiltInMarker(visibleText, marker);
return true;
}
function findBlockedMarkerInPage(rule, title, visibleText) {
var titleMarker = findBlockedMarkerInText(rule, title);
var bodyMarker;
if (titleMarker) return titleMarker;
bodyMarker = findBlockedMarkerInText(rule, visibleText);
if (!bodyMarker) return '';
return pageLooksFocusedOnBlockedError(title, visibleText, bodyMarker) ? bodyMarker : '';
}
function stripHtmlForMarkerScan(text) {
var source = String(text || '');
if (source.indexOf('<') === -1 || !/<(?:html|head|body|title|script|style|template|div|span|p|h[1-6]|main|section|article|pre|code|br|meta|link)\b/i.test(source)) {
return source;
}
return source
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<template\b[^>]*>[\s\S]*?<\/template>/gi, ' ')
.replace(/<([a-z][\w:-]*)\b[^>]*(?:\shidden\b|style\s*=\s*["'][^"']*(?:display\s*:\s*none|visibility\s*:\s*hidden)[^"']*["'])[^>]*>[\s\S]*?<\/\1>/gi, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;|&#160;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, "'");
}
function extractHtmlTitle(text) {
var match = String(text || '').match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
return match ? stripHtmlForMarkerScan(match[1]) : '';
}
function responseLooksBlocked(rule, text) {
var title = extractHtmlTitle(text);
var visibleText = stripHtmlForMarkerScan(text);
return Boolean(findBlockedMarkerInPage(rule, title, visibleText));
}
function preferCachedHostEnabled(rule) {
return rule.preferCachedHost !== false;
}
var api = {
DEFAULT_MARKERS: DEFAULT_MARKERS.slice(),
DEFAULT_MARKERS_TEXT: DEFAULT_MARKERS_TEXT,
uid: uid,
createMultclubRule: createMultclubRule,
createEmptyRule: createEmptyRule,
getDefaultRules: getDefaultRules,
normalizeRule: normalizeRule,
validateRuleCode: validateRuleCode,
validateRule: validateRule,
safeUrl: safeUrl,
normalizeHost: normalizeHost,
escapeRegex: escapeRegex,
splitHosts: splitHosts,
uniquePush: uniquePush,
getPath: getPath,
getProtocol: getProtocol,
buildTargetUrl: buildTargetUrl,
urlsReferToSameNavigation: urlsReferToSameNavigation,
padNumber: padNumber,
hostFromNumber: hostFromNumber,
inferNumberFromTemplate: inferNumberFromTemplate,
getNumberRange: getNumberRange,
hostPassesStrategyGuard: hostPassesStrategyGuard,
matchesRule: matchesRule,
findRuleForHost: findRuleForHost,
generateNumberHosts: generateNumberHosts,
hostFromLetters: hostFromLetters,
inferLettersFromTemplate: inferLettersFromTemplate,
generateTokens: generateTokens,
generateLetterHosts: generateLetterHosts,
estimateCandidateCount: estimateCandidateCount,
getEffectiveMaxRedirects: getEffectiveMaxRedirects,
buildCandidates: buildCandidates,
getCacheKey: getCacheKey,
getMarkers: getMarkers,
findCloudflareRayMarker: findCloudflareRayMarker,
findBlockedMarkerInText: findBlockedMarkerInText,
findBlockedMarkerInPage: findBlockedMarkerInPage,
stripHtmlForMarkerScan: stripHtmlForMarkerScan,
responseLooksBlocked: responseLooksBlocked,
preferCachedHostEnabled: preferCachedHostEnabled
};
root.AutoMirrorShared = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);