Современные проблемы требуют современных решений. Когда важные люди в высоких кабинетах планомерно замедляют привычные сервисы, режут трафик и заставляют глобальную сеть работать со скоростью уставшего почтового голубя, у любого нормального инженера рано или поздно сдают нервы.
Смотреть на то, как твой вылизанный бандл грузится рывками из-за отваливающихся узлов связи, больше нет сил. Все эти бесконечные битвы за 100/100 в Google PageSpeed, микро-оптимизации LCP и внедрение Edge-кэширования теряют смысл, когда пакеты просто не доходят до адресата.
И в какой-то момент я осознал простую истину: если ты не можешь остановить глобальную деградацию веба — возглавь её.
Раз уж мы летим в прошлое, давайте лететь туда с ветерком. Под скрежет диалап-модема, с вырвиглазными GIF-баннерами, кислотными фонами и ломающейся вёрсткой.
Встречайте: Шакализатор сайтов 3000.
Это не просто шуточный скрипт. Это инженерно выверенный Web 1.0 proxy-деградатор на связке Next.js (App Router) и Cheerio. Архитектура боли, которая берёт любой современный лендинг (будь то Apple, Т-Банк или сам Хабр), скачивает его HTML, безжалостно вырезает весь Tailwind, React и CSS-модули, а затем принудительно возвращает страницу в эпоху 1999 года.
Что умеет эта машина судного дня:
-
проксирует целевую страницу на сервере и перехватывает навигацию — можно серфить по ошакаленному сайту, не покидая 1999 год;
-
прогоняет все изображения через внутренний proxy на базе
sharp, принудительно сжимая их, убивая сглаживание и имитируя построчную dial-up загрузку; -
растеризует современные
inline svgв пиксельное месиво; -
инжектит в DOM бегущие строки
<marquee>, тайловые фоны со звёздным небом, счётчики посещений и scam-попапы, которые убегают от курсора.
Никаких полумер. Только хардкорный серверный рендеринг визуального мусора. В этой статье я покажу код и расскажу, как собрать идеальный симулятор цифровой боли, попутно не уронив сервер от утечек памяти.
Добро пожаловать в ад. Отключайте блокировщики рекламы — мы начинаем деградацию.
Задача
Хотелось сделать не просто «страницу с фильтром», а полноценный прокси:
-
Пользователь вводит URL.
-
Сервер скачивает HTML целевой страницы.
-
DOM прогоняется через набор мутаций.
-
Все ссылки переписываются так, чтобы пользователь продолжал ходить внутри ошакаленного мира.
-
Все картинки идут через отдельный image proxy.
-
Результат показывается в iframe или открывается как отдельная страница.
Ключевая мысль: современный сайт нельзя просто «покрасить под ретро». Если оставить его CSS и JS, он будет слишком аккуратным. Сначала — санитарная зачистка.
Стек
Стек получился минималистичный:
-
Next.js App Router
-
TypeScript
-
Cheerio
-
sharp
-
nginx + systemd
-
GitHub Actions
Почему Next.js? Удобно держать рядом UI, API routes и полноценный HTML route /shakal.
Почему Cheerio? Не нужен браузер, layout engine и Playwright на каждый запрос. Берём HTML как дерево, проходимся по нему ломом, возвращаем мутированную строку.
Почему sharp? Шакалить картинки через Canvas на клиенте быстро упирается в CORS. Серверный proxy позволяет делать с изображениями практически всё что угодно.
Общая архитектура
В проекте два основных маршрута.
Первый — POST /api/shakalize — готовит preview. Принимает JSON:
{
"url": "https://example.com",
"mode": "corporate"
}
И возвращает ссылку на готовый документ:
{
"requestedUrl": "https://example.com/",
"previewUrl": "https://r.fun/shakal?url=https%3A%2F%2Fexample.com%2F&mode=corporate",
"mode": "corporate"
}
Второй — GET /shakal?url=...&mode=... — отдаёт мутированный документ и делает основную работу:
export async function GET(request: Request) {
const requestUrl = new URL(request.url);
const appOrigin = getPublicAppOrigin(request);
const rawTargetUrl = requestUrl.searchParams.get("url");
const mode = normalizeDegradationMode(requestUrl.searchParams.get("mode"));
const normalizedTargetUrl = normalizeTargetUrl(rawTargetUrl);
const result = await shakalizeUrl(
normalizedTargetUrl.toString(),
appOrigin,
Math.random,
mode,
);
return new NextResponse(result.html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
},
});
}
Важная деталь: appOrigin нужно брать из публичных заголовков Host / X-Forwarded-Proto, а не из request.url. Иначе в production за nginx Next.js решит, что живёт на localhost:3000, и начнёт генерировать ссылки внутрь iframe на localhost. Я уже наступил на эти грабли в проде.
function firstHeaderValue(value: string | null): string | null {
return value?.split(",")[0]?.trim() || null;
}
export function getPublicAppOrigin(request: Request): URL {
const fallbackUrl = new URL(request.url);
const forwardedHost =
firstHeaderValue(request.headers.get("x-forwarded-host")) ??
firstHeaderValue(request.headers.get("host")) ??
fallbackUrl.host;
const forwardedProto =
firstHeaderValue(request.headers.get("x-forwarded-proto")) ??
fallbackUrl.protocol.replace(":", "");
return new URL(`${forwardedProto}://${forwardedHost}`);
}
Первый слой боли: удаляем современность
Почти вся магия начинается с очень простого фильтра:
export const removeModernAssets: DomFilter = ($) => {
$('link[rel="stylesheet"], style, script').remove();
};
export const stripPresentationalAttributes: DomFilter = ($) => {
$("*").each((_, element) => {
$(element).removeAttr("class");
$(element).removeAttr("style");
});
};
На этом этапе умирает: Tailwind, CSS modules, styled-components, inline-стили, клиентская гидратация и почти вся надежда frontend-разработчика на нормальный день.
SPA, которые полностью рендерятся клиентским JS, после этого могут стать пустыми. Это не баг, это философская позиция. Если сайт не умеет отдавать содержательный HTML — он сам выбрал этот путь.
Cheerio как конвейер мутаций
Мне не хотелось писать один огромный файл на тысячу строк с названием destroyEverything.ts. Поэтому пайплайн сделан как набор DOM-фильтров:
export type DomFilter = (
$: CheerioAPI,
context: ShakalizeContext,
) => void | Promise<void>;
export type ShakalizeContext = {
random: () => number;
requestedUrl: URL;
finalUrl: URL;
appOrigin: URL;
mode: DegradationMode;
};
Фильтры выполняются по очереди:
for (const filter of [...degradationFilters, ...nostalgiaFilters]) {
await filter($, context);
}
Пайплайн превратился в маленькую фабрику по производству ностальгического ущерба: удалить современные ресурсы → переписать картинки → переписать ссылки → растеризовать SVG → добавить ретро-стили → обернуть body в таблицу → внедрить скам-попап → добавить мусорный футер.
Инъекция Web 1.0
После зачистки начинается самое приятное. Сначала инжектится глобальный CSS:
html, body {
font-family: "Comic Sans MS", "Times New Roman", serif !important;
background-image: url("https://gifburg.com/images/gifs/stars/gifs/0014.gif") !important;
color: #00ff00 !important;
cursor: url("https://www.rw-designer.com/cursor-view/21545.png"), auto !important;
}
a { color: #0000ee !important; text-decoration: underline !important; }
a:visited { color: #551a8b !important; }
Потом DOM получает старые добрые артефакты: <center> вокруг содержимого, <marquee> вместо первого заголовка, border="5" на картинки, таблицы вместо header и nav, счётчик посещений, блок «MIDI PLAYER: OFFLINE BUT LOOKS IMPORTANT», баннеры 88×31 по краям и скам-попап «YOU ARE THE 1,000,000th VISITOR».
Для боковых баннеров весь body оборачивается в «рамку боли»:
<table width="100%" height="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="pain-rail">...</td>
<td class="pain-content">оригинальный мутированный сайт</td>
<td class="pain-rail">...</td>
</tr>
</table>
Это не только выглядит плохо, но и архитектурно честно. В 1999 году задачи действительно решались таблицами, терпением и верой в лучшее.
Режимы деградации
Один фильтр быстро наскучивает. Поэтому появились пресеты: GeoCities, Hacker Terminal, Corporate Hell 2001, Princess Homepage.
Каждый режим меняет цветовую схему, шрифты, стиль разделителей, интенсивность битых картинок и характер визуального мусора. Corporate Hell 2001 — серые таблицы, Tahoma, скучные синие ссылки и beveled-разделители в духе Win98. Princess Homepage — розовый фон, блёстки и Comic Sans без каких-либо извинений.
Shareable URL выглядит так: /?url=https://example.com&mode=corporate. Это оказалось важнее, чем кажется — если человек получил смешной результат, он должен отправить именно его.
Перехват навигации
Если просто отдать мутированный HTML, пользователь нажмёт на ссылку и улетит обратно в нормальный интернет. Это недопустимо. Поэтому все ссылки переписываются:
export const rerouteLinksThroughShakalProxy: DomFilter = ($, context) => {
$("a[href], area[href]").each((_, element) => {
const link = $(element);
const href = link.attr("href");
const resolvedUrl = resolveRemoteAssetUrl(href, context.finalUrl);
if (!resolvedUrl) return;
link.attr(
"href",
buildShakalDocumentUrl(resolvedUrl, context.appOrigin, context.mode),
);
});
};
Теперь каждая следующая страница тоже проходит через пайплайн деградации. Сайты на клиентском роутере и history API после удаления скриптов сами себя наказали.
Image proxy: шакалим картинки по-взрослому
Современные изображения слишком чёткие — они ломают атмосферу. Все img[src], srcset и source[srcset] переписываются на внутренний proxy /api/shakal-image?url=..., который:
-
скачивает исходную картинку;
-
проверяет размер ответа;
-
отдаёт в
sharp; -
уменьшает до 28% от оригинала;
-
растягивает обратно через
nearest; -
сохраняет в JPEG с quality: 9.
const tinyWidth = Math.max(24, Math.round(width * 0.28));
const tinyHeight = Math.max(24, Math.round(height * 0.28));
return image
.resize(tinyWidth, tinyHeight, { fit: "inside", kernel: sharp.kernel.nearest })
.resize(width, height, { fit: "fill", kernel: sharp.kernel.nearest })
.jpeg({ quality: 9, mozjpeg: false })
.toBuffer();
На выходе — прекрасное пиксельное месиво, будто картинку переслали по ICQ, сохранили в Paint и переслали ещё раз.
Фейковая построчная загрузка
Настоящую progressive-загрузку можно делать через progressive JPEG и streaming response. Но хотелось контролируемого визуального эффекта, заметного всегда. Поэтому — фейковый scanline reveal.
Картинки получают атрибуты class="retro-scan-image" и data-scan-duration="12000". Клиентский скрипт оборачивает их в <span class="retro-scan-frame">, поверх которого лежит тёмный overlay, медленно уезжающий сверху вниз.
Это один из тех случаев, когда фейк честнее реальности. Пользователь видит ровно тот эффект, ради которого пришёл.
Inline SVG: враг аутентичности
После удаления CSS inline SVG начинают жить своей жизнью: растягиваются на весь экран, остаются идеально чёткими и портят весь ретро-вайб. Поэтому появился отдельный фильтр растеризации:
export const rasterizeInlineSvgElements: DomFilter = async ($) => {
const svgElements = $("svg").toArray();
for (const element of svgElements) {
const svgMarkup = $.html(element);
const raster = await rasterizeSvg(svgMarkup);
$(element).replaceWith(
`<img src="data:image/jpeg;base64,${raster.toString("base64")}" border="5">`,
);
}
};
Теперь даже самые модные векторные иконки выглядят так, будто их нашли на старом CD с клипартом.
Битые картинки как продуктовая фича
Если все картинки успешно загрузились — это подозрительно хорошо. Поэтому часть изображений намеренно заменяется на broken-image placeholder в духе старых Windows-браузеров.
Но если ломать всё подряд, можно превратить логотип Google в огромный крест на весь экран. Поэтому появилась эвристика: не трогать крупные изображения, осторожно с логотипами, активнее ломать мелкие декоративные ассеты, менять вероятность в зависимости от режима деградации. Именно такие ограничения отличают весёлую порчу от нечитабельного хаоса.
Искусственные задержки
Старый интернет был не только уродливым, но и медленным. HTML получает небольшую задержку, картинки — более длинную и стабильную:
const delayMs = stableIntInRangeFromString(
normalizedImageUrl.toString(),
IMAGE_DELAY_RANGE_MS.min,
IMAGE_DELAY_RANGE_MS.max,
);
await sleep(delayMs);
Задержка стабильная от URL — полный рандом делает поведение слишком дёрганым, а стабильная задержка создаёт ощущение «этот конкретный баннер всегда еле ползёт».
Как не убить VPS на 1 CPU и 1 GB RAM
Проект смешной, но sharp не шутит. Пришлось добавить несколько скучных, но необходимых вещей: лимиты на размер HTML и картинок, таймауты fetch-запросов, in-memory cache с дедупликацией, sharp.concurrency(1), sharp.cache(false) и простой limiter для тяжёлых задач:
class AsyncLimiter {
private active = 0;
private readonly queue: Array<() => void> = [];
constructor(private readonly maxConcurrent: number) {}
async run<T>(task: () => Promise<T>): Promise<T> {
if (this.active >= this.maxConcurrent) {
await new Promise<void>((resolve) => this.queue.push(resolve));
}
this.active += 1;
try {
return await task();
} finally {
this.active -= 1;
this.queue.shift()?.();
}
}
}
Да, это не Redis и не BullMQ. Но для MVP на маленьком позволяет не умереть от первого же сайта с двадцатью hero-картинками.
Кэширование
Кэш здесь нужен не для красоты, а для выживания. Страницы и картинки складываются в in-memory cache с TTL и ограничением размера. Плюс защита от dogpile-эффекта: если десять пользователей одновременно запросили одну картинку, сервер не должен десять раз запускать sharp. Достаточно одного promise, на который подпишутся остальные.
Деплой: где я наступил на грабли
Деплой через GitHub Actions: checkout → npm ci → typecheck → build → упаковка standalone-сборки → scp на сервер → распаковка → переключение symlink → рестарт systemd.
Next.js в standalone-режиме:
const nextConfig = {
output: "standalone",
poweredByHeader: false,
};
export default nextConfig;
На сервере — systemd-сервис. Самая смешная проблема: /shakal падал с ReferenceError: File is not defined. Причина — сборка на Node 22, а сервер запускал на Node 18. Решение банальное: привести runtime к Node 22. После этого:
https://retroweb.fun -> 200 OK
https://www.retroweb.fun -> 200 OK
Ограничения
Проект сознательно не пытается быть универсальным браузером. Может ломаться на сайтах с антибот-защитой (Cloudflare Enterprise), страницах без SSR, формах с POST-навигацией и сложных видео/канвасах.
Также пока нет полноценной SSRF-защиты: нужно блокировать private IP, localhost, link-local-адреса и DNS rebinding-сценарии. MVP имеет лимиты и таймауты, но перед серьёзной нагрузкой этот слой нужно усиливать.
Что можно добавить дальше
-
SSRF-защита;
-
Redis cache;
-
режим «33.6 kbps / 56k / rural nightmare»;
-
генерация preview-картинки для шаринга;
-
guestbook-муляж;
-
fake Win98 alert windows;
-
статистика самых популярных ошакаленных сайтов;
-
«режим музейного экспоната» — показывать, какие мутации были применены к странице.
Итог
Веб двадцать лет пытался стать быстрее, чище и удобнее. Мы добавляли bundlers, hydration, streaming, edge, CDN, image optimization, font-display, preconnect, preload, islands architecture и ещё десяток способов приблизить пользователя к идеальному первому экрану.
А потом пришёл я и добавил <marquee>.
Иногда pet-проекты нужны не для пользы, а для вентиляции инженерной психики. «Шакализатор» смешной, местами абсурдный, но внутри — вполне настоящая архитектура: серверный HTML proxy, DOM-пайплайн, image processing, caching, deploy, nginx, systemd и production-грабли.
И в этом есть своя красота.
Если интернет всё равно деградирует — пусть делает это с Comic Sans, счётчиком посещений и баннером «YOU ARE THE 1,000,000th VISITOR».
Форкайте, запускайте локально, прикручивайте свои режимы деградации и не забывайте: если страница выглядит так, будто ее собрали в табличной верстке под нервный MIDI-файл, значит все идет по плану.
Автор: JuxaDan
