decoy-duck 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmitry Tomin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,180 @@
1
- # Temporary Holding Version
1
+ # decoy-duck
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Проверка фильтра рекламы в браузере. Четыре пробы сводятся в вердикт с уровнем уверенности.
4
+
5
+ Фильтр смотрит на URL запроса. Четыре файла-приманки лежат на сайте как обычная статика и запрашиваются браузером по известным путям. В бандл приложения они не входят.
6
+
7
+ ## Установка
8
+
9
+ ```
10
+ npm install decoy-duck
11
+ ```
12
+
13
+ Пакет публичный, отдельный registry не нужен.
14
+
15
+ ## Подключение
16
+
17
+ Два шага: вызвать проверку и положить файлы-приманки в корень сайта.
18
+
19
+ ```js
20
+ import { detectAdblock } from 'decoy-duck'
21
+
22
+ const result = await detectAdblock()
23
+ ```
24
+
25
+ Скопируйте файлы из `node_modules/decoy-duck/src/filter-check/baits/` в выходной каталог сборки. Имена остаются такими:
26
+
27
+ | Файл в пакете | Адрес на сайте |
28
+ |---|---|
29
+ | `ad_header.js` | `/ad_header.js` |
30
+ | `assets/ctrl.js` | `/assets/ctrl.js` |
31
+ | `assets/pixel.png` | `/assets/pixel.png` |
32
+ | `ads/images/pixel.png` | `/ads/images/pixel.png` |
33
+
34
+ Хеш в имени файла меняет URL, и правило фильтра его уже не узнает.
35
+
36
+ В Webpack это делает `copy-webpack-plugin`:
37
+
38
+ ```js
39
+ import CopyPlugin from 'copy-webpack-plugin'
40
+
41
+ export default {
42
+ plugins: [
43
+ new CopyPlugin({
44
+ patterns: [
45
+ { from: 'node_modules/decoy-duck/src/filter-check/baits/ad_header.js', to: 'ad_header.js' },
46
+ { from: 'node_modules/decoy-duck/src/filter-check/baits/assets/ctrl.js', to: 'assets/ctrl.js' },
47
+ { from: 'node_modules/decoy-duck/src/filter-check/baits/assets/pixel.png', to: 'assets/pixel.png' },
48
+ { from: 'node_modules/decoy-duck/src/filter-check/baits/ads/images/pixel.png', to: 'ads/images/pixel.png' },
49
+ ],
50
+ }),
51
+ ],
52
+ }
53
+ ```
54
+
55
+ Эти четыре файла оставляют статикой. Импорт их в свой модуль затягивает содержимое в бандл: браузер не делает запрос по нужному URL, и фильтр ничего не видит.
56
+
57
+ Сайт из корня домена дополнительных адресов не требует. Если страница открывается из `/app/`, файлы лежат по `/app/ad_header.js` и остальным трём путям, а проверка получает эти адреса явно:
58
+
59
+ ```js
60
+ import { detectAdblock, defaultBait } from 'decoy-duck'
61
+
62
+ const result = await detectAdblock({
63
+ bait: {
64
+ ...defaultBait,
65
+ scriptUrl: '/app/ad_header.js',
66
+ imageUrl: '/app/ads/images/pixel.png',
67
+ controlScriptUrl: '/app/assets/ctrl.js',
68
+ controlImageUrl: '/app/assets/pixel.png',
69
+ },
70
+ })
71
+ ```
72
+
73
+ Без плагина путь с ведущим `/` уходит как написан в `bait`. Плагин Vite сам добавляет к такому пути `base`. Абсолютный `https://` в обоих случаях передаётся как есть.
74
+
75
+ ## Результат
76
+
77
+ `detected` равен `true`, только если статус `active`. `false` — фильтр не найден. `null` — часть проб не ответила, вывода нет.
78
+
79
+ ```js
80
+ {
81
+ detected: true,
82
+ status: 'active', // active | inactive | unknown
83
+ confidence: 'high', // high | medium | low
84
+ probability: 90, // 0–100, вероятность что фильтр включён
85
+ signals: {
86
+ cosmetic: 'clear', // blocked | clear | inconclusive
87
+ script: 'blocked',
88
+ image: 'clear',
89
+ thirdParty: 'clear', // blocked | clear | inconclusive | skipped
90
+ },
91
+ elapsedMs: 1040,
92
+ }
93
+ ```
94
+
95
+ Четыре пробы стартуют параллельно. У скрипта и картинки есть нейтральный контроль. Если не открылся и он, проба остаётся `inconclusive`.
96
+
97
+ - Заблокированный скрипт или картинка на своём домене — `active`, уверенность высокая.
98
+ - Вместе скрытый элемент и недоступный сторонний рекламный хост — `active`, уверенность высокая.
99
+ - Только скрытый элемент — `active`, уверенность средняя.
100
+ - Только сторонний хост, а остальные пробы открылись — `active`, уверенность средняя. Это «рекламный хост недоступен»: фильтр, прокси, DNS или сам хост.
101
+ - Все свои пробы открылись, а сторонняя выключена (`skipped`) или тоже открылась — `inactive`, уверенность высокая.
102
+ - Иначе — `unknown`.
103
+
104
+ `probability` — вероятность, что фильтр включён, от 0 до 100:
105
+
106
+ - оба своих файла закрыты — 98
107
+ - закрыт один из них — 90
108
+ - скрыт элемент и недоступен сторонний хост — 85
109
+ - скрыт только элемент — 60
110
+ - недоступен только сторонний хост — 40
111
+ - свои пробы открылись — 5
112
+ - вывода нет — 50
113
+
114
+ `decide(signals)` — чистая функция того же правила. Типы лежат рядом с входом пакета.
115
+
116
+ ## Опции
117
+
118
+ ```js
119
+ import { detectAdblock, defaultBait } from 'decoy-duck'
120
+
121
+ const controller = new AbortController()
122
+
123
+ const result = await detectAdblock({
124
+ timeout: 2500,
125
+ signal: controller.signal,
126
+ thirdParty: false,
127
+ bait: { ...defaultBait },
128
+ })
129
+ ```
130
+
131
+ `timeout` — предел сетевых проб в миллисекундах, по умолчанию 2500. `signal` отменяет проверку.
132
+
133
+ `thirdParty: false` не отправляет запрос к рекламному хосту. По умолчанию проверка один раз запрашивает `pagead2.googlesyndication.com`. Cookies и referrer не уходят, но хост видит IP и User-Agent. Скрипт Google при этом не выполняется.
134
+
135
+ Обрыв этого запроса — не доказательство фильтра. Так же выглядят CSP, корпоративный прокси, сбой DNS и недоступность самого хоста. Поэтому одна только сторонняя проба даёт `active` со средней уверенностью. Высокую уверенность дают заблокированные скрипт или картинка на своём домене.
136
+
137
+ ## Vite
138
+
139
+ В проекте на Vite те же четыре файла может разложить плагин. Это необязательный путь: проверка по-прежнему вызывается через `detectAdblock()`.
140
+
141
+ ```js
142
+ import { defineConfig } from 'vite'
143
+ import { filterCheck } from 'decoy-duck/vite'
144
+
145
+ export default defineConfig({
146
+ plugins: [filterCheck()],
147
+ })
148
+ ```
149
+
150
+ Плагин в dev отдаёт файлы по адресам из `defaultBait` с учётом Vite `base` и при `vite build` копирует их в `build.outDir`. Этот каталог считается от `root` конфига, а не от текущей папки процесса. Файлы остаются в корне выходного каталога: `base` — это префикс URL, а не вложенная папка. Свои пути с ведущим `/` в `bait` получают этот префикс сами.
151
+
152
+ Импорт пакета в dev собирается в виртуальный модуль с нейтральным id: иначе Vite запросил бы файл из `node_modules/decoy-duck/`, и блокировщик мог бы вырезать сам детектор.
153
+
154
+ Если файл с тем же путём уже лежит в выходном каталоге и байты отличаются, сборка останавливается и не затирает его.
155
+
156
+ ## Разработка этого репозитория
157
+
158
+ Проверка без браузера: `npm test`.
159
+
160
+ Браузерный прогон с живыми расширениями: `npm run test:browser`. Окно при этом не открывается. Чтобы смотреть демо на экране, по одному расширению за раз: `HEADED=1 npm run test:browser`. Он не входит в `npm test` и перед публикацией не запускается. Chromium качает Playwright, сборки расширений — скрипт, в `.cache/extensions`. Страница открывается как `localtest.me`, а не `localhost`: списки фильтров не трогают локальный хост.
161
+
162
+ Сейчас в прогоне чистый Chromium, uBlock Origin Lite (оптимальный и полный режим), AdGuard, [Adblock Plus](https://adblockplus.org/ru/) и Ghostery. Полный uBlock Origin в текущем релизе всё ещё Manifest V2, Chromium его не загружает.
163
+
164
+ `npm run dev` в этом репозитории открывает демо через тот же публичный вход и плагин Vite.
165
+
166
+ ## Публикация
167
+
168
+ Пакет публичный, без scope. Репозиторий: `github.com/N3P0m/decoy-duck`. Следующие версии отправляет GitHub Actions по тегу `v*`, файл `.github/workflows/publish.yml`. Версия в теге должна совпасть с `version` в `package.json`. Перед отправкой `prepublishOnly` запускает тесты.
169
+
170
+ Первый выпуск имени, которого ещё нет в реестре, делается локально: `npm login`, затем `npm publish`. npm спросит код 2FA. Trusted Publishing настраивается только у уже существующего пакета, поэтому секрет `NPM_TOKEN` workflow не использует.
171
+
172
+ Сразу после появления пакета на npmjs.com откройте его → Settings → Trusted Publisher → GitHub Actions:
173
+
174
+ - Organization or user: `N3P0m`
175
+ - Repository: `decoy-duck`
176
+ - Workflow filename: `publish.yml`
177
+ - Environment оставьте пустым
178
+ - Разрешите прямую публикацию через `npm publish`. Новые настройки по умолчанию пускают только `npm stage publish`.
179
+
180
+ Первая успешная публикация через этот publisher должна пройти в течение 48 часов, иначе настройка сгорает. Поле `repository.url` в `package.json` должно остаться `git+https://github.com/N3P0m/decoy-duck.git`: npm сверяет его с репозиторием, из которого пришёл OIDC-токен. Репозиторий должен быть публичным, иначе provenance не появится.
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "decoy-duck",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Проверка фильтра рекламы в браузере: четыре пробы и вероятность, что фильтр включён. Для Vite есть плагин, который отдаёт bait-файлы.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/N3P0m/decoy-duck.git"
9
+ },
10
+ "homepage": "https://github.com/N3P0m/decoy-duck#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/N3P0m/decoy-duck/issues"
13
+ },
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./src/filter-check/index.d.ts",
18
+ "default": "./src/filter-check/index.js"
19
+ },
20
+ "./vite": {
21
+ "types": "./src/filter-check/vite.d.ts",
22
+ "default": "./src/filter-check/vite.js"
23
+ }
24
+ },
25
+ "types": "./src/filter-check/index.d.ts",
26
+ "files": [
27
+ "src/filter-check",
28
+ "README.md",
29
+ "!src/filter-check/**/*.test.js"
30
+ ],
31
+ "scripts": {
32
+ "dev": "vite",
33
+ "build": "vite build",
34
+ "preview": "vite preview",
35
+ "test": "node --test test/*.test.js && tsc --noEmit -p tsconfig.json",
36
+ "test:browser": "node --test --test-timeout 300000 test/browser/extensions.test.js",
37
+ "prepublishOnly": "npm test"
38
+ },
39
+ "dependencies": {
40
+ "esbuild": "^0.28.2"
41
+ },
42
+ "devDependencies": {
43
+ "playwright": "^1.56.1",
44
+ "typescript": "^5.9.3",
45
+ "vite": "^8.3.4"
46
+ }
47
+ }
@@ -0,0 +1,10 @@
1
+ (function () {
2
+ try {
3
+ var src = document.currentScript && document.currentScript.src;
4
+ if (!src) return;
5
+ var token = new URL(src).searchParams.get('t');
6
+ if (!token) return;
7
+ var bag = globalThis.__hdrBag || (globalThis.__hdrBag = Object.create(null));
8
+ bag[token] = 1;
9
+ } catch (e) {}
10
+ })();
@@ -0,0 +1,10 @@
1
+ (function () {
2
+ try {
3
+ var src = document.currentScript && document.currentScript.src;
4
+ if (!src) return;
5
+ var token = new URL(src).searchParams.get('t');
6
+ if (!token) return;
7
+ var bag = globalThis.__ctlBag || (globalThis.__ctlBag = Object.create(null));
8
+ bag[token] = 1;
9
+ } catch (e) {}
10
+ })();
@@ -0,0 +1,130 @@
1
+ import { decide } from './verdict.js';
2
+ import { probeCosmetic } from './probes/cosmetic.js';
3
+ import { probeImagePair, probeScriptPair, probeThirdParty } from './probes/resource.js';
4
+ import { applyBase, publicBase } from './urls.js';
5
+
6
+ const DEFAULT_TIMEOUT = 2500;
7
+
8
+ /** Адреса проб. Файлы из `baits/` должны отдаваться по этим путям как статика. */
9
+ export const defaultBait = {
10
+ scriptUrl: '/ad_header.js',
11
+ imageUrl: '/ads/images/pixel.png',
12
+ controlScriptUrl: '/assets/ctrl.js',
13
+ controlImageUrl: '/assets/pixel.png',
14
+ thirdPartyUrl: 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js',
15
+ };
16
+
17
+ function domReady(signal) {
18
+ if (signal?.aborted) return Promise.resolve(false);
19
+ if (document.readyState !== 'loading') return Promise.resolve(true);
20
+ return new Promise((resolve) => {
21
+ const onReady = () => {
22
+ signal?.removeEventListener('abort', onAbort);
23
+ resolve(true);
24
+ };
25
+ const onAbort = () => {
26
+ document.removeEventListener('DOMContentLoaded', onReady);
27
+ resolve(false);
28
+ };
29
+ document.addEventListener('DOMContentLoaded', onReady, { once: true });
30
+ signal?.addEventListener('abort', onAbort, { once: true });
31
+ });
32
+ }
33
+
34
+ function blank() {
35
+ return {
36
+ detected: null,
37
+ status: 'unknown',
38
+ confidence: 'low',
39
+ probability: 50,
40
+ signals: {
41
+ cosmetic: 'inconclusive',
42
+ script: 'inconclusive',
43
+ image: 'inconclusive',
44
+ thirdParty: 'inconclusive',
45
+ },
46
+ elapsedMs: 0,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Проверяет, режет ли страница рекламу.
52
+ * Сетевые bait-файлы не входят в бандл: их URL должны совпасть с правилами фильтра.
53
+ *
54
+ * @param {object} [options]
55
+ * @param {number} [options.timeout=2500] Таймаут сетевых проб, мс.
56
+ * @param {AbortSignal} [options.signal] Отмена проверки.
57
+ * @param {Partial<typeof defaultBait>} [options.bait] Свои URL вместо `defaultBait`.
58
+ * @param {boolean} [options.thirdParty=true] Запрос к рекламному хосту. `false` его не отправляет.
59
+ * @returns {Promise<{
60
+ * detected: boolean | null,
61
+ * status: 'active' | 'inactive' | 'unknown',
62
+ * confidence: 'high' | 'medium' | 'low',
63
+ * probability: number,
64
+ * signals: {
65
+ * cosmetic: 'blocked' | 'clear' | 'inconclusive',
66
+ * script: 'blocked' | 'clear' | 'inconclusive',
67
+ * image: 'blocked' | 'clear' | 'inconclusive',
68
+ * thirdParty: 'blocked' | 'clear' | 'inconclusive' | 'skipped',
69
+ * },
70
+ * elapsedMs: number,
71
+ * }>} `detected === true` только при `status: 'active'`. `null` значит, что ответа нет.
72
+ */
73
+ function withBase(bait, base) {
74
+ return {
75
+ ...bait,
76
+ scriptUrl: applyBase(base, bait.scriptUrl),
77
+ imageUrl: applyBase(base, bait.imageUrl),
78
+ controlScriptUrl: applyBase(base, bait.controlScriptUrl),
79
+ controlImageUrl: applyBase(base, bait.controlImageUrl),
80
+ };
81
+ }
82
+
83
+ export async function detectAdblock(options = {}) {
84
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
85
+ const bait = withBase({ ...defaultBait, ...options.bait }, publicBase());
86
+ const signal = options.signal;
87
+ const thirdPartyEnabled = options.thirdParty !== false && Boolean(bait.thirdPartyUrl);
88
+
89
+ const ready = await domReady(signal);
90
+ if (!ready || signal?.aborted) return blank();
91
+
92
+ const started = performance.now();
93
+ const [cosmetic, script, image, thirdParty] = await Promise.all([
94
+ probeCosmetic({ signal }).catch(() => ({ status: 'inconclusive', control: 'error' })),
95
+ probeScriptPair({
96
+ controlUrl: bait.controlScriptUrl,
97
+ baitUrl: bait.scriptUrl,
98
+ timeout,
99
+ signal,
100
+ }).catch(() => ({ status: 'inconclusive', control: 'error' })),
101
+ probeImagePair({
102
+ controlUrl: bait.controlImageUrl,
103
+ baitUrl: bait.imageUrl,
104
+ timeout,
105
+ signal,
106
+ }).catch(() => ({ status: 'inconclusive', control: 'error' })),
107
+ thirdPartyEnabled
108
+ ? probeThirdParty({ url: bait.thirdPartyUrl, timeout, signal }).catch(() => 'failed')
109
+ : Promise.resolve('skipped'),
110
+ ]);
111
+
112
+ const controlOk = script.control === 'loaded' || image.control === 'loaded';
113
+ let thirdPartyStatus = 'inconclusive';
114
+ if (thirdParty === 'skipped') thirdPartyStatus = 'skipped';
115
+ else if (thirdParty === 'ok') thirdPartyStatus = 'clear';
116
+ else if (thirdParty === 'failed' && controlOk) thirdPartyStatus = 'blocked';
117
+
118
+ const signals = {
119
+ cosmetic: cosmetic.status,
120
+ script: script.status,
121
+ image: image.status,
122
+ thirdParty: thirdPartyStatus,
123
+ };
124
+
125
+ return {
126
+ ...decide(signals),
127
+ signals,
128
+ elapsedMs: Math.round(performance.now() - started),
129
+ };
130
+ }
@@ -0,0 +1,51 @@
1
+ export type ProbeStatus = 'blocked' | 'clear' | 'inconclusive' | 'skipped';
2
+ export type DetectStatus = 'active' | 'inactive' | 'unknown';
3
+ export type Confidence = 'high' | 'medium' | 'low';
4
+
5
+ export interface BaitUrls {
6
+ scriptUrl: string;
7
+ imageUrl: string;
8
+ controlScriptUrl: string;
9
+ controlImageUrl: string;
10
+ thirdPartyUrl: string;
11
+ }
12
+
13
+ export interface DetectSignals {
14
+ cosmetic: ProbeStatus;
15
+ script: ProbeStatus;
16
+ image: ProbeStatus;
17
+ thirdParty: ProbeStatus;
18
+ }
19
+
20
+ export interface DetectResult {
21
+ detected: boolean | null;
22
+ status: DetectStatus;
23
+ confidence: Confidence;
24
+ /** Вероятность, что фильтр включён, от 0 до 100. */
25
+ probability: number;
26
+ signals: DetectSignals;
27
+ elapsedMs: number;
28
+ }
29
+
30
+ export interface DetectOptions {
31
+ /** Таймаут сетевых проб, мс. По умолчанию 2500. */
32
+ timeout?: number;
33
+ /** Отмена проверки. */
34
+ signal?: AbortSignal;
35
+ /** Свои URL вместо `defaultBait`. Путь с `/` уходит как написан. Префикс Vite `base` добавляет только плагин `filterCheck`. */
36
+ bait?: Partial<BaitUrls>;
37
+ /**
38
+ * Запрос к стороннему рекламному хосту.
39
+ * По умолчанию включён. `false` не отправляет его: хост узнаёт IP и User-Agent,
40
+ * а обрыв запроса бывает и без фильтра.
41
+ */
42
+ thirdParty?: boolean;
43
+ }
44
+
45
+ export declare const defaultBait: BaitUrls;
46
+
47
+ export declare function detectAdblock(options?: DetectOptions): Promise<DetectResult>;
48
+
49
+ export declare function decide(
50
+ signals: Partial<DetectSignals>,
51
+ ): Pick<DetectResult, 'detected' | 'status' | 'confidence' | 'probability'>;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Проверка фильтра рекламы.
3
+ *
4
+ * Файлы-приманки должны запрашиваться браузером по своим URL, их нельзя класть в бандл.
5
+ * Путь с ведущим `/` уходит как написан. Префикс Vite `base` добавляет только `filterCheck` из `decoy-duck/vite`.
6
+ * Установка описана в корневом README пакета.
7
+ */
8
+
9
+ export { defaultBait, detectAdblock } from './detect.js';
10
+ export { decide } from './verdict.js';
@@ -0,0 +1,76 @@
1
+ const BAIT_CLASS = 'square-ad sticky-ad sponsored-ad static-ad';
2
+ const CHECK_DELAYS = [100, 400];
3
+ const SETTLE_DELAY = 1000;
4
+
5
+ function isCollapsed(el) {
6
+ if (!el.isConnected) return true;
7
+ if (el.offsetHeight === 0 || el.offsetWidth === 0) return true;
8
+
9
+ const style = getComputedStyle(el);
10
+ if (style.display === 'none') return true;
11
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;
12
+ if (el.getClientRects().length === 0) return true;
13
+
14
+ return false;
15
+ }
16
+
17
+ export function probeCosmetic({ signal } = {}) {
18
+ return new Promise((resolve) => {
19
+ if (signal?.aborted || !document.body) {
20
+ resolve({ status: 'inconclusive' });
21
+ return;
22
+ }
23
+
24
+ const el = document.createElement('div');
25
+ el.className = BAIT_CLASS;
26
+ el.setAttribute('aria-hidden', 'true');
27
+ el.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:10px;height:10px;pointer-events:none;';
28
+ document.body.appendChild(el);
29
+
30
+ let settled = false;
31
+ const timers = [];
32
+ const observer = new MutationObserver(() => {
33
+ if (isCollapsed(el)) finish('blocked');
34
+ });
35
+
36
+ const finish = (status) => {
37
+ if (settled) return;
38
+ settled = true;
39
+ for (const id of timers) clearTimeout(id);
40
+ observer.disconnect();
41
+ signal?.removeEventListener('abort', onAbort);
42
+ el.remove();
43
+ resolve({ status });
44
+ };
45
+
46
+ const onAbort = () => finish('inconclusive');
47
+
48
+ const check = () => {
49
+ if (isCollapsed(el)) finish('blocked');
50
+ };
51
+
52
+ signal?.addEventListener('abort', onAbort, { once: true });
53
+ observer.observe(el, { attributes: true });
54
+ observer.observe(document.body, { childList: true });
55
+
56
+ check();
57
+ if (settled) return;
58
+
59
+ requestAnimationFrame(() => {
60
+ if (settled) return;
61
+ requestAnimationFrame(() => {
62
+ if (!settled) check();
63
+ });
64
+ });
65
+
66
+ for (const delay of CHECK_DELAYS) {
67
+ timers.push(setTimeout(check, delay));
68
+ }
69
+
70
+ timers.push(setTimeout(() => {
71
+ if (settled) return;
72
+ if (isCollapsed(el)) finish('blocked');
73
+ else finish('clear');
74
+ }, SETTLE_DELAY));
75
+ });
76
+ }
@@ -0,0 +1,151 @@
1
+ const CONTROL_FLAG = '__ctl';
2
+ const BAIT_FLAG = '__hdr';
3
+
4
+ function withCacheBust(url) {
5
+ const glue = url.includes('?') ? '&' : '?';
6
+ return `${url}${glue}t=${Date.now()}`;
7
+ }
8
+
9
+ function loadScript(url, flag, timeout, signal) {
10
+ return new Promise((resolve) => {
11
+ if (signal?.aborted || !document.head) {
12
+ resolve('aborted');
13
+ return;
14
+ }
15
+
16
+ const script = document.createElement('script');
17
+ let settled = false;
18
+ const timer = setTimeout(() => finish('timeout'), timeout);
19
+
20
+ const token = `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
21
+ const glue = url.includes('?') ? '&' : '?';
22
+ const bag = `${flag}Bag`;
23
+
24
+ const dropToken = () => {
25
+ const store = globalThis[bag];
26
+ if (!store) return;
27
+ delete store[token];
28
+ if (Object.keys(store).length === 0) delete globalThis[bag];
29
+ };
30
+
31
+ const finish = (result) => {
32
+ if (settled) return;
33
+ settled = true;
34
+ clearTimeout(timer);
35
+ signal?.removeEventListener('abort', onAbort);
36
+ script.onload = null;
37
+ script.onerror = null;
38
+ script.remove();
39
+ dropToken();
40
+ resolve(result);
41
+ };
42
+
43
+ const onAbort = () => finish('aborted');
44
+ script.onload = () => finish(globalThis[bag]?.[token] === 1 ? 'loaded' : 'empty');
45
+ script.onerror = () => finish('error');
46
+ script.async = true;
47
+ script.src = `${url}${glue}t=${token}`;
48
+ signal?.addEventListener('abort', onAbort, { once: true });
49
+ document.head.appendChild(script);
50
+ });
51
+ }
52
+
53
+ function loadImage(url, timeout, signal) {
54
+ return new Promise((resolve) => {
55
+ if (signal?.aborted) {
56
+ resolve('aborted');
57
+ return;
58
+ }
59
+
60
+ const img = new Image();
61
+ let settled = false;
62
+ const timer = setTimeout(() => finish('timeout'), timeout);
63
+
64
+ const finish = (result) => {
65
+ if (settled) return;
66
+ settled = true;
67
+ clearTimeout(timer);
68
+ signal?.removeEventListener('abort', onAbort);
69
+ img.onload = null;
70
+ img.onerror = null;
71
+ resolve(result);
72
+ };
73
+
74
+ const onAbort = () => finish('aborted');
75
+
76
+ img.onload = () => finish(img.naturalWidth > 0 ? 'loaded' : 'empty');
77
+ img.onerror = () => finish('error');
78
+ signal?.addEventListener('abort', onAbort, { once: true });
79
+ img.src = withCacheBust(url);
80
+ });
81
+ }
82
+
83
+ export function interpretPair(control, bait) {
84
+ if (control === 'loaded' && (bait === 'error' || bait === 'timeout' || bait === 'empty')) {
85
+ return 'blocked';
86
+ }
87
+ if (control === 'loaded' && bait === 'loaded') return 'clear';
88
+ return 'inconclusive';
89
+ }
90
+
91
+ export function probeScriptPair({ controlUrl, baitUrl, timeout, signal }) {
92
+ return Promise.all([
93
+ loadScript(controlUrl, CONTROL_FLAG, timeout, signal),
94
+ loadScript(baitUrl, BAIT_FLAG, timeout, signal),
95
+ ]).then(([control, bait]) => ({
96
+ status: interpretPair(control, bait),
97
+ control,
98
+ bait,
99
+ }));
100
+ }
101
+
102
+ export function probeImagePair({ controlUrl, baitUrl, timeout, signal }) {
103
+ return Promise.all([
104
+ loadImage(controlUrl, timeout, signal),
105
+ loadImage(baitUrl, timeout, signal),
106
+ ]).then(([control, bait]) => ({
107
+ status: interpretPair(control, bait),
108
+ control,
109
+ bait,
110
+ }));
111
+ }
112
+
113
+ export function probeThirdParty({ url, timeout, signal }) {
114
+ return new Promise((resolve) => {
115
+ if (signal?.aborted) {
116
+ resolve('aborted');
117
+ return;
118
+ }
119
+
120
+ const controller = new AbortController();
121
+ let aborted = false;
122
+ const timer = setTimeout(() => controller.abort(), timeout);
123
+
124
+ const onAbort = () => {
125
+ aborted = true;
126
+ controller.abort();
127
+ };
128
+
129
+ let settled = false;
130
+ const finish = (result) => {
131
+ if (settled) return;
132
+ settled = true;
133
+ clearTimeout(timer);
134
+ signal?.removeEventListener('abort', onAbort);
135
+ resolve(result);
136
+ };
137
+
138
+ signal?.addEventListener('abort', onAbort, { once: true });
139
+
140
+ fetch(url, {
141
+ mode: 'no-cors',
142
+ cache: 'no-store',
143
+ credentials: 'omit',
144
+ referrerPolicy: 'no-referrer',
145
+ signal: controller.signal,
146
+ }).then(
147
+ () => finish('ok'),
148
+ () => finish(aborted || signal?.aborted ? 'aborted' : 'failed'),
149
+ );
150
+ });
151
+ }
@@ -0,0 +1,34 @@
1
+ export const BAIT_FILES = {
2
+ '/ad_header.js': 'ad_header.js',
3
+ '/assets/ctrl.js': 'assets/ctrl.js',
4
+ '/assets/pixel.png': 'assets/pixel.png',
5
+ '/ads/images/pixel.png': 'ads/images/pixel.png',
6
+ };
7
+
8
+ export function applyBase(base, url) {
9
+ if (!url.startsWith('/') || url.startsWith('//')) return url;
10
+ if (!base || base === '/') return url;
11
+ const prefix = base.endsWith('/') ? base.slice(0, -1) : base;
12
+ return `${prefix}${url}`;
13
+ }
14
+
15
+ export function publicBase() {
16
+ if (typeof __filterCheckBase === 'string' && __filterCheckBase) return __filterCheckBase;
17
+ const env = import.meta.env;
18
+ if (!env) return '/';
19
+ const base = env.BASE_URL;
20
+ return typeof base === 'string' && base.length > 0 ? base : '/';
21
+ }
22
+
23
+ export function stripBase(pathname, base) {
24
+ if (!base || base === '/' || base === './') return pathname;
25
+ const prefix = base.endsWith('/') ? base.slice(0, -1) : base;
26
+ if (pathname === prefix) return '/';
27
+ if (pathname.startsWith(`${prefix}/`)) return pathname.slice(prefix.length);
28
+ return pathname;
29
+ }
30
+
31
+ export function matchBait(url = '', base = '/') {
32
+ const pathname = stripBase(url.split('?')[0], base);
33
+ return BAIT_FILES[pathname] ?? null;
34
+ }
@@ -0,0 +1,60 @@
1
+ const KNOWN = new Set(['blocked', 'clear', 'inconclusive', 'skipped']);
2
+
3
+ function read(signals, key) {
4
+ const value = signals?.[key];
5
+ return KNOWN.has(value) ? value : 'inconclusive';
6
+ }
7
+
8
+ /**
9
+ * Сводит четыре статуса проб в вердикт. Чистая функция, без DOM.
10
+ *
11
+ * @param {{ cosmetic?: string, script?: string, image?: string, thirdParty?: string }} signals
12
+ * Каждый статус: `blocked`, `clear` или `inconclusive`.
13
+ * @returns {{
14
+ * detected: boolean | null,
15
+ * status: 'active' | 'inactive' | 'unknown',
16
+ * confidence: 'high' | 'medium' | 'low',
17
+ * probability: number,
18
+ * }} `probability` — вероятность, что фильтр включён, от 0 до 100.
19
+ */
20
+ export function decide(signals) {
21
+ const cosmetic = read(signals, 'cosmetic');
22
+ const script = read(signals, 'script');
23
+ const image = read(signals, 'image');
24
+ const thirdParty = read(signals, 'thirdParty');
25
+
26
+ if (script === 'blocked' || image === 'blocked') {
27
+ const both = script === 'blocked' && image === 'blocked';
28
+ return { detected: true, status: 'active', confidence: 'high', probability: both ? 98 : 90 };
29
+ }
30
+
31
+ if (cosmetic === 'blocked' && thirdParty === 'blocked') {
32
+ return { detected: true, status: 'active', confidence: 'high', probability: 85 };
33
+ }
34
+
35
+ if (cosmetic === 'blocked') {
36
+ return { detected: true, status: 'active', confidence: 'medium', probability: 60 };
37
+ }
38
+
39
+ if (
40
+ thirdParty === 'blocked' &&
41
+ cosmetic === 'clear' &&
42
+ script === 'clear' &&
43
+ image === 'clear'
44
+ ) {
45
+ return { detected: true, status: 'active', confidence: 'medium', probability: 40 };
46
+ }
47
+
48
+ const thirdPartyClear = thirdParty === 'clear' || thirdParty === 'skipped';
49
+
50
+ if (
51
+ cosmetic === 'clear' &&
52
+ script === 'clear' &&
53
+ image === 'clear' &&
54
+ thirdPartyClear
55
+ ) {
56
+ return { detected: false, status: 'inactive', confidence: 'high', probability: 5 };
57
+ }
58
+
59
+ return { detected: null, status: 'unknown', confidence: 'low', probability: 50 };
60
+ }
@@ -0,0 +1,4 @@
1
+ import type { Plugin } from 'vite';
2
+
3
+ /** Отдаёт bait-файлы в dev и копирует их в `outDir` при сборке. `outDir` считается от `config.root`, URL — от `config.base`. */
4
+ export declare function filterCheck(): Plugin;
@@ -0,0 +1,103 @@
1
+ import { build } from 'esbuild';
2
+ import { cpSync, createReadStream, existsSync, mkdirSync, readFileSync } from 'node:fs';
3
+ import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { BAIT_FILES, matchBait } from './urls.js';
6
+
7
+ const root = dirname(fileURLToPath(import.meta.url));
8
+ const entry = join(root, 'index.js');
9
+ const baits = join(root, 'baits');
10
+ const VIRTUAL = '\0filter-check';
11
+ const PACKAGE = 'decoy-duck';
12
+
13
+ const TYPES = {
14
+ '.js': 'text/javascript; charset=utf-8',
15
+ '.png': 'image/png',
16
+ };
17
+
18
+ export function resolveOutDir(projectRoot, outDir) {
19
+ return isAbsolute(outDir) ? outDir : resolve(projectRoot, outDir);
20
+ }
21
+
22
+ function serveBaits(base, req, res, next) {
23
+ const rel = matchBait(req.url, base);
24
+ if (!rel) {
25
+ next();
26
+ return;
27
+ }
28
+
29
+ const file = join(baits, rel);
30
+ if (!existsSync(file)) {
31
+ next();
32
+ return;
33
+ }
34
+
35
+ res.setHeader('Content-Type', TYPES[extname(file)] || 'application/octet-stream');
36
+ res.setHeader('Cache-Control', 'no-store');
37
+ createReadStream(file).pipe(res);
38
+ }
39
+
40
+ export function copyBaits(targetDir) {
41
+ for (const rel of Object.values(BAIT_FILES)) {
42
+ const source = join(baits, rel);
43
+ const dest = join(targetDir, rel);
44
+ if (existsSync(dest)) {
45
+ const same = readFileSync(source).equals(readFileSync(dest));
46
+ if (!same) {
47
+ throw new Error(
48
+ `filter-check: ${dest} уже есть и отличается от bait-файла пакета. Переименуйте свой файл, иначе проверка рекламы получит не тот ответ.`,
49
+ );
50
+ }
51
+ continue;
52
+ }
53
+ mkdirSync(dirname(dest), { recursive: true });
54
+ cpSync(source, dest);
55
+ }
56
+ }
57
+
58
+ export function filterCheck() {
59
+ let outDir = '';
60
+ let base = '/';
61
+ let command = 'serve';
62
+
63
+ return {
64
+ name: 'filter-check',
65
+ enforce: 'pre',
66
+ config() {
67
+ return {
68
+ optimizeDeps: { exclude: [PACKAGE] },
69
+ };
70
+ },
71
+ configResolved(config) {
72
+ outDir = resolveOutDir(config.root, config.build.outDir);
73
+ base = config.base || '/';
74
+ command = config.command;
75
+ },
76
+ resolveId(id) {
77
+ if (id === PACKAGE) return VIRTUAL;
78
+ },
79
+ async load(id) {
80
+ if (id !== VIRTUAL) return null;
81
+ const result = await build({
82
+ absWorkingDir: root,
83
+ entryPoints: [entry],
84
+ bundle: true,
85
+ format: 'esm',
86
+ platform: 'browser',
87
+ write: false,
88
+ legalComments: 'none',
89
+ });
90
+ return `const __filterCheckBase = ${JSON.stringify(base)};\n${result.outputFiles[0].text}`;
91
+ },
92
+ configureServer(server) {
93
+ server.middlewares.use((req, res, next) => serveBaits(base, req, res, next));
94
+ },
95
+ configurePreviewServer(server) {
96
+ server.middlewares.use((req, res, next) => serveBaits(base, req, res, next));
97
+ },
98
+ closeBundle() {
99
+ if (command !== 'build') return;
100
+ copyBaits(outDir);
101
+ },
102
+ };
103
+ }