Современные проблемы требуют современных решений. Когда важные люди в высоких кабинетах планомерно замедляют привычные сервисы, режут трафик и заставляют глобальную сеть работать со скоростью уставшего почтового голубя, у любого нормального инженера рано или поздно сдают нервы.
Смотреть на то, как твой вылизанный бандл грузится рывками из-за отваливающихся узлов связи, больше нет сил. Все эти бесконечные битвы за 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("http://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 на маленьком VPS позволяет не умереть от первого же сайта с двадцатью 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 OKhttps://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-файл, значит все идет по плану.
ссылка на оригинал статьи https://habr.com/ru/articles/1027376/