Publish Auto Mirror Switcher 1.4.3 with installation and privacy documentation

This commit is contained in:
DepressedCat committed 2026-10-11 00:13:49 +03:00
commit 5052f542de
33 files changed
+4912

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
dist/
node_modules/
.agents/
.runtime/
*.log
.env
.env.*
+85
View File
@@ -0,0 +1,85 @@
# Chrome Web Store Submission Notes
## Package
Upload `dist/auto-mirror-switcher-1.4.3-chrome-web-store.zip`.
## Release Notes / Change List
English:
```text
Version 1.4.3
- Added automatic UI language detection: Russian is used for ru* browser locales, English is used otherwise.
- Added English localization for the options page, stopped page, runtime messages, validation errors, and extension metadata.
- Reduced false blocked-page detections: built-in markers such as Cloudflare, forbidden, bad gateway, and service unavailable no longer stop browsing when they appear inside a normal loaded page.
- Improved fixed host-list rules: the host RegExp field is hidden and no longer required; the custom host list now defines which source hosts the rule handles.
- Improved probe diagnostics so the stopped page lists hosts checked by probe mode.
```
Русский:
```text
Версия 1.4.3
- Добавлено автоопределение языка интерфейса: русский используется для ru* локалей браузера, английский - для остальных.
- Добавлена английская локализация страницы настроек, страницы остановки, служебных сообщений, ошибок валидации и metadata расширения.
- Уменьшены ложные срабатывания страниц блокировки: встроенные маркеры вроде Cloudflare, forbidden, bad gateway и service unavailable больше не останавливают работу, если встречаются внутри обычной загруженной страницы.
- Улучшены правила с готовым списком host: поле RegExp скрыто и больше не требуется; список host теперь сам определяет, на каких исходных host работает правило.
- Улучшена диагностика probe-режима: страница остановки показывает host, которые реально проверялись в фоне.
```
## Single Purpose
Auto Mirror Switcher automatically moves a tab from a failed mirror host to the next configured working mirror while preserving the original page path, query string, and hash.
## Store Listing
Short description:
```text
Switch failed mirror hosts to a working candidate while preserving the page path, query, and hash.
```
Detailed description:
```text
Auto Mirror Switcher helps users keep browsing when a configured mirror host fails. Create mirror rules, choose numeric, letter, or custom host strategies, and the extension can detect navigation errors, blocked pages, or HTTP failure responses before switching the tab to the next candidate. The original path, query parameters, and hash are preserved.
Settings are stored locally in Chrome. The extension includes diagnostics, import/export, and a stopped page for cases where no candidate host is available.
```
## Permission Justification
`storage`: stores user-created rules, settings, cached working hosts, and local diagnostic logs.
`tabs`: reads and updates the active tab URL when switching a failed mirror to a candidate mirror, and opens the options page fallback.
`webNavigation`: observes main-frame navigation lifecycle events and errors so failed mirror pages can be detected.
`webRequest`: observes completed main-frame request status codes. The extension does not block, redirect, or modify network requests through this API.
`host_permissions` for `http://*/*` and `https://*/*`: supports user-defined mirror hosts and candidate probing across arbitrary sites selected by the user. Rules determine when switching is active.
## Privacy
The extension stores settings and diagnostic data locally in Chrome storage. It does not sell or transfer user data. It does not include remote code or analytics. Network requests are limited to the user-configured mirror hosts and candidate hosts needed to test whether a mirror is working.
## Test Instructions
1. Load the extension and open its options page.
2. Enable the extension.
3. Create a rule with a host regex and candidate hosts or a numeric template.
4. Navigate to a matching test host that fails or returns a configured blocked-page marker.
5. Verify that the tab switches to the next candidate while preserving the path, query string, and hash.
6. Open diagnostics in the options page to inspect the recorded decision path.
## Local Verification
Before upload, run:
```powershell
node --check shared.js
node --check options.js
node --check i18n.js
node tests\shared.test.js
```
+25
View File
@@ -0,0 +1,25 @@
# Данные и конфиденциальность Auto Mirror Switcher
Эта страница описывает версию 1.4.3 и её исходный код.
## Что обрабатывается в браузере
Расширение получает адреса вкладок и события навигации, HTTP-статусы главной страницы, а на страницах с подходящими правилами читает заголовок и видимый текст для поиска признаков блокировки. Полученные сведения используются для выбора зеркала и диагностики.
В `chrome.storage.local` сохраняются настройки, правила, последний работающий хост, состояние интерфейса и журнал диагностики. В журнале могут быть полные URL с параметрами запроса, названия страниц, время событий, причины ошибок, проверенные хосты и адреса переходов. Лимит журнала — 1200 записей. Часть событий ошибок может относиться к адресам, для которых подходящего правила не найдено.
Текущее состояние вкладок и переключений хранится в `chrome.storage.session`, если оно доступно; предусмотрен переход на локальное хранилище. Данные страницы остановки также сохраняются локально.
## Сетевые запросы
Фоновая проверка делает GET-запросы к зеркалам, выбранным правилами, и следует HTTP-перенаправлениям. Она сохраняет путь и параметры запроса исходной страницы и использует `credentials: omit`, чтобы не прикладывать cookies к фоновым запросам. Якорь URL не передаётся в HTTP-запросе.
После выбора зеркала обычный переход вкладки выполняется браузером. Сам сайт получает запрос и обрабатывает данные в соответствии со своей политикой. Сайты видят обычные сведения сетевого обращения, в том числе IP-адрес и параметры запрошенного URL.
В исходном коде расширения нет аналитики, рекламных SDK, загрузки удалённого исполняемого кода и передачи журналов разработчику. Отдельного сервера для работы расширения нет. Установка и обновление через Chrome Web Store регулируются правилами Google; доступ к репозиторию и архивам обслуживает Gitea.
## Управление данными
Очистка диагностического журнала и сброс кэша доступны в настройках. Экспорт настроек или журнала создаёт файл по действию пользователя и не отправляет его автоматически. Экспортированный журнал может содержать URL и параметры страниц. Удаление расширения удаляет его хранилище в браузере; ранее экспортированные файлы остаются там, где пользователь их сохранил.
Для вопросов и сообщения о проблеме используйте раздел Issues в репозитории проекта.
+85
View File
@@ -0,0 +1,85 @@
# Auto Mirror Switcher
![Auto Mirror Switcher](store-assets/large-promo-1400x560.png)
Расширение для Chrome, которое переключает недоступное зеркало сайта на рабочий адрес. Путь страницы, параметры запроса и якорь сохраняются.
**Версия 1.4.3 · Manifest V3 · русский и английский интерфейс**
[Установить из Chrome Web Store](https://chromewebstore.google.com/detail/auto-mirror-switcher/bnlddkbfnjlcopijihiidgokhnnbmndi) · [Скачать ZIP из релиза](https://git.lonarpgproxy.space/OwnerLink/Auto-Mirror-Switcher/releases/tag/v1.4.3) · [English documentation](docs/README.en.md)
## Возможности
- Правила для зеркал с номерами, буквами или готовым списком адресов.
- Обнаружение ошибок навигации, заданных HTTP-ошибок и страниц блокировки.
- Режим `probe`: проверка кандидатов в фоне с выбором работающего зеркала.
- Режим `step`: переход к следующему адресу при ошибке загрузки.
- Запоминание последнего работающего зеркала.
- Импорт и экспорт настроек, локальный журнал диагностики.
- Автоматический выбор русского или английского языка.
- Страница остановки с информацией о проверенных адресах, когда подходящего зеркала нет.
Например, `https://mirror1.example.com/page?id=42#comments` может стать `https://mirror2.example.com/page?id=42#comments`: меняется адрес зеркала, контекст страницы сохраняется.
## Установка
Рекомендуемый способ — [Chrome Web Store](https://chromewebstore.google.com/detail/auto-mirror-switcher/bnlddkbfnjlcopijihiidgokhnnbmndi); обновления доставляет браузер.
Для ручной установки:
1. Скачайте `auto-mirror-switcher-1.4.3-chrome-web-store.zip` из [релиза 1.4.3](https://git.lonarpgproxy.space/OwnerLink/Auto-Mirror-Switcher/releases/tag/v1.4.3).
2. Распакуйте архив в постоянную папку.
3. Откройте `chrome://extensions`, включите режим разработчика и нажмите «Загрузить распакованное расширение».
4. Выберите папку, в которой находится `manifest.json`.
5. Нажмите на значок расширения, чтобы открыть настройки.
При ручной установке новую версию нужно распаковать самостоятельно и обновить расширение на странице `chrome://extensions`.
## Настройка
Включите расширение, создайте или выберите правило и задайте адреса зеркал. Для готового списка достаточно перечислить хосты; для шаблонов с номерами или буквами задайте фильтр хоста и диапазон перебора. Нажмите «Применить к правилу», затем «Сохранить». При включённом автосохранении изменения сохраняются автоматически.
Пример числового правила:
```text
hostRegex: ^mirror\d+\.example\.com$
template: mirror{n}.example.com
strategy: number
numberStart: 1
numberEnd: 100
```
Для неизвестного сайта расширение не создаёт зеркала самостоятельно: подходящие адреса и правила задаются в настройках. Оно использует обычные HTTP(S)-запросы и переходы браузера; работа выбранных зеркал зависит от самих сайтов и сети.
## Данные и разрешения
Правила, настройки, кэш работающих хостов и диагностические записи хранятся в браузере. В журнале могут быть URL страниц, названия вкладок, ошибки и адреса переходов. Журнал ограничен последними 1200 записями; очистка и экспорт доступны в настройках.
В коде нет аналитики, аккаунтов и сервера сбора данных. Проверки выполняются по адресам зеркал из правил и могут следовать HTTP-перенаправлениям сайтов. При проверке зеркалу отправляются сохранённые путь и параметры запроса; фоновая проверка использует `credentials: omit`.
| Разрешение | Назначение |
| --- | --- |
| `storage` | Локальные настройки, кэш и диагностика; состояние текущей работы в хранилище сессии, если оно доступно. |
| `tabs` | Чтение адреса вкладки и переход на выбранное зеркало. |
| `webNavigation` | События навигации и ошибки загрузки главной страницы. |
| `webRequest` | Чтение HTTP-статусов главной страницы для определения ошибок. |
| Доступ к HTTP(S)-сайтам | Пользователь может настроить произвольные зеркала; контентный скрипт проверяет маркеры блокировки на страницах с подходящими правилами. |
Подробности — [PRIVACY.md](PRIVACY.md).
## Релиз 1.4.3
Для правил с готовым списком хостов поле RegExp скрыто и больше не требуется. Список хостов определяет, на каких исходных адресах работает правило. Также версия включает локализацию, исправления ложного определения блокировок и улучшенную диагностику проверок. Полный список — [примечания к релизу](STORE_RELEASE_NOTES_1.4.3.md).
## Проверка исходников
Зависимости и сборщик не требуются. Для проверки с установленным Node.js:
```text
node tests/shared.test.js
node --check shared.js
node --check options.js
node --check i18n.js
```
Содержимое ZIP в релизе совпадает с исходным пакетом 1.4.3 из предоставленной папки. Контрольные суммы опубликованы рядом с архивом. Папка исходного проекта называется `auto-mirror-switcher-mv2`, однако текущая версия использует Manifest V3.
+17
View File
@@ -0,0 +1,17 @@
# Auto Mirror Switcher 1.4.3 Release Notes
## English
- Added automatic UI language detection: Russian is used for `ru*` browser locales, English is used otherwise.
- Added English localization for the options page, stopped page, runtime messages, validation errors, and extension metadata.
- Reduced false blocked-page detections: built-in markers such as Cloudflare, forbidden, bad gateway, and service unavailable no longer stop browsing when they appear inside a normal loaded page.
- Improved fixed host-list rules: the host RegExp field is hidden and no longer required; the custom host list now defines which source hosts the rule handles.
- Improved probe diagnostics so the stopped page lists hosts checked by probe mode.
## Русский
- Добавлено автоопределение языка интерфейса: русский используется для `ru*` локалей браузера, английский - для остальных.
- Добавлена английская локализация страницы настроек, страницы остановки, служебных сообщений, ошибок валидации и metadata расширения.
- Уменьшены ложные срабатывания страниц блокировки: встроенные маркеры вроде Cloudflare, forbidden, bad gateway и service unavailable больше не останавливают работу, если встречаются внутри обычной загруженной страницы.
- Улучшены правила с готовым списком host: поле RegExp скрыто и больше не требуется; список host теперь сам определяет, на каких исходных host работает правило.
- Улучшена диагностика probe-режима: страница остановки показывает host, которые реально проверялись в фоне.
+8
View File
@@ -0,0 +1,8 @@
{
"extensionName": {
"message": "Auto Mirror Switcher"
},
"extensionDescription": {
"message": "Switch failed mirror hosts to a working candidate while preserving the page path, query, and hash."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extensionName": {
"message": "Auto Mirror Switcher"
},
"extensionDescription": {
"message": "Переключает неработающие mirror-домены на рабочий вариант, сохраняя путь страницы, query и hash."
}
}
+86
View File
@@ -0,0 +1,86 @@
(function (root) {
'use strict';
var S = root.AutoMirrorShared;
var D = root.AutoMirrorDiagnostics;
function getCachedHost(rule, callback) {
var key = S.getCacheKey(rule);
chrome.storage.local.get([key], function (items) {
var cached = items[key];
var ttlHours = Number(rule.cacheTtlHours);
var neverExpire = !Number.isFinite(ttlHours) || ttlHours <= 0;
if (!cached || !cached.host) {
D.appendDebugLog('debug', 'cache.miss', { rule: rule.name || rule.id || 'unknown', key: key });
callback(null);
return;
}
var cachedHost = S.normalizeHost(cached.host);
if (!S.matchesRule(rule, cachedHost)) {
chrome.storage.local.remove(key, function () {
D.log('Removed cached host outside the current rule/range:', cachedHost, 'rule:', rule.name || rule.id);
});
callback(null);
return;
}
if (!neverExpire) {
var time = Number(cached.time || 0);
var ttl = ttlHours * 60 * 60 * 1000;
if (!time || Date.now() - time > ttl) {
D.appendDebugLog('info', 'cache.expired', { rule: rule.name || rule.id || 'unknown', cachedHost: cachedHost, ttlHours: ttlHours });
callback(null);
return;
}
}
D.appendDebugLog('debug', 'cache.hit', { rule: rule.name || rule.id || 'unknown', cachedHost: cachedHost, ttlHours: ttlHours, neverExpire: neverExpire });
callback(cachedHost);
});
}
function setCachedHost(rule, host) {
host = S.normalizeHost(host);
if (!S.matchesRule(rule, host)) {
D.log('Not caching host outside the current rule/range:', host, 'rule:', rule.name || rule.id);
return;
}
var data = {};
data[S.getCacheKey(rule)] = {
host: host,
time: Date.now()
};
chrome.storage.local.set(data, function () {
D.appendDebugLog('info', 'cache.set', { rule: rule.name || rule.id || 'unknown', host: host });
});
}
function clearCachedHostIfMatches(rule, host, reason) {
var key = S.getCacheKey(rule);
var failedHost = S.normalizeHost(host);
chrome.storage.local.get([key], function (items) {
var cached = items[key];
var cachedHost = cached && cached.host ? S.normalizeHost(cached.host) : '';
if (!cachedHost || cachedHost !== failedHost) return;
chrome.storage.local.remove(key, function () {
D.log('Cleared cached host:', failedHost, 'reason:', reason || 'host-failed');
});
});
}
root.AutoMirrorCache = {
getCachedHost: getCachedHost,
setCachedHost: setCachedHost,
clearCachedHostIfMatches: clearCachedHostIfMatches
};
})(typeof globalThis !== 'undefined' ? globalThis : this);
+122
View File
@@ -0,0 +1,122 @@
(function (root) {
'use strict';
var DEBUG_LOG_KEY = 'debugLogs';
var DEBUG_LOG_LIMIT = 1200;
var debugLogsCache = [];
var earlyDebugLogs = [];
var debugLogsLoaded = false;
var debugSaveTimer = null;
function trimText(value, limit) {
value = String(value == null ? '' : value);
limit = Math.max(20, Number(limit || 500));
return value.length > limit ? value.slice(0, limit) + '…' : value;
}
function makePlainLogValue(value, depth) {
depth = Number(depth || 0);
if (value == null) return value;
if (typeof value === 'string') return trimText(value, 900);
if (typeof value === 'number' || typeof value === 'boolean') return value;
if (value instanceof Error) return { name: value.name, message: value.message, stack: trimText(value.stack, 1200) };
if (value instanceof URL) return value.href;
if (depth > 3) return '[max-depth]';
if (Array.isArray(value)) {
return value.slice(0, 40).map(function (item) {
return makePlainLogValue(item, depth + 1);
});
}
if (typeof value === 'object') {
var result = {};
Object.keys(value).slice(0, 60).forEach(function (key) {
result[key] = makePlainLogValue(value[key], depth + 1);
});
return result;
}
return trimText(String(value), 300);
}
function trimDebugLogs(logs) {
logs = Array.isArray(logs) ? logs : [];
return logs.length > DEBUG_LOG_LIMIT ? logs.slice(logs.length - DEBUG_LOG_LIMIT) : logs;
}
function scheduleDebugSave() {
if (!debugLogsLoaded || debugSaveTimer) return;
debugSaveTimer = root.setTimeout(function () {
debugSaveTimer = null;
try {
var payload = {};
payload[DEBUG_LOG_KEY] = trimDebugLogs(debugLogsCache);
chrome.storage.local.set(payload);
} catch (e) {
// Diagnostics must never break redirect logic.
}
}, 80);
}
function initializeDebugLogs() {
var defaults = {};
defaults[DEBUG_LOG_KEY] = [];
try {
chrome.storage.local.get(defaults, function (items) {
var stored = Array.isArray(items[DEBUG_LOG_KEY]) ? items[DEBUG_LOG_KEY] : [];
debugLogsCache = trimDebugLogs(stored.concat(earlyDebugLogs));
earlyDebugLogs = [];
debugLogsLoaded = true;
scheduleDebugSave();
});
} catch (e) {
debugLogsLoaded = true;
}
}
function appendDebugLog(level, event, data) {
try {
var entry = {
time: new Date().toISOString(),
level: level || 'info',
event: String(event || 'log'),
data: makePlainLogValue(data || {})
};
if (!debugLogsLoaded) {
earlyDebugLogs.push(entry);
earlyDebugLogs = trimDebugLogs(earlyDebugLogs);
return;
}
debugLogsCache.push(entry);
debugLogsCache = trimDebugLogs(debugLogsCache);
scheduleDebugSave();
} catch (e) {
// Logging must never break redirect logic.
}
}
function log() {
var args = Array.prototype.slice.call(arguments);
console.log.apply(console, ['[Auto Mirror Switcher]'].concat(args));
appendDebugLog('info', args[0] || 'log', { args: args.slice(1) });
}
function warn() {
var args = Array.prototype.slice.call(arguments);
console.warn.apply(console, ['[Auto Mirror Switcher]'].concat(args));
appendDebugLog('warn', args[0] || 'warn', { args: args.slice(1) });
}
root.AutoMirrorDiagnostics = {
initializeDebugLogs: initializeDebugLogs,
appendDebugLog: appendDebugLog,
log: log,
warn: warn
};
})(typeof globalThis !== 'undefined' ? globalThis : this);
+121
View File
@@ -0,0 +1,121 @@
(function (root) {
'use strict';
var S = root.AutoMirrorShared;
var C = root.AutoMirrorCache;
var D = root.AutoMirrorDiagnostics;
function probeCandidate(rule, host, sourceUrlObj, callback) {
var url = S.buildTargetUrl(rule, host, sourceUrlObj);
var done = false;
var timeout = Math.max(1000, Number(rule.timeoutMs || 3000));
var controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
var timer = controller ? root.setTimeout(function () {
controller.abort();
}, timeout) : null;
function finish(ok, status) {
if (done) return;
done = true;
if (timer) root.clearTimeout(timer);
callback({
host: host,
url: url,
ok: ok,
status: status || 0
});
}
try {
fetch(url, {
method: 'GET',
cache: 'no-store',
credentials: 'omit',
redirect: 'follow',
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
},
signal: controller ? controller.signal : undefined
}).then(function (response) {
response.text().then(function (text) {
var statusOk = response.status >= 200 && response.status < 400;
var textOk = !S.responseLooksBlocked(rule, text || '');
finish(statusOk && textOk, response.status);
}).catch(function () {
finish(false, response.status || 0);
});
}).catch(function () {
finish(false, 0);
});
} catch (e) {
finish(false, 0);
}
}
function findWorkingByProbe(rule, sourceUrlObj, triedHosts, callback) {
C.getCachedHost(rule, function (cachedHost) {
var currentHost = S.normalizeHost(sourceUrlObj.hostname);
var candidates = S.buildCandidates(rule, currentHost, cachedHost).filter(function (host) {
return !triedHosts[host];
});
D.appendDebugLog('debug', 'candidates.built', {
rule: rule.name || rule.id || 'unknown',
currentHost: currentHost,
cachedHost: cachedHost || '',
count: candidates.length,
first: candidates.slice(0, 12),
last: candidates.slice(Math.max(0, candidates.length - 5))
});
if (!candidates.length) {
callback(null);
return;
}
var index = 0;
var active = 0;
var finished = false;
var parallel = Math.max(1, Number(rule.parallelChecks || 8));
function launchMore() {
if (finished) return;
while (active < parallel && index < candidates.length) {
var host = candidates[index];
index += 1;
active += 1;
probeCandidate(rule, host, sourceUrlObj, function (result) {
active -= 1;
triedHosts[result.host] = true;
if (finished) return;
if (result.ok) {
finished = true;
callback(result.host);
return;
}
if (index >= candidates.length && active === 0) {
finished = true;
callback(null);
return;
}
launchMore();
});
}
}
launchMore();
});
}
root.AutoMirrorProbe = {
probeCandidate: probeCandidate,
findWorkingByProbe: findWorkingByProbe
};
})(typeof globalThis !== 'undefined' ? globalThis : this);
+182
View File
@@ -0,0 +1,182 @@
(function (root) {
'use strict';
var S = root.AutoMirrorShared;
var STORAGE_KEY = 'runtimeState';
var loaded = false;
var loading = false;
var callbacks = [];
var memory = {
lastMainFrameUrls: {},
tabStates: {}
};
function getStorageArea() {
if (chrome.storage && chrome.storage.session) return chrome.storage.session;
return chrome.storage.local;
}
function tabKey(tabId) {
return String(tabId);
}
function plainMap(value) {
var result = {};
if (!value || typeof value !== 'object') return result;
Object.keys(value).forEach(function (key) {
if (value[key]) result[key] = value[key];
});
return result;
}
function normalizeTabState(state) {
state = state && typeof state === 'object' ? state : {};
return {
ruleId: state.ruleId || '',
path: state.path || '',
startedAt: Number(state.startedAt || 0),
triedHosts: plainMap(state.triedHosts),
redirects: Number(state.redirects || 0),
lastRedirectFrom: state.lastRedirectFrom || '',
lastRedirectTo: state.lastRedirectTo || '',
lastRedirectAt: Number(state.lastRedirectAt || 0),
lastTriggerUrl: state.lastTriggerUrl || '',
lastTriggerAt: Number(state.lastTriggerAt || 0)
};
}
function normalizeRuntimeState(raw) {
raw = raw && typeof raw === 'object' ? raw : {};
var tabStates = {};
Object.keys(raw.tabStates || {}).forEach(function (key) {
tabStates[key] = normalizeTabState(raw.tabStates[key]);
});
return {
lastMainFrameUrls: plainMap(raw.lastMainFrameUrls),
tabStates: tabStates
};
}
function flushCallbacks() {
var pending = callbacks.slice();
callbacks = [];
pending.forEach(function (callback) {
callback();
});
}
function load(callback) {
if (loaded) {
callback();
return;
}
callbacks.push(callback);
if (loading) return;
loading = true;
try {
var defaults = {};
defaults[STORAGE_KEY] = memory;
getStorageArea().get(defaults, function (items) {
memory = normalizeRuntimeState(items && items[STORAGE_KEY]);
loaded = true;
loading = false;
flushCallbacks();
});
} catch (e) {
memory = normalizeRuntimeState(memory);
loaded = true;
loading = false;
flushCallbacks();
}
}
function save() {
if (!loaded) return;
try {
var payload = {};
payload[STORAGE_KEY] = normalizeRuntimeState(memory);
getStorageArea().set(payload);
} catch (e) {
// Runtime state is a convenience layer; redirect events can rebuild it.
}
}
function getLastMainFrameUrl(tabId, callback) {
load(function () {
callback(memory.lastMainFrameUrls[tabKey(tabId)] || '');
});
}
function setLastMainFrameUrl(tabId, url) {
load(function () {
memory.lastMainFrameUrls[tabKey(tabId)] = String(url || '');
save();
});
}
function getTabState(tabId, rule, sourceUrlObj, callback) {
load(function () {
var key = tabKey(tabId);
var path = S.getPath(sourceUrlObj);
var now = Date.now();
var oldState = normalizeTabState(memory.tabStates[key]);
if (
!oldState.startedAt ||
oldState.ruleId !== rule.id ||
oldState.path !== path ||
now - oldState.startedAt > 5 * 60 * 1000
) {
oldState = {
ruleId: rule.id,
path: path,
startedAt: now,
triedHosts: {},
redirects: 0,
lastRedirectFrom: '',
lastRedirectTo: '',
lastRedirectAt: 0,
lastTriggerUrl: '',
lastTriggerAt: 0
};
memory.tabStates[key] = oldState;
save();
} else {
memory.tabStates[key] = oldState;
}
callback(oldState);
});
}
function saveTabState(tabId, state) {
load(function () {
memory.tabStates[tabKey(tabId)] = normalizeTabState(state);
save();
});
}
function clearTab(tabId) {
load(function () {
delete memory.lastMainFrameUrls[tabKey(tabId)];
delete memory.tabStates[tabKey(tabId)];
save();
});
}
root.AutoMirrorRuntimeState = {
getLastMainFrameUrl: getLastMainFrameUrl,
setLastMainFrameUrl: setLastMainFrameUrl,
getTabState: getTabState,
saveTabState: saveTabState,
clearTab: clearTab
};
})(typeof globalThis !== 'undefined' ? globalThis : this);
+433
View File
@@ -0,0 +1,433 @@
(function (root) {
'use strict';
var S = root.AutoMirrorShared;
var D = root.AutoMirrorDiagnostics;
var C = root.AutoMirrorCache;
var P = root.AutoMirrorProbe;
var R = root.AutoMirrorRuntimeState;
D.initializeDebugLogs();
function getConfig(callback) {
chrome.storage.local.get({
enabled: true,
rules: S.getDefaultRules()
}, function (items) {
callback({
enabled: items.enabled !== false,
rules: Array.isArray(items.rules) ? items.rules : []
});
});
}
function initializeDefaults() {
chrome.storage.local.get(['rules'], function (items) {
if (!Array.isArray(items.rules)) {
chrome.storage.local.set({
enabled: true,
rules: S.getDefaultRules()
});
}
});
}
function reportBadRuleRegex(rule, error) {
D.warn('Bad hostRegex in rule:', rule && (rule.name || rule.id), error && error.message || String(error));
}
function findRuleForHost(rules, hostname) {
return S.findRuleForHost(rules, hostname, function (error, rule) {
reportBadRuleRegex(rule, error);
});
}
function ensureEventIsCurrent(tabId, rawUrl, reason, callback) {
try {
R.getLastMainFrameUrl(tabId, function (lastMainFrameUrl) {
chrome.tabs.get(tabId, function (tab) {
if (chrome.runtime.lastError || !tab || !tab.url) {
D.appendDebugLog('warn', 'trigger.staleIgnored', {
tabId: tabId,
eventUrl: rawUrl,
lastMainFrameUrl: lastMainFrameUrl,
reason: reason,
error: chrome.runtime.lastError && chrome.runtime.lastError.message || ''
});
callback(Boolean(lastMainFrameUrl && S.urlsReferToSameNavigation(lastMainFrameUrl, rawUrl)));
return;
}
var tabMatches = S.urlsReferToSameNavigation(tab.url, rawUrl);
var lastMatches = Boolean(lastMainFrameUrl && S.urlsReferToSameNavigation(lastMainFrameUrl, rawUrl));
if (lastMainFrameUrl) {
if (!lastMatches) {
D.appendDebugLog('warn', 'trigger.staleIgnored', {
tabId: tabId,
eventUrl: rawUrl,
currentTabUrl: tab.url,
lastMainFrameUrl: lastMainFrameUrl,
reason: reason
});
callback(false);
return;
}
if (!tabMatches) {
D.appendDebugLog('debug', 'trigger.currentByLastNavigationUrl', {
tabId: tabId,
eventUrl: rawUrl,
currentTabUrl: tab.url,
lastMainFrameUrl: lastMainFrameUrl,
reason: reason
});
}
callback(true);
return;
}
if (!tabMatches) {
D.appendDebugLog('warn', 'trigger.staleIgnored', {
tabId: tabId,
eventUrl: rawUrl,
currentTabUrl: tab.url,
lastMainFrameUrl: lastMainFrameUrl,
reason: reason
});
callback(false);
return;
}
callback(true);
});
});
} catch (e) {
D.appendDebugLog('warn', 'trigger.currentTabCheckFailed', {
tabId: tabId,
eventUrl: rawUrl,
reason: reason,
error: e && e.message || String(e)
});
callback(false);
}
}
function getTabState(tabId, rule, sourceUrlObj, callback) {
R.getTabState(tabId, rule, sourceUrlObj, callback);
}
function logBuiltCandidates(rule, currentHost, cachedHost, candidates) {
D.appendDebugLog('debug', 'candidates.built', {
rule: rule.name || rule.id || 'unknown',
currentHost: currentHost,
cachedHost: cachedHost || '',
count: candidates.length,
first: candidates.slice(0, 12),
last: candidates.slice(Math.max(0, candidates.length - 5))
});
}
function chooseNextCandidate(rule, sourceUrlObj, state, callback) {
C.getCachedHost(rule, function (cachedHost) {
var currentHost = S.normalizeHost(sourceUrlObj.hostname);
var candidates = S.buildCandidates(rule, currentHost, cachedHost);
logBuiltCandidates(rule, currentHost, cachedHost, candidates);
D.appendDebugLog('debug', 'step.choose.start', {
rule: rule.name || rule.id || 'unknown',
currentHost: currentHost,
path: S.getPath(sourceUrlObj),
cachedHost: cachedHost || '',
triedHosts: Object.keys(state.triedHosts || {}),
candidatesCount: candidates.length,
candidatesFirst: candidates.slice(0, 15),
candidatesLast: candidates.slice(Math.max(0, candidates.length - 10))
});
for (var i = 0; i < candidates.length; i += 1) {
if (!state.triedHosts[candidates[i]]) {
D.appendDebugLog('info', 'step.choose.selected', {
rule: rule.name || rule.id || 'unknown',
selectedHost: candidates[i],
selectedIndex: i,
currentHost: currentHost
});
callback(candidates[i]);
return;
}
}
D.appendDebugLog('warn', 'step.choose.none', {
rule: rule.name || rule.id || 'unknown',
currentHost: currentHost,
triedHosts: Object.keys(state.triedHosts || {}),
candidatesCount: candidates.length
});
callback(null);
});
}
function showStoppedPage(tabId, rule, sourceUrlObj, state, reason) {
var stopId = 'stop:' + tabId + ':' + Date.now();
var tried = Object.keys(state && state.triedHosts || {});
var data = {};
data[stopId] = {
time: new Date().toISOString(),
rule: rule && (rule.name || rule.id) || 'unknown',
sourceUrl: sourceUrlObj && sourceUrlObj.href || '',
path: sourceUrlObj ? S.getPath(sourceUrlObj) : '',
reason: reason || 'stopped',
triedHosts: tried,
redirects: state && state.redirects || 0,
range: S.getNumberRange(rule || {})
};
D.appendDebugLog('warn', 'stopPage.open', data[stopId]);
chrome.storage.local.set(data, function () {
chrome.tabs.update(tabId, {
url: chrome.runtime.getURL('stopped.html') + '#' + encodeURIComponent(stopId)
});
});
}
function redirectTab(tabId, rule, host, sourceUrlObj, state, reason) {
var target = S.buildTargetUrl(rule, host, sourceUrlObj);
state.redirects += 1;
state.triedHosts[host] = true;
state.lastRedirectFrom = sourceUrlObj.href;
state.lastRedirectTo = target;
state.lastRedirectAt = Date.now();
D.appendDebugLog('info', 'redirect', {
tabId: tabId,
rule: rule.name || rule.id || 'unknown',
from: sourceUrlObj.href,
fromHost: S.normalizeHost(sourceUrlObj.hostname),
to: target,
toHost: host,
reason: reason,
redirects: state.redirects,
triedHosts: Object.keys(state.triedHosts || {})
});
D.log('Redirecting tab', tabId, 'to', target, 'reason:', reason);
R.setLastMainFrameUrl(tabId, target);
R.saveTabState(tabId, state);
chrome.tabs.update(tabId, { url: target });
}
function maybeRedirectToCachedHost(tabId, rawUrl) {
if (typeof tabId !== 'number' || tabId < 0) return;
var sourceUrlObj = S.safeUrl(rawUrl);
if (!sourceUrlObj || !/^https?:$/.test(sourceUrlObj.protocol)) return;
getConfig(function (config) {
if (!config.enabled) return;
var currentHost = S.normalizeHost(sourceUrlObj.hostname);
var rule = findRuleForHost(config.rules, currentHost);
if (!rule) return;
D.appendDebugLog('debug', 'beforeNavigate.ruleMatched', { tabId: tabId, url: rawUrl, host: currentHost, rule: rule.name || rule.id || 'unknown' });
if (!S.preferCachedHostEnabled(rule)) {
D.appendDebugLog('debug', 'cachedRedirect.disabled', { tabId: tabId, host: currentHost, rule: rule.name || rule.id || 'unknown' });
return;
}
C.getCachedHost(rule, function (cachedHost) {
cachedHost = S.normalizeHost(cachedHost);
if (!cachedHost || cachedHost === currentHost) {
D.appendDebugLog('debug', 'cachedRedirect.skipped', { tabId: tabId, host: currentHost, cachedHost: cachedHost || '', reason: !cachedHost ? 'no-cache' : 'same-host' });
return;
}
if (!S.matchesRule(rule, cachedHost)) {
D.appendDebugLog('warn', 'cachedRedirect.invalidCachedHost', { tabId: tabId, host: currentHost, cachedHost: cachedHost, rule: rule.name || rule.id || 'unknown' });
return;
}
getTabState(tabId, rule, sourceUrlObj, function (state) {
if (state.redirects >= S.getEffectiveMaxRedirects(rule)) return;
if (state.triedHosts[currentHost]) return;
state.triedHosts[currentHost] = true;
redirectTab(tabId, rule, cachedHost, sourceUrlObj, state, 'cached-host');
});
});
});
}
function triggerRedirect(tabId, rawUrl, reason) {
if (typeof tabId !== 'number' || tabId < 0) return;
var sourceUrlObj = S.safeUrl(rawUrl);
if (!sourceUrlObj || !/^https?:$/.test(sourceUrlObj.protocol)) return;
ensureEventIsCurrent(tabId, rawUrl, reason, function (isCurrent) {
if (!isCurrent) return;
getConfig(function (config) {
if (!config.enabled) return;
var currentHost = S.normalizeHost(sourceUrlObj.hostname);
var rule = findRuleForHost(config.rules, currentHost);
if (!rule) {
D.appendDebugLog('debug', 'trigger.noRule', { tabId: tabId, url: rawUrl, host: currentHost, reason: reason });
return;
}
D.appendDebugLog('info', 'trigger', {
tabId: tabId,
url: rawUrl,
host: currentHost,
reason: reason,
rule: rule.name || rule.id || 'unknown',
mode: rule.mode || 'step',
strategy: rule.strategy || 'number',
range: S.getNumberRange(rule),
includeEmpty: rule.includeEmpty === true,
template: rule.template || ''
});
C.clearCachedHostIfMatches(rule, currentHost, reason);
getTabState(tabId, rule, sourceUrlObj, function (state) {
var maxRedirects = S.getEffectiveMaxRedirects(rule);
var now = Date.now();
if (state.lastTriggerUrl === sourceUrlObj.href && now - Number(state.lastTriggerAt || 0) < 2500) {
D.appendDebugLog('warn', 'trigger.duplicateIgnored', {
tabId: tabId,
url: sourceUrlObj.href,
reason: reason,
previousAt: state.lastTriggerAt || 0
});
return;
}
state.lastTriggerUrl = sourceUrlObj.href;
state.lastTriggerAt = now;
state.triedHosts[currentHost] = true;
R.saveTabState(tabId, state);
if (state.redirects >= maxRedirects) {
D.warn('Max redirects reached for tab', tabId, 'rule:', rule.name || rule.id, 'effective limit:', maxRedirects);
showStoppedPage(tabId, rule, sourceUrlObj, state, 'max-redirects:' + reason);
return;
}
if (rule.mode === 'probe') {
P.findWorkingByProbe(rule, sourceUrlObj, state.triedHosts, function (host) {
if (!host) {
D.warn('No working mirror found by probe for rule:', rule.name || rule.id);
showStoppedPage(tabId, rule, sourceUrlObj, state, 'probe-no-working-host:' + reason);
return;
}
C.setCachedHost(rule, host);
redirectTab(tabId, rule, host, sourceUrlObj, state, reason + '/probe');
});
} else {
chooseNextCandidate(rule, sourceUrlObj, state, function (host) {
if (!host) {
D.warn('No next candidate found for rule:', rule.name || rule.id);
showStoppedPage(tabId, rule, sourceUrlObj, state, 'no-next-candidate:' + reason);
return;
}
redirectTab(tabId, rule, host, sourceUrlObj, state, reason + '/step');
});
}
});
});
});
}
chrome.runtime.onInstalled.addListener(initializeDefaults);
initializeDefaults();
chrome.action.onClicked.addListener(function () {
if (chrome.runtime.openOptionsPage) {
chrome.runtime.openOptionsPage();
} else {
chrome.tabs.create({ url: chrome.runtime.getURL('options.html') });
}
});
chrome.webNavigation.onBeforeNavigate.addListener(function (details) {
if (details.frameId !== 0) return;
R.setLastMainFrameUrl(details.tabId, details.url);
maybeRedirectToCachedHost(details.tabId, details.url);
});
chrome.webNavigation.onCommitted.addListener(function (details) {
if (details.frameId !== 0) return;
R.setLastMainFrameUrl(details.tabId, details.url);
});
chrome.tabs.onRemoved.addListener(function (tabId) {
R.clearTab(tabId);
});
chrome.webNavigation.onErrorOccurred.addListener(function (details) {
if (details.frameId !== 0) return;
triggerRedirect(details.tabId, details.url, 'navigation-error:' + (details.error || 'unknown'));
});
chrome.webRequest.onCompleted.addListener(function (details) {
if (!details || details.type !== 'main_frame') return;
var status = Number(details.statusCode || 0);
if (status < 500 && status !== 403 && status !== 451) return;
triggerRedirect(details.tabId, details.url, 'http-status:' + status);
}, { urls: ['http://*/*', 'https://*/*'] });
chrome.runtime.onMessage.addListener(function (message, sender) {
if (!message) return;
if (message.type === 'DEBUG_LOG') {
D.appendDebugLog(message.level || 'debug', message.event || 'content.debug', Object.assign({
tabId: sender && sender.tab ? sender.tab.id : null,
senderUrl: sender && sender.url ? sender.url : ''
}, message.data || {}));
return;
}
if (!sender || !sender.tab) return;
if (message.type === 'BLOCKED_PAGE_DETECTED') {
D.appendDebugLog('warn', 'content.blockedPageDetected', {
tabId: sender.tab.id,
url: message.url,
marker: message.marker || '',
rule: message.rule || '',
title: message.title || ''
});
triggerRedirect(sender.tab.id, message.url, 'content-blocked-page:' + (message.marker || 'marker'));
return;
}
if (message.type === 'PAGE_OK') {
D.appendDebugLog('info', 'content.pageOk', { tabId: sender.tab.id, url: message.url, rule: message.rule || '', title: message.title || '' });
var urlObj = S.safeUrl(message.url);
if (!urlObj) return;
ensureEventIsCurrent(sender.tab.id, message.url, 'content-page-ok', function (isCurrent) {
if (!isCurrent) return;
getConfig(function (config) {
var rule = findRuleForHost(config.rules, urlObj.hostname);
if (!rule) return;
C.setCachedHost(rule, urlObj.hostname);
R.clearTab(sender.tab.id);
D.log('Cached working host:', urlObj.hostname, 'for rule:', rule.name || rule.id);
});
});
}
});
})(typeof globalThis !== 'undefined' ? globalThis : this);
+108
View File
@@ -0,0 +1,108 @@
(function (root) {
'use strict';
var S = root.AutoMirrorShared;
function findBlockedMarker(rule) {
return S.findBlockedMarkerInPage(
rule,
document.title || '',
document.body ? document.body.innerText : ''
);
}
function sendDebug(event, data, level) {
try {
chrome.runtime.sendMessage({
type: 'DEBUG_LOG',
level: level || 'debug',
event: event,
data: data || {}
});
} catch (e) {
// Diagnostics must never break the page.
}
}
function reportBlocked(rule, marker) {
chrome.runtime.sendMessage({
type: 'BLOCKED_PAGE_DETECTED',
url: location.href,
marker: marker || '',
rule: rule && (rule.name || rule.id) || '',
title: document.title || ''
});
}
function reportOk(rule) {
chrome.runtime.sendMessage({
type: 'PAGE_OK',
url: location.href,
rule: rule && (rule.name || rule.id) || '',
title: document.title || ''
});
}
function run() {
chrome.storage.local.get({ enabled: true, rules: [] }, function (items) {
if (items.enabled === false || !Array.isArray(items.rules)) return;
var rule = S.findRuleForHost(items.rules, location.hostname);
if (!rule) return;
sendDebug('content.ruleMatched', {
url: location.href,
host: location.hostname,
rule: rule.name || rule.id || 'unknown',
mode: rule.mode || 'step',
strategy: rule.strategy || 'number'
});
var delays = [0, 150, 600, 1800, 4200];
var reported = false;
delays.forEach(function (delay, index) {
root.setTimeout(function () {
if (reported) return;
var marker = findBlockedMarker(rule);
if (marker) {
reported = true;
sendDebug('content.blockedMarkerFound', {
url: location.href,
host: location.hostname,
rule: rule.name || rule.id || 'unknown',
marker: marker,
checkDelayMs: delay,
title: document.title || ''
}, 'warn');
reportBlocked(rule, marker);
return;
}
sendDebug('content.markerCheckClean', {
url: location.href,
host: location.hostname,
rule: rule.name || rule.id || 'unknown',
checkDelayMs: delay,
checkIndex: index + 1,
totalChecks: delays.length,
title: document.title || ''
});
if (index === delays.length - 1) {
reported = true;
reportOk(rule);
}
}, delay);
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
})(typeof globalThis !== 'undefined' ? globalThis : this);
+87
View File
@@ -0,0 +1,87 @@
# Auto Mirror Switcher
Browser extension for Chrome, Manifest V3. It switches failed mirror hosts to the next configured candidate while preserving the original page path, query parameters, and hash.
## Version 1.4.3
- Hid the host RegExp field for fixed host-list rules.
- Fixed list-mode matching so `customHosts` defines the handled source hosts without requiring a RegExp.
## Version 1.4.2
- Applied the false-positive guard to every built-in blocked-page marker, not only Cloudflare and `forbidden`.
- Added coverage that embeds each default marker inside a long normal page and verifies it does not stop browsing.
## Version 1.4.1
- Reduced false blocked-page detections from generic `forbidden` text embedded in otherwise working pages.
- Stopped pages now list hosts checked by `probe` mode, not only the source host.
## Version 1.4.0
- Added automatic UI language detection: Russian is used for `ru*` browser locales, English is used otherwise.
- Added English localization for the options page, stopped page, runtime messages, validation errors, and extension manifest metadata.
## Version 1.3.1
- Reduced false blocked-page detections when Cloudflare/522 text is embedded inside an otherwise working page.
- Ignored hidden/script/template HTML while probing candidate mirrors for blocked-page markers.
## Version 1.3.0
- Expanded the in-page usage guide with behavior details from the redirect logic.
- Replaced concrete browser/domain examples in the guide and settings placeholders with Chrome and example.com examples.
## Install In Chrome
1. Open `chrome://extensions`.
2. Enable Developer mode.
3. Click `Load unpacked`.
4. Select the unpacked extension folder.
5. Click the extension icon to open settings.
## Basic Setup
1. Enable the extension with the top toggle.
2. Select or create a rule.
3. Set `hostRegex`, `strategy`, and either `template` or a custom host list.
4. Click `Apply to rule`, then `Save`.
5. If autosave is enabled, changes are saved automatically and the manual save button is disabled.
## Example Rule
Source page:
```text
https://mirror1.example.com/path/page.html?view=full#top
```
Numeric mirror rule:
```text
hostRegex: ^mirror\d+\.example\.com$
template: mirror{n}.example.com
strategy: number
numberStart: 1
numberEnd: 100
```
If the current host fails, the extension can try:
```text
https://mirror2.example.com/path/page.html?view=full#top
https://mirror3.example.com/path/page.html?view=full#top
```
The host changes; path, query, and hash stay the same.
## How Switching Works
- `step` mode immediately opens the next candidate when a navigation error, HTTP 5xx/403/451, or blocked-page marker is detected.
- `probe` mode checks candidates in the background and opens the first host that responds with a 2xx/3xx status and no blocked-page markers.
- A successfully loaded page stores its host as the last working host for that rule.
- If the cached host later fails, the cache is cleared and the search starts again.
## Diagnostics
Use the diagnostics log in the options page when a rule does not switch as expected. You can also inspect the extension service worker from `chrome://extensions` and look for `[Auto Mirror Switcher]` logs.
+286
View File
@@ -0,0 +1,286 @@
(function (root) {
'use strict';
var EN = {
saveFixRule: 'Fix the error in the current rule before saving.',
saved: 'Saved.',
autoSaveOn: 'Autosave enabled.',
autoSaveOff: 'Autosave disabled.',
noRules: 'No rules yet.',
hostListLabel: 'host list',
untitledRule: 'Untitled rule',
ruleAppliedAuto: 'Rule applied and will be saved automatically.',
ruleAppliedManual: 'Rule applied. Do not forget to click "Save".',
ruleAddedAuto: 'Rule added and will be saved automatically.',
ruleAddedManual: 'Rule added. Do not forget to click "Save".',
cacheCleared: 'Last working host cache cleared for rule "{name}".',
confirmDeleteRule: 'Delete rule "{name}"?',
ruleDeletedAuto: 'Rule deleted and will be saved automatically.',
ruleDeletedManual: 'Rule deleted. Do not forget to click "Save".',
importNoRulesArray: 'No rules array was found in the file.',
importRuleError: 'Error in rule #{index} "{name}": {error}',
importNoRules: 'The imported file does not contain any rules.',
exportCanceled: 'Export canceled: fix the error in the current rule first.',
settingsExported: 'Settings exported to a JSON file.',
importInvalidObject: 'File must contain a JSON object with a rules field.',
importWrongAppConfirm: 'The file does not look like an Auto Mirror Switcher export. Try importing anyway?',
importCanceled: 'Import canceled.',
importedSavedCount: 'Imported and saved rules: {count}.',
importReplaceConfirm: 'Import will replace current rules ({current}) with rules from the file ({incoming}). Continue?',
importError: 'Import error: {message}',
importReadError: 'Import error: could not read the file.',
logEmpty: 'Log is empty.',
logUpdated: 'Log updated. Events: {count}.',
logExported: 'Log exported. Events: {count}.',
confirmClearLog: 'Clear the diagnostic log?',
logCleared: 'Diagnostic log cleared.',
logCopied: 'Log copied.',
logCopyManual: 'Could not copy the log automatically. Select the text manually.',
validationHostRegexRequired: 'The hostRegex field cannot be empty.',
validationHostRegexInvalid: 'Invalid RegExp in hostRegex: {detail}',
validationNumberTemplateMissing: 'For number strategy, the host template must contain {n}.',
validationLettersTemplateMissing: 'For letter strategy, the host template must contain {s}.',
validationListHostsRequired: 'For list mode, enter at least one host.'
};
function browserLanguage() {
try {
if (root.chrome && chrome.i18n && chrome.i18n.getUILanguage) {
return chrome.i18n.getUILanguage();
}
} catch (e) {
// Fall back to navigator below.
}
return root.navigator && (navigator.language || navigator.userLanguage) || 'en';
}
function detectLanguage() {
var language = String(browserLanguage() || 'en').toLowerCase();
return /^ru(?:-|$)/.test(language) ? 'ru' : 'en';
}
var language = detectLanguage();
function format(text, vars) {
vars = vars || {};
return String(text == null ? '' : text).replace(/\{([a-zA-Z0-9_]+)\}/g, function (match, key) {
return Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : match;
});
}
function t(key, fallback, vars) {
var text = language === 'en' && EN[key] ? EN[key] : fallback;
return format(text == null ? key : text, vars);
}
function validationError(error, fallback) {
if (!error) return null;
if (language !== 'en') return fallback || error.message || '';
var key = {
hostRegexRequired: 'validationHostRegexRequired',
hostRegexInvalid: 'validationHostRegexInvalid',
numberTemplateMissing: 'validationNumberTemplateMissing',
lettersTemplateMissing: 'validationLettersTemplateMissing',
listHostsRequired: 'validationListHostsRequired'
}[error.code];
return t(key || '', fallback || error.message || '', {
detail: error.detail || ''
});
}
function $(selector) {
return document.querySelector(selector);
}
function setText(selector, value) {
var element = $(selector);
if (element) element.textContent = value;
}
function setHtml(selector, value) {
var element = $(selector);
if (element) element.innerHTML = value;
}
function setAttr(selector, attr, value) {
var element = $(selector);
if (element) element.setAttribute(attr, value);
}
function setLabelPrefix(controlSelector, value) {
var control = $(controlSelector);
var label = control && control.closest ? control.closest('label') : null;
if (!label) return;
Array.prototype.some.call(label.childNodes, function (node) {
if (node.nodeType !== Node.TEXT_NODE || !node.nodeValue.trim()) return false;
node.nodeValue = node.nodeValue.replace(node.nodeValue.trim(), value);
return true;
});
}
function applyOptionsEnglish() {
document.title = 'Auto Mirror Switcher';
setText('.header p', 'Automatically switches failed mirror domains while preserving the full page path.');
setText('.header .switch-card span', 'Extension enabled');
setText('#addRuleBtn', 'Add rule');
setText('#addMultclubBtn', 'Add Multclub preset');
setText('#exportSettingsBtn', 'Export settings');
setText('#importSettingsBtn', 'Import settings');
setText('.autosave-toggle span', 'Autosave');
setText('#saveBtn', 'Save');
setText('#guideSection > summary h2', 'How to use');
setText('#guideSection > summary p', 'Step-by-step rule setup and when the extension switches mirrors.');
setText('.save-reminder', 'Important: click "Save" after changes');
setText('.steps .step:nth-child(1) strong', 'Enable the extension and rule');
setText('.steps .step:nth-child(1) p', 'The global switch at the top enables page handling. Each rule has its own checkbox in the editor; disabled rules are ignored during mirror search.');
setText('.steps .step:nth-child(2) strong', 'Describe source addresses');
setText('.steps .step:nth-child(2) p', 'In the RegExp field, specify which hosts should be handled. Only the host is checked, without protocol, path, query, or hash, for example mirror1.example.com.');
setText('.steps .step:nth-child(3) strong', 'Configure candidates');
setText('.steps .step:nth-child(3) p', 'Choose a strategy: numbers in a template, letter tokens, or a fixed host list. The new address always uses the selected host plus the original page path.');
setText('.steps .step:nth-child(4) strong', 'Save and test');
setText('.steps .step:nth-child(4) p', 'After saving, open a page. The extension reacts to network errors, HTTP 5xx/403/451, or a configured blocked-page marker in the page text.');
setText('#guideSection .section-body > details:nth-of-type(1) > summary', 'Example with a sample domain');
setText('#guideSection .section-body > details:nth-of-type(1) .details-body p:nth-of-type(1)', 'If this page is open:');
setText('#guideSection .section-body > details:nth-of-type(1) .details-body p:nth-of-type(2)', 'and the current host is considered broken, the extension picks the next host by the rule and opens the same path:');
setHtml('#guideSection .section-body > details:nth-of-type(1) .details-body p:nth-of-type(3)', 'The protocol comes from the rule setting: always <code>https</code>, always <code>http</code>, or the same as the source URL. Path, query, and hash are preserved unchanged.');
setText('#guideSection .section-body > details:nth-of-type(2) > summary', 'Example rules for example.com');
setText('#guideSection .guide-columns > div:nth-child(1) h3', 'Numbers in a subdomain');
setText('#guideSection .guide-columns > div:nth-child(2) h3', 'Numbers before the domain');
setText('#guideSection .guide-columns > div:nth-child(3) h3', 'Variant without a number');
setHtml('#guideSection .guide-columns > div:nth-child(3) p', '<code>\\d+</code> means "one or more digits", and <code>\\d*</code> allows no digit. Enable the no-number variant only if a host like <code>mirror.example.com</code> should also be a candidate.');
setText('#guideSection .guide-columns > div:nth-child(4) h3', 'Letters/tokens');
setText('#guideSection .guide-columns > div:nth-child(5) h3', 'Fixed list');
setText('#guideSection .section-body > details:nth-of-type(3) > summary', 'What the main fields mean');
setText('.field-help dt:nth-of-type(1)', 'Which hosts to handle, RegExp');
setHtml('.field-help dd:nth-of-type(1)', 'Host filter where the extension should activate in template-based modes. Only the host is checked, for example <code>mirror1.example.com</code>, without <code>https://</code>, path, or parameters. In fixed-list mode this field is hidden: the rule activates on hosts from the list.');
setText('.field-help dt:nth-of-type(2)', 'step mode');
setText('.field-help dd:nth-of-type(2)', 'On error, immediately picks the next candidate from the list and opens it in the current tab. Candidates are built from the rule, and already tried hosts are skipped.');
setText('.field-help dt:nth-of-type(3)', 'probe mode');
setText('.field-help dd:nth-of-type(3)', 'Checks candidates with background requests first, then opens the first host that responds with 2xx/3xx and no blocked-page markers in the text. The number of simultaneous checks is set by "Parallel checks".');
setText('.field-help dt:nth-of-type(4)', 'Last working host');
setHtml('.field-help dd:nth-of-type(4)', 'When a page loads without blocked-page markers, the content script sends <code>PAGE_OK</code>, and the extension remembers that host. <strong>"Working host cache, hours" = 0</strong> means "do not expire by time": the saved host is used until it itself returns an error or blocked-page marker.');
setText('.field-help dt:nth-of-type(5)', 'Blocked/error page markers');
setHtml('.field-help dd:nth-of-type(5)', 'After load, the content script checks the title and page text several times. If a marker is found, <code>BLOCKED_PAGE_DETECTED</code> is sent, the current host is considered bad, its cache is cleared, and the extension looks for the next option. Add exact phrases from the error page here, one per line.');
setText('.field-help dt:nth-of-type(6)', 'Maximum redirects');
setText('.field-help dd:nth-of-type(6)', 'This protects against an infinite search loop. The code automatically raises the effective limit to the candidate list size plus a reserve, but a manual value is still useful for large lists.');
setText('#guideSection .section-body > details:nth-of-type(4) > summary', 'Export and import settings');
setHtml('#guideSection .section-body > details:nth-of-type(4) .details-body p:nth-of-type(1)', '<strong>Export settings</strong> downloads a JSON file with all rules and the global extension switch. Keep this file separately to quickly restore the configuration after reinstalling Chrome or moving the extension.');
setHtml('#guideSection .section-body > details:nth-of-type(4) .details-body p:nth-of-type(2)', '<strong>Import settings</strong> loads that JSON file back and replaces the current rules. After a successful import, settings are saved in the extension immediately.');
setText('#guideSection .section-body > details:nth-of-type(4) .details-body p:nth-of-type(3)', 'The last working host cache is not exported: it depends on the current Chrome profile and updates automatically when pages are actually opened.');
setText('#guideSection .section-body > details:nth-of-type(5) > summary', 'If it did not work');
setHtml('.troubleshooting li:nth-child(1)', 'Check that the extension is enabled at the top of this page and on <code>chrome://extensions</code>.');
setHtml('.troubleshooting li:nth-child(2)', 'After any changes, click <strong>"Apply to rule"</strong>, then <strong>"Save"</strong>. If autosave is enabled, the save button is disabled and changes are written automatically.');
setHtml('.troubleshooting li:nth-child(3)', 'For template-based modes, make sure <code>hostRegex</code> matches the host exactly, for example <code>mirror1.example.com</code>. In fixed-list mode, check that the current host is in the list.');
setHtml('.troubleshooting li:nth-child(4)', 'If the working number is outside the range, increase the <strong>"To"</strong> field. <strong>"Max redirects"</strong> automatically expands to the range size, but it can still be raised manually for large lists.');
setHtml('.troubleshooting li:nth-child(5)', 'If an error page opens with <code>Host Error</code>, <code>Error 522</code>, <code>Origin is unreachable</code>, <code>Cloudflare Ray ID</code>, or similar text and the extension does not switch, add the exact phrase from that page to <strong>"Blocked/error page markers"</strong>.');
setHtml('.troubleshooting li:nth-child(6)', 'If the extension keeps sending you to an old saved host, click "Clear rule cache" or open the problematic page directly; on DNS/network error, HTTP 5xx/403/451, or a found blocked marker, the cache will be cleared automatically.');
setHtml('.troubleshooting li:nth-child(7)', 'For diagnostics, open <strong>"Diagnostic log"</strong> in these settings, click <strong>"Refresh log"</strong>, and export JSON. You can also open the extension DevTools at <code>chrome://extensions</code> and watch <code>[Auto Mirror Switcher]</code> logs.');
setText('aside.card h2', 'Rules');
setText('.editor-card h2', 'Editor');
setText('#ruleForm > label.checkline span', 'Rule enabled');
setLabelPrefix('#name', 'Name');
setAttr('#name', 'placeholder', 'Example: Example mirrors');
setLabelPrefix('#hostRegex', 'Which hosts to handle, RegExp');
setHtml('#hostRegex + small', 'Only the host is checked, without protocol and path. Example: <code>mirror10.example.com</code>.');
setLabelPrefix('#protocol', 'Protocol for the new address');
setText('#protocol option[value="same"]', 'same as source URL');
setLabelPrefix('#mode', 'Mode on DNS/network error');
setText('#mode option[value="step"]', 'step: open the next option');
setText('#mode option[value="probe"]', 'probe: check options in the background first');
setLabelPrefix('#strategy', 'Search strategy');
setText('#strategy option[value="number"]', 'Numbers in the domain');
setText('#strategy option[value="letters"]', 'Letters/tokens in the domain');
setText('#strategy option[value="list"]', 'Fixed host list');
setLabelPrefix('#template', 'Host template');
setHtml('#template + small', 'For numbers use <code>{n}</code>; for letters/tokens use <code>{s}</code>. Examples: <code>mirror{n}.example.com</code>, <code>{n}.example.com</code>, <code>node-{s}.example.com</code>.');
setText('#numberBlock legend', 'Number search');
setLabelPrefix('#numberStart', 'From');
setLabelPrefix('#numberEnd', 'To');
setLabelPrefix('#numberStep', 'Step');
setLabelPrefix('#padLength', 'Left zeroes');
setHtml('#includeEmpty + span', 'Try the variant without a number: <code id="includeEmptyHostExample">example.com</code> for template <code id="includeEmptyTemplateExample">example{n}.com</code>');
setText('#lettersBlock legend', 'Letter/token search');
setLabelPrefix('#letterCharset', 'Character set');
setLabelPrefix('#letterMinLen', 'Min. length');
setLabelPrefix('#letterMaxLen', 'Max. length');
setLabelPrefix('#maxCandidates', 'Candidate limit');
setText('#listBlock legend', 'Host list');
setLabelPrefix('#customHosts', 'Hosts separated by newline, space, or comma');
setText('#ruleForm fieldset:nth-of-type(4) legend', 'Timing and loop protection');
setLabelPrefix('#timeoutMs', 'Probe timeout, ms');
setLabelPrefix('#parallelChecks', 'Parallel checks');
setLabelPrefix('#maxRedirects', 'Max redirects');
setLabelPrefix('#cacheTtlHours', 'Working host cache, hours');
setText('#cacheTtlHours + small', '0 = do not expire by time; cache is reset only if the host fails.');
setHtml('#preferCachedHost + span', 'Immediately open the last working host if it is already known. For example, from <code id="cachedFromExample">example1.com/page.html</code> go directly to <code id="cachedToExample">example2.com/page.html</code>.');
setLabelPrefix('#blockedMarkers', 'Blocked/error page markers');
setText('#blockedMarkers + small', 'If the loaded page contains one of these strings, the extension treats the mirror as broken and continues switching.');
setText('#applyRuleBtn', 'Apply to rule');
setText('#duplicateRuleBtn', 'Duplicate');
setText('#clearRuleCacheBtn', 'Clear rule cache');
setText('#deleteRuleBtn', 'Delete');
setText('.log-title', 'Diagnostic log');
setText('.log-description', 'If the extension goes to the wrong place, refresh the log and export JSON or copy text from the box.');
setText('#debugLogSection .guide-badge', 'Keeps up to the last 1200 events');
setText('#refreshLogBtn', 'Refresh log');
setText('#copyLogBtn', 'Copy log');
setText('#exportLogBtn', 'Export log');
setText('#clearLogBtn', 'Clear log');
setAttr('#debugLogOutput', 'placeholder', 'Redirect, cache, Cloudflare marker, and candidate selection events will appear here.');
setText('.log-body small', 'The log can contain URLs of pages opened through extension rules. Check it for sensitive data before publishing.');
}
function applyStoppedEnglish() {
document.title = 'Auto Mirror Switcher - search stopped';
setText('h1', 'Working mirror not found');
setText('.lead', 'The extension stopped searching to avoid an infinite redirect loop.');
setText('.grid > div:nth-child(1) label', 'Rule');
setText('.grid > div:nth-child(2) label', 'Stop reason');
setText('.grid > div:nth-child(3) label', 'Source URL');
setText('.grid > div:nth-child(4) label', 'Page path');
setText('.grid > div:nth-child(5) label', 'Redirects');
setText('.grid > div:nth-child(6) label', 'Range');
setText('section:not(.grid) label', 'Checked hosts');
setText('#copy', 'Copy diagnostics');
setText('#openOptions', 'Open settings');
setHtml('.hint', 'If every host shows Cloudflare 522/523/524, the extension will try them until the end of the range. For these sites, <strong>probe</strong> mode is more convenient: it checks candidates in the background and opens only the found working host.');
}
function applyPage() {
if (!document || !document.documentElement) return;
document.documentElement.lang = language;
if (language !== 'en') return;
var path = String(location && location.pathname || '');
if (path.indexOf('options.html') !== -1) {
applyOptionsEnglish();
} else if (path.indexOf('stopped.html') !== -1) {
applyStoppedEnglish();
}
}
root.AutoMirrorI18n = {
language: language,
t: t,
validationError: validationError,
applyPage: applyPage
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyPage);
} else {
applyPage();
}
})(typeof globalThis !== 'undefined' ? globalThis : this);
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 706 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+50
View File
@@ -0,0 +1,50 @@
{
"manifest_version": 3,
"name": "__MSG_extensionName__",
"version": "1.4.3",
"default_locale": "en",
"description": "__MSG_extensionDescription__",
"permissions": [
"storage",
"tabs",
"webNavigation",
"webRequest"
],
"host_permissions": [
"http://*/*",
"https://*/*"
],
"background": {
"service_worker": "service-worker.js"
},
"content_scripts": [
{
"matches": [
"http://*/*",
"https://*/*"
],
"js": [
"shared.js",
"content.js"
],
"run_at": "document_idle",
"all_frames": false
}
],
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"options_page": "options.html",
"action": {
"default_title": "__MSG_extensionName__",
"default_icon": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}
}
+652
View File
@@ -0,0 +1,652 @@
:root {
color-scheme: dark;
--bg: #080b12;
--bg-soft: #0d1220;
--card: #111827;
--card-2: #151f32;
--border: #263349;
--border-strong: #385171;
--text: #e6edf7;
--muted: #9aa8bd;
--muted-2: #738199;
--accent: #69a7ff;
--accent-strong: #2f81f7;
--accent-soft: rgba(47, 129, 247, .16);
--danger: #ff6b7a;
--danger-soft: rgba(255, 107, 122, .12);
--success: #5ee0a0;
--shadow: 0 18px 50px rgba(0, 0, 0, .35);
--radius: 16px;
}
* {
box-sizing: border-box;
}
html {
min-height: 100%;
background: var(--bg);
}
body {
min-height: 100%;
margin: 0;
background:
radial-gradient(circle at 15% -10%, rgba(47, 129, 247, .28), transparent 34%),
radial-gradient(circle at 85% 0%, rgba(94, 224, 160, .12), transparent 28%),
linear-gradient(180deg, #0a1020 0%, #080b12 54%, #07080d 100%);
color: var(--text);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
}
.page {
max-width: 1220px;
margin: 0 auto;
padding: 28px;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 18px;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
font-size: 12px;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
}
h1, h2, h3 {
color: var(--text);
margin: 0 0 8px;
line-height: 1.15;
}
h1 {
font-size: 32px;
letter-spacing: -0.04em;
}
h2 {
font-size: 20px;
letter-spacing: -0.02em;
}
h3 {
font-size: 14px;
}
p {
margin: 0;
color: var(--muted);
}
.card {
background: linear-gradient(180deg, rgba(17, 24, 39, .96), rgba(13, 18, 32, .96));
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow);
}
.action-card {
position: sticky;
top: 0;
z-index: 5;
backdrop-filter: blur(14px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
min-height: 78px;
padding: 16px 22px;
}
.grid {
display: grid;
grid-template-columns: 330px 1fr;
gap: 18px;
margin-top: 18px;
align-items: start;
}
.rules-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.rule-item {
width: 100%;
border: 1px solid var(--border);
border-radius: 12px;
padding: 11px;
text-align: left;
background: rgba(255, 255, 255, .035);
color: var(--text);
cursor: pointer;
transition: border-color .16s ease, background .16s ease, transform .16s ease;
}
.rule-item:hover {
border-color: var(--border-strong);
background: rgba(255, 255, 255, .055);
transform: translateY(-1px);
}
.rule-item.active {
border-color: var(--accent);
background: var(--accent-soft);
}
.rule-title {
font-weight: 800;
}
.rule-meta {
margin-top: 4px;
color: var(--muted);
font-size: 12px;
word-break: break-all;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
color: var(--text);
font-weight: 700;
}
small {
color: var(--muted);
font-weight: 400;
}
input[type="text"], input[type="number"], select, textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 11px;
font: inherit;
background: #0b1020;
color: var(--text);
outline: none;
transition: border-color .16s ease, box-shadow .16s ease, background .16s ease;
}
input[type="text"]:focus, input[type="number"]:focus, select:focus, textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(47, 129, 247, .18);
background: #0d1428;
}
input::placeholder, textarea::placeholder {
color: var(--muted-2);
}
input[type="checkbox"] {
width: 17px;
height: 17px;
accent-color: var(--accent-strong);
}
textarea {
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
}
fieldset {
margin: 0 0 14px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 14px;
background: rgba(255, 255, 255, .02);
}
legend {
padding: 0 6px;
color: var(--text);
font-weight: 850;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.row-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.action-card .row-actions {
flex: 1 1 auto;
}
.action-card .autosave-toggle {
margin-left: auto;
}
.action-card .primary {
margin-left: 0;
}
.checkline, .toggle {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 8px;
margin-bottom: 14px;
font-weight: 750;
}
.switch-card {
flex-shrink: 0;
margin: 0;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 999px;
background: rgba(17, 24, 39, .86);
box-shadow: 0 12px 40px rgba(0, 0, 0, .28);
}
.autosave-toggle {
flex-shrink: 0;
margin-bottom: 0;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(255, 255, 255, .035);
}
button {
border: 1px solid var(--border-strong);
border-radius: 12px;
background: #101827;
color: var(--text);
padding: 10px 13px;
font: inherit;
font-weight: 800;
cursor: pointer;
transition: transform .16s ease, border-color .16s ease, background .16s ease;
}
button:hover {
border-color: var(--accent);
background: #162237;
transform: translateY(-1px);
}
button:disabled,
button:disabled:hover {
cursor: not-allowed;
opacity: .48;
transform: none;
border-color: var(--border);
background: #101827;
}
button.primary {
border-color: var(--accent-strong);
background: linear-gradient(180deg, #2f81f7, #1f6feb);
color: #fff;
}
button.primary:hover {
background: linear-gradient(180deg, #4b97ff, #2f81f7);
}
button.primary:disabled,
button.primary:disabled:hover {
border-color: var(--border-strong);
background: #172033;
color: var(--muted);
}
button.danger {
border-color: rgba(255, 107, 122, .65);
background: var(--danger-soft);
color: var(--danger);
}
.toast-container {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 50;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
width: min(380px, calc(100vw - 40px));
pointer-events: none;
}
.toast {
width: fit-content;
max-width: 100%;
padding: 12px 14px;
border: 1px solid rgba(94, 224, 160, .42);
border-radius: 12px;
background: rgba(12, 19, 32, .96);
box-shadow: 0 16px 42px rgba(0, 0, 0, .42);
color: var(--success);
font-weight: 850;
overflow-wrap: anywhere;
animation: toast-in .18s ease-out;
pointer-events: auto;
}
.toast.leaving {
animation: toast-out .24s ease-in forwards;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes toast-out {
to {
opacity: 0;
transform: translateY(10px);
}
}
code {
background: rgba(105, 167, 255, .12);
border: 1px solid rgba(105, 167, 255, .16);
border-radius: 6px;
color: #cfe3ff;
padding: 1px 5px;
}
pre {
margin: 10px 0 0;
padding: 12px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 12px;
background: #070b14;
color: var(--text);
}
pre code {
padding: 0;
border: 0;
background: transparent;
}
.hidden {
display: none;
}
details.guide-card {
margin-top: 18px;
padding: 0;
}
details.guide-card[open] {
padding-bottom: 18px;
}
.guide-header, .section-summary {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 16px;
}
.section-summary {
justify-content: flex-start;
margin-bottom: 0;
padding: 18px;
list-style: none;
}
.section-summary::-webkit-details-marker {
display: none;
}
.section-summary > div {
flex: 1 1 auto;
min-width: 0;
}
.section-summary::before {
content: "▸";
display: grid;
place-items: center;
flex: 0 0 24px;
width: 24px;
height: 24px;
border: 1px solid var(--border);
border-radius: 50%;
color: var(--accent);
transition: transform .16s ease;
}
details[open] > .section-summary::before {
transform: rotate(90deg);
}
.section-body {
border-top: 1px solid var(--border);
padding: 18px 18px 0;
}
.guide-badge {
flex-shrink: 0;
max-width: 260px;
padding: 9px 11px;
border: 1px solid rgba(94, 224, 160, .3);
border-radius: 999px;
background: rgba(94, 224, 160, .1);
color: #b9f7d6;
font-size: 12px;
font-weight: 850;
}
.section-summary > .guide-badge {
margin-left: auto;
}
.autosave-active .save-reminder {
display: none;
}
.steps {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.step {
display: flex;
gap: 10px;
min-height: 128px;
padding: 13px;
border: 1px solid var(--border);
border-radius: 14px;
background: rgba(255, 255, 255, .03);
}
.step-num {
display: grid;
place-items: center;
flex: 0 0 28px;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
font-weight: 900;
}
.step strong {
display: block;
margin-bottom: 4px;
color: var(--text);
}
.step p {
font-size: 13px;
}
details {
border: 1px solid var(--border);
border-radius: 14px;
background: rgba(255, 255, 255, .025);
margin-top: 10px;
overflow: hidden;
}
summary {
padding: 13px 14px;
color: var(--text);
font-weight: 850;
cursor: pointer;
user-select: none;
}
summary:hover {
background: rgba(255, 255, 255, .035);
}
.details-body {
border-top: 1px solid var(--border);
padding: 14px;
}
.guide-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field-help {
display: grid;
grid-template-columns: 260px 1fr;
gap: 10px 16px;
margin: 0;
}
.field-help dt {
color: var(--text);
font-weight: 850;
}
.field-help dd {
margin: 0;
color: var(--muted);
}
.troubleshooting {
margin: 0;
padding-left: 20px;
color: var(--muted);
}
.troubleshooting li + li {
margin-top: 8px;
}
@media (max-width: 980px) {
.grid, .steps, .guide-columns, .field-help {
grid-template-columns: 1fr;
}
.header, .guide-header {
flex-direction: column;
}
.switch-card, .guide-badge {
max-width: none;
}
.action-card, .log-summary {
align-items: stretch;
}
.action-card .autosave-toggle,
.action-card .primary {
margin-left: 0;
}
.toast-container {
right: 14px;
bottom: 14px;
width: calc(100vw - 28px);
}
}
details.log-card {
border-color: rgba(85, 151, 255, .34);
margin-top: 18px;
padding: 0;
}
details.log-card[open] {
padding-bottom: 18px;
}
.log-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.log-summary-main {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1 1 auto;
}
.log-title {
color: var(--text);
font-size: 20px;
font-weight: 850;
line-height: 1.15;
}
.log-description {
color: var(--muted);
}
.log-body {
border-top: 1px solid var(--border);
padding: 18px 18px 0;
}
.debug-log {
width: 100%;
min-height: 260px;
margin-top: 12px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.45;
white-space: pre;
}
+327
View File
@@ -0,0 +1,327 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Auto Mirror Switcher</title>
<link rel="stylesheet" href="options.css">
</head>
<body>
<main class="page">
<header class="header">
<div>
<h1>Auto Mirror Switcher</h1>
<p>Автоматически переключает неработающие mirror-домены и сохраняет полный путь страницы.</p>
</div>
<label class="toggle switch-card">
<input id="globalEnabled" type="checkbox">
<span>Расширение включено</span>
</label>
</header>
<section class="card action-card">
<div class="row row-actions">
<button id="addRuleBtn" type="button">Добавить правило</button>
<button id="addMultclubBtn" type="button">Добавить preset Multclub</button>
<button id="exportSettingsBtn" type="button">Экспорт настроек</button>
<button id="importSettingsBtn" type="button">Импорт настроек</button>
<label class="checkline autosave-toggle">
<input id="autoSaveEnabled" type="checkbox">
<span>Автосохранение</span>
</label>
<button id="saveBtn" type="button" class="primary">Сохранить</button>
<input id="importFileInput" class="hidden" type="file" accept="application/json,.json">
</div>
</section>
<details class="card guide-card" id="guideSection">
<summary class="section-summary">
<div>
<h2>Как пользоваться</h2>
<p>Пошаговая настройка правил и объяснение того, когда расширение переключает зеркало.</p>
</div>
<div class="guide-badge save-reminder">Главное: после изменений нажимай «Сохранить»</div>
</summary>
<div class="section-body">
<div class="steps">
<div class="step">
<span class="step-num">1</span>
<div>
<strong>Включи расширение и правило</strong>
<p>Глобальный переключатель сверху включает обработку страниц. Внутри редактора у каждого правила есть отдельный чекбокс: выключенное правило не участвует в поиске зеркал.</p>
</div>
</div>
<div class="step">
<span class="step-num">2</span>
<div>
<strong>Опиши исходные адреса</strong>
<p>В поле RegExp укажи, какие host обрабатываются. Проверяется только host без протокола, пути, query и hash, например <code>mirror1.example.com</code>.</p>
</div>
</div>
<div class="step">
<span class="step-num">3</span>
<div>
<strong>Настрой кандидатов</strong>
<p>Выбери стратегию: числа в шаблоне, буквенные токены или готовый список host. Новый адрес всегда собирается из выбранного host и исходного пути страницы.</p>
</div>
</div>
<div class="step">
<span class="step-num">4</span>
<div>
<strong>Сохрани и проверь</strong>
<p>После сохранения открой страницу. Расширение сработает при сетевой ошибке, HTTP 5xx/403/451 или найденном маркере блокировки в тексте страницы.</p>
</div>
</div>
</div>
<details>
<summary>Пример с условным доменом</summary>
<div class="details-body">
<p>Если открыта страница:</p>
<pre><code>https://mirror1.example.com/path/page.html?view=full#top</code></pre>
<p>и текущий host считается нерабочим, расширение подбирает следующий host по правилу и открывает тот же путь:</p>
<pre><code>https://mirror2.example.com/path/page.html?view=full#top
https://mirror3.example.com/path/page.html?view=full#top
https://mirror4.example.com/path/page.html?view=full#top</code></pre>
<p>Протокол берётся из настройки правила: всегда <code>https</code>, всегда <code>http</code> или такой же, как в исходном URL. Path, query и hash сохраняются без изменений.</p>
</div>
</details>
<details>
<summary>Примеры правил для example.com</summary>
<div class="details-body guide-columns">
<div>
<h3>Цифры в поддомене</h3>
<pre><code>hostRegex: ^mirror\d+\.example\.com$
template: mirror{n}.example.com
strategy: number</code></pre>
</div>
<div>
<h3>Цифры перед доменом</h3>
<pre><code>hostRegex: ^\d+\.example\.com$
template: {n}.example.com
strategy: number</code></pre>
</div>
<div>
<h3>Вариант без цифры</h3>
<pre><code>hostRegex: ^mirror\d*\.example\.com$
template: mirror{n}.example.com
strategy: number
includeEmpty: on</code></pre>
<p><code>\d+</code> означает «одна или больше цифр», а <code>\d*</code> разрешает отсутствие цифры. Включай вариант без цифры только если host вроде <code>mirror.example.com</code> тоже должен быть кандидатом.</p>
</div>
<div>
<h3>Буквы/токены</h3>
<pre><code>hostRegex: ^node-[a-z]+\.example\.com$
template: node-{s}.example.com
strategy: letters</code></pre>
</div>
<div>
<h3>Готовый список</h3>
<pre><code>mirror1.example.com
mirror2.example.com
mirror3.example.com</code></pre>
</div>
</div>
</details>
<details>
<summary>Что означают основные поля</summary>
<div class="details-body">
<dl class="field-help">
<dt>Какие host обрабатывать, RegExp</dt>
<dd>Фильтр host, на которых расширение должно включаться в режимах с шаблоном. Проверяется только host: например <code>mirror1.example.com</code>, без <code>https://</code>, пути и параметров. В режиме готового списка это поле скрывается: правило включается на host из списка.</dd>
<dt>Режим step</dt>
<dd>При ошибке сразу выбирает следующий кандидат из списка и открывает его в текущей вкладке. Кандидаты строятся по правилу, уже попробованные host пропускаются.</dd>
<dt>Режим probe</dt>
<dd>Сначала проверяет кандидатов фоновыми запросами, потом открывает первый ответивший host со статусом 2xx/3xx и без маркеров блокировки в тексте. Количество одновременных проверок задаёт поле «Параллельных проверок».</dd>
<dt>Последний рабочий host</dt>
<dd>Когда страница загрузилась без маркеров блокировки, content script отправляет сигнал <code>PAGE_OK</code>, и расширение запоминает этот host. Значение <strong>«Кэш рабочего host, часов» = 0</strong> означает «не забывать по времени»: сохранённый host используется, пока сам не даст ошибку или маркер блокировки.</dd>
<dt>Маркеры страницы блокировки/ошибки</dt>
<dd>Content script несколько раз после загрузки проверяет title и текст страницы. Если найден маркер, отправляется <code>BLOCKED_PAGE_DETECTED</code>, текущий host считается плохим, кэш для него очищается, и расширение ищет следующий вариант. Добавляй сюда точные фразы из страницы ошибки, по одной строке.</dd>
<dt>Максимум редиректов</dt>
<dd>Это защита от бесконечного перебора. Код автоматически поднимает эффективный лимит до размера списка кандидатов плюс запас, но ручное значение всё равно полезно для больших списков.</dd>
</dl>
</div>
</details>
<details>
<summary>Экспорт и импорт настроек</summary>
<div class="details-body">
<p><strong>Экспорт настроек</strong> скачивает JSON-файл со всеми правилами и глобальным переключателем расширения. Сохрани этот файл отдельно, чтобы быстро восстановить конфигурацию после переустановки Chrome или переноса расширения.</p>
<p><strong>Импорт настроек</strong> загружает такой JSON-файл обратно и заменяет текущие правила. После успешного импорта настройки сразу сохраняются в расширении.</p>
<p>Кэш последнего рабочего host не экспортируется: он зависит от текущего профиля Chrome и обновляется автоматически при реальном открытии страниц.</p>
</div>
</details>
<details>
<summary>Если не сработало</summary>
<div class="details-body">
<ul class="troubleshooting">
<li>Проверь, что расширение включено вверху страницы и на странице <code>chrome://extensions</code>.</li>
<li>После любых изменений нажми <strong>«Применить к правилу»</strong>, затем <strong>«Сохранить»</strong>. Если включено автосохранение, кнопка сохранения отключена, а изменения записываются автоматически.</li>
<li>Для режимов с шаблоном убедись, что <code>hostRegex</code> совпадает именно с host, например <code>mirror1.example.com</code>. В режиме готового списка проверь, что текущий host есть в списке.</li>
<li>Если рабочий номер вышел за диапазон, увеличь поле <strong>«До»</strong>. Поле <strong>«Макс. редиректов»</strong> автоматически расширяется до размера диапазона, но его всё равно можно поднять вручную для больших списков.</li>
<li>Если открылась страница ошибки с <code>Host Error</code>, <code>Error 522</code>, <code>Origin is unreachable</code>, <code>Cloudflare Ray ID</code> или похожим текстом и расширение не переключилось, добавь точную фразу из этой страницы в поле <strong>«Маркеры страницы блокировки/ошибки»</strong>.</li>
<li>Если расширение продолжает вести на старый сохранённый host, нажми «Очистить кэш правила» или открой именно проблемную страницу; при DNS/сетевой ошибке, HTTP 5xx/403/451 или найденном маркере блокировки кэш будет сброшен автоматически.</li>
<li>Для диагностики открой блок <strong>«Журнал диагностики»</strong> в этих настройках, нажми <strong>«Обновить журнал»</strong> и экспортируй JSON. Также можно открыть DevTools расширения на <code>chrome://extensions</code> и смотреть логи <code>[Auto Mirror Switcher]</code>.</li>
</ul>
</div>
</details>
</div>
</details>
<section class="grid">
<aside class="card">
<h2>Правила</h2>
<div id="rulesList" class="rules-list"></div>
</aside>
<section class="card editor-card">
<h2>Редактор</h2>
<form id="ruleForm">
<input id="ruleId" type="hidden">
<label class="checkline">
<input id="ruleEnabled" type="checkbox">
<span>Правило включено</span>
</label>
<label>
Название
<input id="name" type="text" placeholder="Например: Example mirrors">
</label>
<label id="hostRegexBlock">
Какие host обрабатывать, RegExp
<input id="hostRegex" type="text" placeholder="^mirror\d*\.example\.com$">
<small>Проверяется только host без протокола и пути. Например: <code>mirror10.example.com</code>.</small>
</label>
<div class="row">
<label>
Протокол для нового адреса
<select id="protocol">
<option value="https">https</option>
<option value="http">http</option>
<option value="same">как в исходном URL</option>
</select>
</label>
<label>
Режим при ошибке DNS/сети
<select id="mode">
<option value="step">step: открыть следующий вариант</option>
<option value="probe">probe: сначала проверить варианты в фоне</option>
</select>
</label>
</div>
<label>
Тип перебора
<select id="strategy">
<option value="number">Цифры в домене</option>
<option value="letters">Буквы/токены в домене</option>
<option value="list">Готовый список host</option>
</select>
</label>
<label class="templateBlock">
Шаблон host
<input id="template" type="text" placeholder="mirror{n}.example.com">
<small>Для цифр используй <code>{n}</code>, для букв/токенов — <code>{s}</code>. Примеры: <code>mirror{n}.example.com</code>, <code>{n}.example.com</code>, <code>node-{s}.example.com</code>.</small>
</label>
<fieldset id="numberBlock">
<legend>Цифровой перебор</legend>
<div class="row">
<label>От <input id="numberStart" type="number" min="0" step="1"></label>
<label>До <input id="numberEnd" type="number" min="0" step="1"></label>
<label>Шаг <input id="numberStep" type="number" min="1" step="1"></label>
<label>Нули слева <input id="padLength" type="number" min="0" step="1"></label>
</div>
<label class="checkline">
<input id="includeEmpty" type="checkbox">
<span>Пробовать вариант без цифры: <code id="includeEmptyHostExample">example.com</code> для шаблона <code id="includeEmptyTemplateExample">example{n}.com</code></span>
</label>
</fieldset>
<fieldset id="lettersBlock">
<legend>Буквенный/токенный перебор</legend>
<label>
Набор символов
<input id="letterCharset" type="text" placeholder="abcdefghijklmnopqrstuvwxyz">
</label>
<div class="row">
<label>Мин. длина <input id="letterMinLen" type="number" min="1" step="1"></label>
<label>Макс. длина <input id="letterMaxLen" type="number" min="1" step="1"></label>
<label>Лимит вариантов <input id="maxCandidates" type="number" min="1" step="1"></label>
</div>
</fieldset>
<fieldset id="listBlock">
<legend>Список host</legend>
<label>
Host-ы через новую строку, пробел или запятую
<textarea id="customHosts" rows="7" placeholder="mirror1.example.com&#10;mirror2.example.com"></textarea>
</label>
</fieldset>
<fieldset>
<legend>Тайминги и защита от циклов</legend>
<div class="row">
<label>Timeout проверки, мс <input id="timeoutMs" type="number" min="500" step="100"></label>
<label>Параллельных проверок <input id="parallelChecks" type="number" min="1" step="1"></label>
<label>Макс. редиректов <input id="maxRedirects" type="number" min="1" step="1"></label>
<label>Кэш рабочего host, часов <input id="cacheTtlHours" type="number" min="0" step="1"><small>0 = не забывать по времени; кэш сбрасывается только если host умер.</small></label>
</div>
<label class="checkline">
<input id="preferCachedHost" type="checkbox">
<span>Сразу открывать последний рабочий host, если он уже известен. Например, с <code id="cachedFromExample">example1.com/page.html</code> сразу перейти на <code id="cachedToExample">example2.com/page.html</code>.</span>
</label>
</fieldset>
<label>
Маркеры страницы блокировки/ошибки
<textarea id="blockedMarkers" rows="8"></textarea>
<small>Если загруженная страница содержит одну из этих строк, расширение считает зеркало нерабочим и переключается дальше.</small>
</label>
<div class="row row-actions">
<button id="applyRuleBtn" type="submit" class="primary">Применить к правилу</button>
<button id="duplicateRuleBtn" type="button">Дублировать</button>
<button id="clearRuleCacheBtn" type="button">Очистить кэш правила</button>
<button id="deleteRuleBtn" type="button" class="danger">Удалить</button>
</div>
</form>
</section>
</section>
<details class="card log-card" id="debugLogSection">
<summary class="section-summary log-summary">
<span class="log-summary-main">
<span class="log-title">Журнал диагностики</span>
<span class="log-description">Если расширение ведёт не туда, обнови журнал и экспортируй JSON или текст из окна.</span>
</span>
<span class="guide-badge">Хранится до 1200 последних событий</span>
</summary>
<div class="log-body">
<div class="row row-actions">
<button id="refreshLogBtn" type="button">Обновить журнал</button>
<button id="copyLogBtn" type="button">Копировать журнал</button>
<button id="exportLogBtn" type="button">Экспорт журнала</button>
<button id="clearLogBtn" type="button" class="danger">Очистить журнал</button>
</div>
<textarea id="debugLogOutput" class="debug-log" rows="12" readonly placeholder="Здесь появятся события редиректов, кэша, Cloudflare-маркеров и выбора кандидатов."></textarea>
<small>В журнале могут быть URL страниц, которые ты открывал через правила расширения. Перед публикацией проверь, нет ли там лишнего.</small>
</div>
</details>
</main>
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="false"></div>
<script src="shared.js"></script>
<script src="i18n.js"></script>
<script src="options.js"></script>
</body>
</html>
+767
View File
@@ -0,0 +1,767 @@
(function () {
'use strict';
var S = window.AutoMirrorShared;
var I = window.AutoMirrorI18n || {
t: function (_key, fallback, vars) {
return String(fallback == null ? '' : fallback).replace(/\{([a-zA-Z0-9_]+)\}/g, function (match, name) {
return vars && Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match;
});
},
validationError: function (_error, fallback) { return fallback || ''; }
};
var DEFAULT_MARKERS = S.DEFAULT_MARKERS_TEXT;
var state = {
enabled: true,
rules: [],
selectedId: null,
autoSave: false
};
var SECTION_STATE_KEY = 'optionsSectionState';
var autoSaveTimer = null;
function $(id) {
return document.getElementById(id);
}
function uid() {
return S.uid();
}
function tr(key, fallback, vars) {
return I.t(key, fallback, vars);
}
function multclubRule() {
return S.createMultclubRule();
}
function emptyRule() {
return S.createEmptyRule();
}
function normalizeRule(rule) {
return S.normalizeRule(rule);
}
function selectedRule() {
for (var i = 0; i < state.rules.length; i += 1) {
if (state.rules[i].id === state.selectedId) return state.rules[i];
}
return null;
}
function setStatus(text) {
if (!text) return;
var container = $('toastContainer');
if (!container) return;
var toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = text;
container.appendChild(toast);
window.setTimeout(function () {
toast.classList.add('leaving');
}, 3200);
window.setTimeout(function () {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, 3700);
}
function isAutoSaveEnabled() {
var toggle = $('autoSaveEnabled');
return Boolean(toggle && toggle.checked);
}
function persistSettings(showMessage) {
if (!saveFormToCurrentRule(false)) {
if (showMessage) setStatus(tr('saveFixRule', 'Исправь ошибку в текущем правиле перед сохранением.'));
return false;
}
state.enabled = $('globalEnabled').checked;
state.autoSave = isAutoSaveEnabled();
chrome.storage.local.set({
enabled: state.enabled,
rules: state.rules,
autoSaveChanges: state.autoSave
}, function () {
if (showMessage) setStatus(tr('saved', 'Сохранено.'));
renderAll();
});
return true;
}
function queueAutoSave() {
if (!isAutoSaveEnabled()) return;
window.clearTimeout(autoSaveTimer);
autoSaveTimer = window.setTimeout(function () {
persistSettings(false);
}, 450);
}
function handleAutoSaveToggle() {
state.autoSave = isAutoSaveEnabled();
updateAutoSaveUi();
chrome.storage.local.set({ autoSaveChanges: state.autoSave }, function () {
setStatus(state.autoSave ? tr('autoSaveOn', 'Автосохранение включено.') : tr('autoSaveOff', 'Автосохранение выключено.'));
if (state.autoSave) queueAutoSave();
});
}
function updateAutoSaveUi() {
var enabled = state.autoSave === true;
var saveButton = $('saveBtn');
document.body.classList.toggle('autosave-active', enabled);
if (saveButton) saveButton.disabled = enabled;
}
function renderRulesList() {
var list = $('rulesList');
list.innerHTML = '';
if (!state.rules.length) {
var empty = document.createElement('p');
empty.textContent = tr('noRules', 'Правил пока нет.');
list.appendChild(empty);
return;
}
state.rules.forEach(function (rule) {
var item = document.createElement('button');
item.type = 'button';
item.className = 'rule-item' + (rule.id === state.selectedId ? ' active' : '');
item.dataset.id = rule.id;
var title = document.createElement('div');
title.className = 'rule-title';
title.textContent = (rule.enabled === false ? '⛔ ' : '✅ ') + (rule.name || 'Untitled');
var meta = document.createElement('div');
meta.className = 'rule-meta';
meta.textContent = (rule.strategy === 'list' ? tr('hostListLabel', 'список host') : rule.hostRegex) + ' · ' + rule.strategy + ' · ' + rule.mode;
item.appendChild(title);
item.appendChild(meta);
item.addEventListener('click', function () {
saveFormToCurrentRule(false);
state.selectedId = rule.id;
renderAll();
});
list.appendChild(item);
});
}
function setValue(id, value) {
var element = $(id);
if (!element) return;
if (element.type === 'checkbox') {
element.checked = Boolean(value);
} else {
element.value = value == null ? '' : value;
}
}
function getValue(id) {
var element = $(id);
if (!element) return '';
if (element.type === 'checkbox') return element.checked;
if (element.type === 'number') return Number(element.value);
return element.value;
}
function renderForm() {
var rule = selectedRule();
var disabled = !rule;
Array.prototype.forEach.call($('ruleForm').elements, function (element) {
element.disabled = disabled;
});
if (!rule) return;
setValue('ruleId', rule.id);
setValue('ruleEnabled', rule.enabled !== false);
setValue('name', rule.name);
setValue('hostRegex', rule.hostRegex);
setValue('protocol', rule.protocol);
setValue('mode', rule.mode);
setValue('strategy', rule.strategy);
setValue('template', rule.template);
setValue('includeEmpty', rule.includeEmpty === true);
setValue('numberStart', rule.numberStart);
setValue('numberEnd', rule.numberEnd);
setValue('numberStep', rule.numberStep);
setValue('padLength', rule.padLength);
setValue('letterCharset', rule.letterCharset);
setValue('letterMinLen', rule.letterMinLen);
setValue('letterMaxLen', rule.letterMaxLen);
setValue('maxCandidates', rule.maxCandidates);
setValue('customHosts', rule.customHosts);
setValue('timeoutMs', rule.timeoutMs);
setValue('parallelChecks', rule.parallelChecks);
setValue('maxRedirects', rule.maxRedirects);
setValue('cacheTtlHours', rule.cacheTtlHours);
setValue('preferCachedHost', rule.preferCachedHost !== false);
setValue('blockedMarkers', rule.blockedMarkers || DEFAULT_MARKERS);
updateStrategyBlocks();
}
function replaceFirstToken(template, token, value) {
return String(template || '').replace(token, value);
}
function firstNumberValue() {
var n = Number($('numberStart').value);
return Number.isFinite(n) ? n : 1;
}
function secondNumberValue() {
var start = firstNumberValue();
var step = Math.max(1, Number($('numberStep').value) || 1);
return start + step;
}
function padNumberForPreview(value) {
var pad = Math.max(0, Number($('padLength').value) || 0);
var text = String(Math.max(0, Number(value) || 0));
while (text.length < pad) text = '0' + text;
return text;
}
function updateDynamicExamples() {
var template = $('template').value.trim() || $('template').placeholder || 'example{n}.com';
var strategy = $('strategy').value;
var includeEmptyHost = '—';
var includeEmptyTemplate = template;
var cachedFrom = 'example1.com/page.html';
var cachedTo = 'example2.com/page.html';
if (strategy === 'number') {
var n1 = padNumberForPreview(firstNumberValue());
var n2 = padNumberForPreview(secondNumberValue());
includeEmptyHost = replaceFirstToken(template, '{n}', '');
cachedFrom = replaceFirstToken(template, '{n}', n1) + '/page.html';
cachedTo = replaceFirstToken(template, '{n}', n2) + '/page.html';
} else if (strategy === 'letters') {
var charset = $('letterCharset').value || 'abc';
var s1 = charset.charAt(0) || 'a';
var s2 = charset.charAt(1) || s1 + s1;
includeEmptyHost = '—';
cachedFrom = replaceFirstToken(template, '{s}', s1) + '/page.html';
cachedTo = replaceFirstToken(template, '{s}', s2) + '/page.html';
} else {
var hosts = ($('customHosts').value || '')
.split(/[\s,]+/)
.map(function (host) { return host.trim(); })
.filter(Boolean);
includeEmptyHost = '—';
includeEmptyTemplate = tr('hostListLabel', 'список host');
cachedFrom = (hosts[0] || 'mirror1.example.com') + '/page.html';
cachedTo = (hosts[1] || 'mirror2.example.com') + '/page.html';
}
if ($('includeEmptyHostExample')) $('includeEmptyHostExample').textContent = includeEmptyHost || '—';
if ($('includeEmptyTemplateExample')) $('includeEmptyTemplateExample').textContent = includeEmptyTemplate || '—';
if ($('cachedFromExample')) $('cachedFromExample').textContent = cachedFrom;
if ($('cachedToExample')) $('cachedToExample').textContent = cachedTo;
}
function updateStrategyBlocks() {
var strategy = $('strategy').value;
$('numberBlock').classList.toggle('hidden', strategy !== 'number');
$('lettersBlock').classList.toggle('hidden', strategy !== 'letters');
$('listBlock').classList.toggle('hidden', strategy !== 'list');
$('hostRegexBlock').classList.toggle('hidden', strategy === 'list');
document.querySelector('.templateBlock').classList.toggle('hidden', strategy === 'list');
updateDynamicExamples();
}
function readRuleFromForm() {
return {
id: getValue('ruleId') || uid(),
name: getValue('name').trim() || tr('untitledRule', 'Untitled rule'),
enabled: getValue('ruleEnabled'),
hostRegex: getValue('strategy') === 'list' ? '' : getValue('hostRegex').trim(),
protocol: getValue('protocol'),
mode: getValue('mode'),
strategy: getValue('strategy'),
template: getValue('template').trim(),
includeEmpty: getValue('includeEmpty'),
numberStart: getValue('numberStart'),
numberEnd: getValue('numberEnd'),
numberStep: Math.max(1, getValue('numberStep') || 1),
padLength: Math.max(0, getValue('padLength') || 0),
letterCharset: getValue('letterCharset'),
letterMinLen: Math.max(1, getValue('letterMinLen') || 1),
letterMaxLen: Math.max(1, getValue('letterMaxLen') || 1),
maxCandidates: Math.max(1, getValue('maxCandidates') || 1),
customHosts: getValue('customHosts'),
timeoutMs: Math.max(500, getValue('timeoutMs') || 3000),
parallelChecks: Math.max(1, getValue('parallelChecks') || 8),
maxRedirects: Math.max(1, getValue('maxRedirects') || 220),
cacheTtlHours: Math.max(0, getValue('cacheTtlHours') || 0),
preferCachedHost: getValue('preferCachedHost'),
blockedMarkers: getValue('blockedMarkers') || DEFAULT_MARKERS
};
}
function validateRule(rule) {
var error = S.validateRuleCode ? S.validateRuleCode(rule) : null;
if (!error) return S.validateRule(rule);
return I.validationError(error, error.message);
}
function saveFormToCurrentRule(showMessage) {
var current = selectedRule();
if (!current) return true;
var updated = readRuleFromForm();
var error = validateRule(updated);
if (error) {
if (showMessage) setStatus(error);
return false;
}
for (var i = 0; i < state.rules.length; i += 1) {
if (state.rules[i].id === current.id) {
state.rules[i] = updated;
break;
}
}
if (showMessage) {
setStatus(isAutoSaveEnabled() ? tr('ruleAppliedAuto', 'Правило применено и сохранится автоматически.') : tr('ruleAppliedManual', 'Правило применено. Не забудь нажать «Сохранить».'));
}
renderRulesList();
return true;
}
function renderAll() {
$('globalEnabled').checked = state.enabled !== false;
if ($('autoSaveEnabled')) $('autoSaveEnabled').checked = state.autoSave === true;
updateAutoSaveUi();
renderRulesList();
renderForm();
}
function load() {
chrome.storage.local.get({ enabled: true, rules: [], autoSaveChanges: false }, function (items) {
state.enabled = items.enabled !== false;
state.autoSave = items.autoSaveChanges === true;
state.rules = Array.isArray(items.rules) ? items.rules.map(normalizeRule) : [];
if (!state.rules.length) state.rules.push(multclubRule());
state.selectedId = state.rules[0].id;
renderAll();
});
}
function saveAll() {
persistSettings(true);
}
function addRule(rule) {
saveFormToCurrentRule(false);
state.rules.push(rule);
state.selectedId = rule.id;
renderAll();
setStatus(isAutoSaveEnabled() ? tr('ruleAddedAuto', 'Правило добавлено и сохранится автоматически.') : tr('ruleAddedManual', 'Правило добавлено. Не забудь нажать «Сохранить».'));
queueAutoSave();
}
function getCacheKey(rule) {
return S.getCacheKey(rule);
}
function clearCurrentRuleCache() {
var current = selectedRule();
if (!current) return;
chrome.storage.local.remove(getCacheKey(current), function () {
setStatus(tr('cacheCleared', 'Кэш последнего рабочего host очищен для правила «{name}».', {
name: current.name || current.id
}));
});
}
function deleteCurrentRule() {
var current = selectedRule();
if (!current) return;
if (!confirm(tr('confirmDeleteRule', 'Удалить правило «{name}»?', {
name: current.name || current.id
}))) return;
state.rules = state.rules.filter(function (rule) {
return rule.id !== current.id;
});
state.selectedId = state.rules.length ? state.rules[0].id : null;
renderAll();
setStatus(isAutoSaveEnabled() ? tr('ruleDeletedAuto', 'Правило удалено и сохранится автоматически.') : tr('ruleDeletedManual', 'Правило удалено. Не забудь нажать «Сохранить».'));
queueAutoSave();
}
function duplicateCurrentRule() {
var current = selectedRule();
if (!current) return;
var copy = Object.assign({}, readRuleFromForm());
copy.id = uid();
copy.name = copy.name + ' copy';
addRule(copy);
}
function safeClone(value) {
return JSON.parse(JSON.stringify(value));
}
function normalizeImportedRules(rawRules) {
if (!Array.isArray(rawRules)) {
throw new Error(tr('importNoRulesArray', 'В файле не найден массив rules.'));
}
var seenIds = Object.create(null);
var rules = rawRules.map(function (rawRule, index) {
var rule = normalizeRule(rawRule);
var error = validateRule(rule);
if (error) {
throw new Error(tr('importRuleError', 'Ошибка в правиле #{index} «{name}»: {error}', {
index: index + 1,
name: rule.name || rule.id || 'Untitled',
error: error
}));
}
if (!rule.id || seenIds[rule.id]) {
rule.id = uid();
}
seenIds[rule.id] = true;
return rule;
});
if (!rules.length) {
throw new Error(tr('importNoRules', 'В импортируемом файле нет правил.'));
}
return rules;
}
function buildExportPayload() {
if (!saveFormToCurrentRule(false)) return null;
state.enabled = $('globalEnabled').checked;
return {
app: 'Auto Mirror Switcher',
formatVersion: 1,
extensionVersion: chrome.runtime && chrome.runtime.getManifest ? chrome.runtime.getManifest().version : 'unknown',
exportedAt: new Date().toISOString(),
enabled: state.enabled !== false,
rules: safeClone(state.rules)
};
}
function makeExportFileName() {
var stamp = new Date().toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}Z$/, 'Z')
.replace('T', '-');
return 'auto-mirror-switcher-settings-' + stamp + '.json';
}
function downloadTextFile(filename, text) {
var blob = new Blob([text], { type: 'application/json;charset=utf-8' });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
window.setTimeout(function () {
URL.revokeObjectURL(url);
if (link.parentNode) link.parentNode.removeChild(link);
}, 1000);
}
function exportSettings() {
var payload = buildExportPayload();
if (!payload) {
setStatus(tr('exportCanceled', 'Экспорт отменён: сначала исправь ошибку в текущем правиле.'));
return;
}
downloadTextFile(makeExportFileName(), JSON.stringify(payload, null, 2));
setStatus(tr('settingsExported', 'Настройки экспортированы в JSON-файл.'));
}
function extractImportPayload(data) {
if (Array.isArray(data)) {
return {
enabled: true,
rules: data
};
}
if (!data || typeof data !== 'object') {
throw new Error(tr('importInvalidObject', 'Файл должен содержать JSON-объект с полем rules.'));
}
if (data.app && data.app !== 'Auto Mirror Switcher') {
var ok = confirm(tr('importWrongAppConfirm', 'Файл не похож на экспорт Auto Mirror Switcher. Всё равно попробовать импортировать?'));
if (!ok) throw new Error(tr('importCanceled', 'Импорт отменён.'));
}
if (Array.isArray(data.rules)) {
return {
enabled: data.enabled !== false,
rules: data.rules
};
}
if (data.settings && Array.isArray(data.settings.rules)) {
return {
enabled: data.settings.enabled !== false,
rules: data.settings.rules
};
}
throw new Error(tr('importNoRulesArray', 'В файле не найден массив rules.'));
}
function persistImportedSettings(enabled, rules) {
state.enabled = enabled !== false;
state.rules = rules;
state.selectedId = state.rules[0] ? state.rules[0].id : null;
chrome.storage.local.set({
enabled: state.enabled,
rules: state.rules
}, function () {
renderAll();
setStatus(tr('importedSavedCount', 'Импортировано и сохранено правил: {count}.', {
count: state.rules.length
}));
});
}
function importSettingsObject(data) {
var payload = extractImportPayload(data);
var importedRules = normalizeImportedRules(payload.rules);
var message = tr('importReplaceConfirm', 'Импорт заменит текущие правила ({current}) на правила из файла ({incoming}). Продолжить?', {
current: state.rules.length,
incoming: importedRules.length
});
if (!confirm(message)) {
setStatus(tr('importCanceled', 'Импорт отменён.'));
return;
}
persistImportedSettings(payload.enabled, importedRules);
}
function importSettingsFromFile(file) {
if (!file) return;
var reader = new FileReader();
reader.onload = function () {
try {
var data = JSON.parse(String(reader.result || ''));
importSettingsObject(data);
} catch (e) {
setStatus(tr('importError', 'Ошибка импорта: {message}', {
message: e && e.message ? e.message : String(e)
}));
} finally {
$('importFileInput').value = '';
}
};
reader.onerror = function () {
setStatus(tr('importReadError', 'Ошибка импорта: не удалось прочитать файл.'));
$('importFileInput').value = '';
};
reader.readAsText(file, 'utf-8');
}
function formatDebugLogs(logs) {
if (!Array.isArray(logs) || !logs.length) {
return tr('logEmpty', 'Журнал пуст.');
}
return logs.map(function (entry, index) {
return JSON.stringify({
index: index + 1,
time: entry.time,
level: entry.level,
event: entry.event,
data: entry.data || {}
}, null, 2);
}).join('\n---\n');
}
function getDebugLogs(callback) {
chrome.storage.local.get({ debugLogs: [] }, function (items) {
callback(Array.isArray(items.debugLogs) ? items.debugLogs : []);
});
}
function refreshDebugLog() {
getDebugLogs(function (logs) {
var output = $('debugLogOutput');
if (output) {
output.value = formatDebugLogs(logs);
output.scrollTop = output.scrollHeight;
}
setStatus(tr('logUpdated', 'Журнал обновлён. Событий: {count}.', {
count: logs.length
}));
});
}
function makeDebugLogFileName() {
var stamp = new Date().toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}Z$/, 'Z')
.replace('T', '-');
return 'auto-mirror-switcher-debug-log-' + stamp + '.json';
}
function exportDebugLog() {
getDebugLogs(function (logs) {
var payload = {
app: 'Auto Mirror Switcher',
type: 'debug-log',
extensionVersion: chrome.runtime && chrome.runtime.getManifest ? chrome.runtime.getManifest().version : 'unknown',
exportedAt: new Date().toISOString(),
logs: logs
};
downloadTextFile(makeDebugLogFileName(), JSON.stringify(payload, null, 2));
setStatus(tr('logExported', 'Журнал экспортирован. Событий: {count}.', {
count: logs.length
}));
});
}
function clearDebugLog() {
if (!confirm(tr('confirmClearLog', 'Очистить журнал диагностики?'))) return;
chrome.storage.local.set({ debugLogs: [] }, function () {
if ($('debugLogOutput')) $('debugLogOutput').value = tr('logEmpty', 'Журнал пуст.');
setStatus(tr('logCleared', 'Журнал диагностики очищен.'));
});
}
function copyDebugLog() {
var text = $('debugLogOutput') ? $('debugLogOutput').value : '';
if (!text || text === tr('logEmpty', 'Журнал пуст.')) {
refreshDebugLog();
text = $('debugLogOutput') ? $('debugLogOutput').value : '';
}
function fallbackCopy() {
var output = $('debugLogOutput');
if (!output) return;
output.focus();
output.select();
try {
document.execCommand('copy');
setStatus(tr('logCopied', 'Журнал скопирован.'));
} catch (e) {
setStatus(tr('logCopyManual', 'Не удалось скопировать журнал автоматически. Выдели текст вручную.'));
}
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
setStatus(tr('logCopied', 'Журнал скопирован.'));
}).catch(fallbackCopy);
} else {
fallbackCopy();
}
}
function setupPersistentSection(id, key, defaultOpen) {
var section = $(id);
if (!section) return;
chrome.storage.local.get({ [SECTION_STATE_KEY]: {} }, function (items) {
var saved = items[SECTION_STATE_KEY] || {};
section.open = typeof saved[key] === 'boolean' ? saved[key] : defaultOpen === true;
});
section.addEventListener('toggle', function () {
chrome.storage.local.get({ [SECTION_STATE_KEY]: {} }, function (items) {
var saved = items[SECTION_STATE_KEY] || {};
saved[key] = section.open;
var payload = {};
payload[SECTION_STATE_KEY] = saved;
chrome.storage.local.set(payload);
});
});
}
$('strategy').addEventListener('change', updateStrategyBlocks);
['template', 'numberStart', 'numberStep', 'padLength', 'letterCharset', 'customHosts'].forEach(function (id) {
var element = $(id);
if (element) element.addEventListener('input', updateDynamicExamples);
});
$('globalEnabled').addEventListener('change', queueAutoSave);
$('autoSaveEnabled').addEventListener('change', handleAutoSaveToggle);
$('ruleForm').addEventListener('input', queueAutoSave);
$('ruleForm').addEventListener('change', queueAutoSave);
$('ruleForm').addEventListener('submit', function (event) {
event.preventDefault();
if (saveFormToCurrentRule(true)) queueAutoSave();
});
$('addRuleBtn').addEventListener('click', function () { addRule(emptyRule()); });
$('addMultclubBtn').addEventListener('click', function () { addRule(multclubRule()); });
$('exportSettingsBtn').addEventListener('click', exportSettings);
$('importSettingsBtn').addEventListener('click', function () { $('importFileInput').click(); });
$('importFileInput').addEventListener('change', function () { importSettingsFromFile(this.files && this.files[0]); });
$('saveBtn').addEventListener('click', saveAll);
$('deleteRuleBtn').addEventListener('click', deleteCurrentRule);
$('duplicateRuleBtn').addEventListener('click', duplicateCurrentRule);
$('clearRuleCacheBtn').addEventListener('click', clearCurrentRuleCache);
$('refreshLogBtn').addEventListener('click', refreshDebugLog);
$('copyLogBtn').addEventListener('click', copyDebugLog);
$('exportLogBtn').addEventListener('click', exportDebugLog);
$('clearLogBtn').addEventListener('click', clearDebugLog);
setupPersistentSection('guideSection', 'guideOpen', false);
setupPersistentSection('debugLogSection', 'debugLogOpen', false);
load();
refreshDebugLog();
})();
+9
View File
@@ -0,0 +1,9 @@
{
"name": "auto-mirror-switcher",
"version": "1.4.3",
"private": true,
"type": "commonjs",
"scripts": {
"test": "node tests/shared.test.js"
}
}
+8
View File
@@ -0,0 +1,8 @@
importScripts(
'shared.js',
'background-diagnostics.js',
'background-cache.js',
'background-probe.js',
'background-state.js',
'background.js'
);
+805
View File
@@ -0,0 +1,805 @@
(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);
+81
View File
@@ -0,0 +1,81 @@
:root {
color-scheme: dark;
--bg: #070a12;
--card: #111827;
--card2: #0b1220;
--border: #263449;
--text: #eef4ff;
--muted: #9fb0c8;
--accent: #3b82f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background: radial-gradient(circle at top left, #18213a, var(--bg) 48%);
color: var(--text);
font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.card {
width: min(980px, 100%);
background: linear-gradient(180deg, rgba(17,24,39,.98), rgba(11,18,32,.98));
border: 1px solid var(--border);
border-radius: 18px;
padding: 28px;
box-shadow: 0 24px 80px rgba(0,0,0,.45);
}
.badge {
display: inline-flex;
padding: 6px 10px;
border: 1px solid #24446f;
border-radius: 999px;
background: rgba(59,130,246,.14);
color: #bcd7ff;
font-weight: 700;
font-size: 12px;
letter-spacing: .02em;
}
h1 { margin: 16px 0 8px; font-size: 30px; }
.lead, .hint { color: var(--muted); }
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin: 22px 0;
}
label {
display: block;
margin: 0 0 6px;
color: #dbeafe;
font-weight: 700;
}
pre, textarea {
width: 100%;
margin: 0;
padding: 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #070b18;
color: var(--text);
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
textarea { min-height: 180px; resize: vertical; }
.actions { display: flex; gap: 12px; margin: 18px 0 0; flex-wrap: wrap; }
button {
border: 1px solid #31517c;
background: #14243d;
color: var(--text);
border-radius: 12px;
padding: 11px 14px;
font-weight: 800;
cursor: pointer;
}
button:hover { border-color: var(--accent); }
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } body { padding: 16px; } }
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Auto Mirror Switcher — поиск остановлен</title>
<link rel="stylesheet" href="stopped.css">
</head>
<body>
<main class="card">
<div class="badge">Auto Mirror Switcher</div>
<h1>Рабочее зеркало не найдено</h1>
<p class="lead">Расширение остановило перебор, чтобы не уйти в бесконечный цикл редиректов.</p>
<section class="grid">
<div>
<label>Правило</label>
<pre id="rule">—</pre>
</div>
<div>
<label>Причина остановки</label>
<pre id="reason">—</pre>
</div>
<div>
<label>Исходный URL</label>
<pre id="sourceUrl">—</pre>
</div>
<div>
<label>Путь страницы</label>
<pre id="path">—</pre>
</div>
<div>
<label>Редиректов</label>
<pre id="redirects">—</pre>
</div>
<div>
<label>Диапазон</label>
<pre id="range">—</pre>
</div>
</section>
<section>
<label>Проверенные host</label>
<textarea id="triedHosts" readonly></textarea>
</section>
<div class="actions">
<button id="copy">Скопировать диагностику</button>
<button id="openOptions">Открыть настройки</button>
</div>
<p class="hint">Если все host показывают Cloudflare 522/523/524, расширение будет перебирать их до конца диапазона. Для таких сайтов удобнее режим <strong>probe</strong>: он проверяет кандидатов в фоне и открывает только найденный рабочий host.</p>
</main>
<script src="i18n.js"></script>
<script src="stopped.js"></script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
(function () {
'use strict';
function $(id) { return document.getElementById(id); }
function text(id, value) { $(id).textContent = value == null || value === '' ? '—' : String(value); }
var key = decodeURIComponent(location.hash.replace(/^#/, ''));
function render(data) {
data = data || {};
text('rule', data.rule);
text('reason', data.reason);
text('sourceUrl', data.sourceUrl);
text('path', data.path);
text('redirects', data.redirects);
text('range', data.range ? JSON.stringify(data.range, null, 2) : '—');
$('triedHosts').value = Array.isArray(data.triedHosts) ? data.triedHosts.join('\n') : '';
}
if (key) {
chrome.storage.local.get([key], function (items) {
render(items[key]);
});
} else {
render(null);
}
$('copy').addEventListener('click', function () {
var payload = {
rule: $('rule').textContent,
reason: $('reason').textContent,
sourceUrl: $('sourceUrl').textContent,
path: $('path').textContent,
redirects: $('redirects').textContent,
range: $('range').textContent,
triedHosts: $('triedHosts').value.split('\n').filter(Boolean)
};
navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
});
$('openOptions').addEventListener('click', function () {
if (chrome.runtime.openOptionsPage) chrome.runtime.openOptionsPage();
});
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+355
View File
@@ -0,0 +1,355 @@
const assert = require('assert');
const S = require('../shared.js');
function test(name, fn) {
try {
fn();
console.log('ok - ' + name);
} catch (error) {
console.error('not ok - ' + name);
throw error;
}
}
test('numeric rule rejects empty host when includeEmpty is disabled', function () {
const rule = {
enabled: true,
hostRegex: '^v\\d*\\.vost\\.pw$',
strategy: 'number',
template: 'v{n}.vost.pw',
includeEmpty: false,
numberStart: 1,
numberEnd: 100
};
assert.strictEqual(S.matchesRule(rule, 'v.vost.pw'), false);
assert.strictEqual(S.matchesRule(rule, 'v50.vost.pw'), true);
});
test('default rules use probe mode', function () {
assert.strictEqual(S.createMultclubRule().mode, 'probe');
assert.strictEqual(S.createEmptyRule().mode, 'probe');
});
test('default blocked markers match the Multclub preset list', function () {
const requestedMarkers = `доступ ограничен
доступ к сайту ограничен
сайт заблокирован
заблокировано
ограничение доступа
ресурс заблокирован
по решению органов государственной власти
роскомнадзор
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`.split('\n');
assert.deepStrictEqual(S.DEFAULT_MARKERS, requestedMarkers);
assert.strictEqual(S.createMultclubRule().blockedMarkers, requestedMarkers.join('\n'));
});
test('numeric rule rejects hosts outside the configured range', function () {
const rule = {
enabled: true,
hostRegex: '^v\\d+\\.vost\\.pw$',
strategy: 'number',
template: 'v{n}.vost.pw',
includeEmpty: false,
numberStart: 1,
numberEnd: 100
};
assert.strictEqual(S.matchesRule(rule, 'v109.vost.pw'), false);
});
test('numeric rule supports ranges that start at zero', function () {
const rule = {
enabled: true,
hostRegex: '^mirror\\d+\\.example$',
strategy: 'number',
template: 'mirror{n}.example',
includeEmpty: false,
numberStart: 0,
numberEnd: 2,
numberStep: 1
};
assert.strictEqual(S.matchesRule(rule, 'mirror0.example'), true);
assert.deepStrictEqual(S.buildCandidates(rule, 'mirror1.example', null), [
'mirror2.example',
'mirror0.example'
]);
});
test('numeric rule distinguishes zero from an empty number slot', function () {
const rule = {
enabled: true,
hostRegex: '^mirror\\d*\\.example$',
strategy: 'number',
template: 'mirror{n}.example',
includeEmpty: false,
numberStart: 0,
numberEnd: 2,
numberStep: 1
};
assert.strictEqual(S.matchesRule(rule, 'mirror0.example'), true);
assert.strictEqual(S.matchesRule(rule, 'mirror.example'), false);
});
test('buildCandidates starts after current number and wraps without duplicates', function () {
const rule = {
enabled: true,
hostRegex: '^site\\d+\\.example$',
strategy: 'number',
template: 'site{n}.example',
includeEmpty: false,
numberStart: 1,
numberEnd: 5,
numberStep: 1
};
assert.deepStrictEqual(S.buildCandidates(rule, 'site3.example', null), [
'site4.example',
'site5.example',
'site1.example',
'site2.example'
]);
});
test('cached host is first only when it still matches the rule', function () {
const rule = {
enabled: true,
hostRegex: '^site\\d+\\.example$',
strategy: 'number',
template: 'site{n}.example',
includeEmpty: false,
numberStart: 1,
numberEnd: 5,
numberStep: 1
};
assert.deepStrictEqual(S.buildCandidates(rule, 'site3.example', 'site2.example').slice(0, 3), [
'site2.example',
'site4.example',
'site5.example'
]);
assert.strictEqual(S.buildCandidates(rule, 'site3.example', 'site9.example')[0], 'site4.example');
});
test('list strategy matches hosts from custom hosts without a host regex', function () {
const rule = {
enabled: true,
hostRegex: '',
strategy: 'list',
customHosts: 'mirror1.example\nmirror2.example'
};
assert.strictEqual(S.validateRule(rule), null);
assert.strictEqual(S.matchesRule(rule, 'mirror1.example'), true);
assert.strictEqual(S.matchesRule(rule, 'mirror3.example'), false);
assert.deepStrictEqual(S.buildCandidates(rule, 'mirror1.example', null), [
'mirror1.example',
'mirror2.example'
]);
});
test('list strategy validation requires custom hosts instead of host regex', function () {
assert.deepStrictEqual(S.validateRuleCode({
hostRegex: '',
strategy: 'list',
customHosts: ''
}), {
code: 'listHostsRequired',
message: 'Для режима списка нужно указать хотя бы один host.'
});
});
test('letter strategy rotates after the current token', function () {
const rule = {
enabled: true,
hostRegex: '^mirror-[ab]+\\.example$',
strategy: 'letters',
template: 'mirror-{s}.example',
letterCharset: 'ab',
letterMinLen: 1,
letterMaxLen: 2,
maxCandidates: 6
};
assert.deepStrictEqual(S.buildCandidates(rule, 'mirror-b.example', null), [
'mirror-aa.example',
'mirror-ab.example',
'mirror-ba.example',
'mirror-bb.example',
'mirror-a.example'
]);
});
test('blocked-page detection recognizes Cloudflare and configured markers', function () {
const rule = {
blockedMarkers: 'custom block marker'
};
assert.strictEqual(S.findBlockedMarkerInText(rule, 'Cloudflare Ray ID: abcdef1234567890'), 'cloudflare-ray-id');
assert.strictEqual(S.findBlockedMarkerInText(rule, 'Код ошибки 522 cloudflare'), 'код ошибки 522');
assert.strictEqual(S.findBlockedMarkerInText(rule, 'page says CUSTOM BLOCK MARKER here'), 'custom block marker');
});
test('probe blocked-page detection ignores hidden HTML marker text', function () {
const rule = {
blockedMarkers: ''
};
const workingHtml = `
<html>
<head><title>Working anime page</title></head>
<body>
<h1>Episode page</h1>
<script>var staleError = "Cloudflare error 522";</script>
<template>Error 522 Cloudflare Host Error</template>
<div hidden>Cloudflare Ray ID: abcdef1234567890</div>
<div style="display: none">Error 523 Cloudflare</div>
</body>
</html>`;
const blockedHtml = `
<html>
<head><title>site.example | 522: Connection timed out</title></head>
<body>
<h1>Connection timed out</h1>
<p>Error 522</p>
<p>Cloudflare</p>
</body>
</html>`;
assert.strictEqual(S.responseLooksBlocked(rule, workingHtml), false);
assert.strictEqual(S.responseLooksBlocked(rule, blockedHtml), true);
});
test('page blocked-page detection ignores embedded Cloudflare text on a normal page', function () {
const normalText = [
'Koko wa Ore ni Makasete Saki ni Ike episode page',
'Navigation catalog comments related episodes release schedule'.repeat(80),
'Player tab 3 failed: Cloudflare 522 connection timed out',
'More normal page content'.repeat(80)
].join('\n');
assert.strictEqual(S.findBlockedMarkerInPage({}, 'Animevost episode page', normalText), '');
assert.strictEqual(S.findBlockedMarkerInPage({}, '522: Connection timed out', normalText), 'connection timed out');
});
test('page blocked-page detection ignores embedded generic forbidden text on a normal page', function () {
const normalText = [
'Multclub home page',
'Navigation catalog comments releases schedule profile search'.repeat(80),
'A player message mentions forbidden in a nested widget.',
'More normal page content'.repeat(80)
].join('\n');
assert.strictEqual(S.findBlockedMarkerInPage({}, 'Multclub', normalText), '');
assert.strictEqual(S.findBlockedMarkerInPage({}, '403 Forbidden', normalText), 'forbidden');
assert.strictEqual(S.findBlockedMarkerInPage({}, 'Multclub', 'Forbidden'), 'forbidden');
});
test('page blocked-page detection ignores every built-in marker embedded in a long normal page', function () {
S.DEFAULT_MARKERS.forEach(function (marker) {
const normalText = [
'Normal mirror page',
'Navigation catalog comments releases schedule profile search'.repeat(90),
'Embedded diagnostic text: ' + marker,
'More normal page content'.repeat(90)
].join('\n');
assert.strictEqual(
S.findBlockedMarkerInPage({}, 'Normal mirror page', normalText),
'',
'marker should not stop a long normal page: ' + marker
);
});
});
test('page blocked-page detection still accepts focused built-in error pages', function () {
assert.strictEqual(S.findBlockedMarkerInPage({}, 'Multclub', 'Доступ к сайту ограничен по решению органов государственной власти'), 'доступ к сайту ограничен');
assert.strictEqual(S.findBlockedMarkerInPage({}, 'Multclub', 'Bad gateway'), 'bad gateway');
assert.strictEqual(S.findBlockedMarkerInPage({}, 'Multclub', 'Cloudflare host error. Error 522. Ray ID abcdef1234567890.'), 'cloudflare-ray-id');
});
test('URL helpers preserve path, query, and hash', function () {
const rule = { protocol: 'https' };
const source = new URL('http://old.example/path/page.html?a=1#top');
assert.strictEqual(S.buildTargetUrl(rule, 'new.example', source), 'https://new.example/path/page.html?a=1#top');
});
test('rule validation rejects an empty host regex', function () {
assert.strictEqual(S.validateRule({
hostRegex: '',
strategy: 'number',
template: 'site{n}.example'
}), 'Поле hostRegex не должно быть пустым.');
});
test('rule validation exposes stable error codes for localization', function () {
assert.deepStrictEqual(S.validateRuleCode({
hostRegex: '',
strategy: 'number',
template: 'site{n}.example'
}), {
code: 'hostRegexRequired',
message: 'Поле hostRegex не должно быть пустым.'
});
});
+98
View File
@@ -0,0 +1,98 @@
Add-Type -AssemblyName System.Drawing
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$outDir = Join-Path $root 'icons'
if (-not (Test-Path -LiteralPath $outDir)) {
New-Item -ItemType Directory -Path $outDir | Out-Null
}
function New-Icon {
param(
[int] $Size,
[string] $Path
)
$scale = $Size / 128.0
$bitmap = New-Object System.Drawing.Bitmap $Size, $Size, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$graphics.Clear([System.Drawing.Color]::Transparent)
function S([double] $value) { return [single]($value * $scale) }
function New-RoundedPath([System.Drawing.RectangleF] $rect, [single] $radius) {
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
$diameter = $radius * 2
$path.AddArc($rect.X, $rect.Y, $diameter, $diameter, 180, 90)
$path.AddArc($rect.Right - $diameter, $rect.Y, $diameter, $diameter, 270, 90)
$path.AddArc($rect.Right - $diameter, $rect.Bottom - $diameter, $diameter, $diameter, 0, 90)
$path.AddArc($rect.X, $rect.Bottom - $diameter, $diameter, $diameter, 90, 90)
$path.CloseFigure()
return $path
}
$bgRect = New-Object System.Drawing.RectangleF (S 4), (S 4), (S 120), (S 120)
$bgPath = New-RoundedPath $bgRect (S 24)
$bgBrush = New-Object System.Drawing.Drawing2D.LinearGradientBrush $bgRect,
([System.Drawing.Color]::FromArgb(255, 24, 31, 42)),
([System.Drawing.Color]::FromArgb(255, 16, 18, 27)),
([System.Drawing.Drawing2D.LinearGradientMode]::ForwardDiagonal)
$graphics.FillPath($bgBrush, $bgPath)
$borderPen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255, 66, 87, 108)), (S 3)
$graphics.DrawPath($borderPen, $bgPath)
$leftRect = New-Object System.Drawing.RectangleF (S 23), (S 30), (S 34), (S 46)
$rightRect = New-Object System.Drawing.RectangleF (S 71), (S 52), (S 34), (S 46)
$panelBrush = New-Object System.Drawing.Drawing2D.LinearGradientBrush $leftRect,
([System.Drawing.Color]::FromArgb(255, 53, 211, 190)),
([System.Drawing.Color]::FromArgb(255, 72, 118, 255)),
([System.Drawing.Drawing2D.LinearGradientMode]::Vertical)
$panelPen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(235, 228, 248, 255)), (S 3)
$leftPath = New-RoundedPath $leftRect (S 8)
$rightPath = New-RoundedPath $rightRect (S 8)
$graphics.FillPath($panelBrush, $leftPath)
$graphics.DrawPath($panelPen, $leftPath)
$graphics.FillPath($panelBrush, $rightPath)
$graphics.DrawPath($panelPen, $rightPath)
$arrowPen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255, 255, 184, 77)), (S 8)
$arrowPen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
$arrowPen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
$graphics.DrawLine($arrowPen, (S 46), (S 86), (S 83), (S 86))
$arrowBrush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 255, 184, 77))
$arrowHead = @(
(New-Object System.Drawing.PointF (S 84), (S 72)),
(New-Object System.Drawing.PointF (S 108), (S 86)),
(New-Object System.Drawing.PointF (S 84), (S 100))
)
$graphics.FillPolygon($arrowBrush, $arrowHead)
$smallArrowPen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(230, 228, 248, 255)), (S 5)
$smallArrowPen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
$smallArrowPen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
$graphics.DrawLine($smallArrowPen, (S 81), (S 38), (S 49), (S 38))
$smallHeadBrush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(230, 228, 248, 255))
$smallHead = @(
(New-Object System.Drawing.PointF (S 50), (S 28)),
(New-Object System.Drawing.PointF (S 32), (S 38)),
(New-Object System.Drawing.PointF (S 50), (S 48))
)
$graphics.FillPolygon($smallHeadBrush, $smallHead)
$bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
$graphics.Dispose()
$bitmap.Dispose()
}
$sizes = @(16, 32, 48, 128)
foreach ($size in $sizes) {
New-Icon -Size $size -Path (Join-Path $outDir "icon-$size.png")
}