# Из DOS в Федиверс: как я выпарсила 1980 цитат и пустила их по кругу в Mastodon :-)))
Дисклеймер: Писать эту статью на английском, скорее всего, нет абсолютно никакого смысла. Весь этот олдскульный фольклор с old-dos.ru, локальные шутки про суровых сисопов, падающий «маздай», диалап и коннект на 2400 бод — это чистый, непереводимый культурный код русскоязычного сегмента сети из девяностых и нулевых, который иной раз я сама не догоняю, например, "У меня мышь в коме" для меня как шутка была не очень понятна, но, как оказалось, там имелся в виду COM-порт. Поэтому текст остаётся на великом и могучем (а местами — на KOI8-R).

Всё началось с того, что я зашла на сайт old-dos.ru и залипла на цитаты в нижней части страницы. Фидошный сленг, шутки про падающий маздай и оборванный коннект — всё это было слишком хорошо, чтобы просто оставить это пылиться на старом сайте. Я решила утащить их к себе.

Сначала я пошла по простому пути: попыталась выкачать сайт целиком, надеясь найти заветный текстовый файл со всеми шутками внутри. План провалился — файла в открытом виде не оказалось или я искала плохо, а может быть смотрела не туда.

Пришлось расчехлять Rust. Я набросала небольшой скрипт-парсер, который делал простую вещь: обновлял страницу, выдирал цитату, проверял, есть ли она уже в моей базе, и если нет — дописывал в текстовый файл. Скрипт покрутился какое-то время и собрал мне шикарную базу — ровно 1980 оригинальных цитат.

Дальше эту сырую массу нужно было подготовить для бота. Я набросала быстрый shell-скрипт, который на лету вычистил мусорные теги, заэкранировал кавычки и аккуратно перегнал весь текст в плоский JSON вида { "id": 1, "text": "..." }.

Имея на руках готовую базу, я написала второго бота на Rust — уже для Mastodon. Логика простая: бот берёт случайную цитату, проверяет по локальному файлу, чтобы не постить баяны в текущем цикле, и отправляет в ленту. Чтобы всё это работало на моей Raspberry Pi Zero, бинарник был скомпилирован под ARMv6 и повешен в крон на публикацию три раза в день. 1980 цитат хватит почти на два года без повторов.

На этом можно было бы закончить, но душа требовала эстетики. Раз уж у меня есть такой архив, я решила сделать из него базу для классических UNIX-утилит fortune и cowsay. Пара команд в консоли, немного магии с перегоном UTF-8 в KOI8-R (чтобы облачко не разваливалось из-за русских букв) — и теперь при каждом запуске терминала меня встречает ASCII-коала, выдающая рандомную олдскульную шутку.

## Акт I. Как выкачать наследие, или Парсинг с привкусом Windows-1251

Прежде чем форматировать JSON и настраивать кросс-компиляцию для Mastodon-бота, нужно было собрать саму базу. Сайт old-dos.ru — это настоящий портал в прошлое. Никаких удобных API или готовых архивов для скачивания там, конечно же, нет. Цитата генерируется случайным образом при каждом обновлении главной страницы и висит где-то в самом низу.

Раз готового файла нет, значит, сделаем свой. Задача для скрипта вырисовывалась предельно четкая: бесконечно обновлять страницу, находить блок с текстом, проверять, есть ли такая шутка в моей коллекции, и если нет — аккуратно дописывать в файл.

Для этой задачи я набросала отдельный мини-проект на Rust. В Cargo.toml понадобилось добавить всего три зависимости: reqwest для самих HTTP-запросов, scraper для ковыряния HTML-дерева и encoding_rs. Последняя библиотека оказалась критически важной. Старые сайты суровы и до сих пор живут в кодировке Windows-1251, от которой современные парсеры, по умолчанию ожидающие UTF-8, просто давятся и выдают нечитаемые кракозябры.

Файл конфигурации получился таким:

```toml
[package]
name = "olddos"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = { version = "0.11", features = ["blocking"] }
scraper = "0.18"
encoding_rs = "0.8"
```

А вот и сам код скрипта-добытчика:

```rust
use reqwest::blocking::Client;
use scraper::{Html, Selector};
use std::collections::HashSet;
use std::fs::OpenOptions;
use std::io::Write;
use std::thread;
use std::time::Duration;

fn main() {
    let url = "http://old-dos.ru/";
    let mut unique_quotes = HashSet::new();
    let file_name = "old_dos_quotes.txt";

    let client = Client::builder()
        .user_agent("Mozilla/5.0 (X11; Fedora; Linux x86_64) AppleWebKit/537.36")
        .build()
        .unwrap();

    println!("📡 Начинаю бесконечный сбор приколов.");

    let mut total_attempts = 0;

    loop {
        total_attempts += 1;

        if let Ok(response) = client.get(url).send() {
            if let Ok(bytes) = response.bytes() {
                let (decoded, _, _) = encoding_rs::WINDOWS_1251.decode(&bytes);
                let html = Html::parse_document(&decoded);
                let selector = Selector::parse("body").unwrap();

                if let Some(body) = html.select(&selector).next() {
                    let lines: Vec<String> = body.text()
                        .map(|t| t.trim().to_string())
                        .filter(|t| !t.is_empty())
                        .collect();

                    if let Some(pos) = lines.iter().position(|l| l.contains("For abuses")) {
                        if pos > 0 {
                            let quote = lines[pos - 1].clone();

                            if quote.len() > 5 && !quote.contains("Поиск") && !quote.contains("1 2 3") {
                                if unique_quotes.insert(quote.clone()) {
                                    println!("[Новая! #{}] {}", unique_quotes.len(), quote);

                                    let mut file = OpenOptions::new()
                                        .create(true)
                                        .append(true)
                                        .open(file_name)
                                        .unwrap();

                                    if let Err(e) = writeln!(file, "{}", quote) {
                                        eprintln!("Ошибка записи: {}", e);
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }

        print!("\rПопытка: {} | Уникальных: {} ", total_attempts, unique_quotes.len());
        std::io::stdout().flush().unwrap();

        thread::sleep(Duration::from_millis(500));
    }
}
```

### Что здесь происходит под капотом:
* Этика парсинга и почему только один поток: Если убрать искусственный тормоз в виде thread::sleep(Duration::from_millis(500)) и врубить агрессивную многопоточность, мы не базу соберем, а устроим классическую DoS-атаку. Сайт old-dos.ru крутится не на кластерах Google. Скорее всего, под капотом там старенький веб-сервер и база данных, которая при каждом нашем запросе делает что-то вроде тяжеловесного `ORDER BY RAND() LIMIT 1`, чтобы выплюнуть случайную цитату. Если ударить по ней десятком потоков без задержек, процессор сервера мгновенно забьётся в сотку, база ляжет, а для остальных посетителей сайт просто умрет, выдавая 502 Bad Gateway. Парсинг старого веба — это про уважение к чужому железу. Мы пришли сюда спасать историческое наследие, а не добивать сервер, на котором оно держится. Поэтому полсекунды задержки — это идеальный компромисс между скоростью сбора и жизнью дедушки-сервера.
* Битва с кодировкой: Получив ответ сервера, мы берем сырые байты и прогоняем их через encoding_rs::WINDOWS_1251.decode(). Только после этого HTML скармливается парсеру.
* Поиск иголки в стоге HTML: В старой верстке нет красивых семантических тегов вроде <div class="quote">. Пришлось искать зацепку. Я заметила, что сразу под шуткой всегда идет служебная строчка "For abuses". Скрипт просто ищет эту фразу в тексте страницы и забирает элемент, который находится ровно на одну позицию выше.
* Защита от мусора и потери данных: Чтобы не нахватать дублей, используется HashSet. Как только попадается новая уникальная фраза (и она проходит фильтр на отсев кнопок навигации вроде "Поиск"), она тут же дописывается в old_dos_quotes.txt в режиме append. Это гарантирует, что даже если скрипт упадет, ни одна собранная строчка не пропадет.

Я запустила этот код и пошла заниматься своими делами. Статус в консоли компактно обновлялся в одну строчку через символ возврата каретки \r, показывая количество попыток и пополнение словаря. Когда счетчик перевалил за 1980 уникальных цитат, а новые стали появляться совсем уж редко, я просто нажала кнопку 'Stop' в RustRover.

Доверять коду на 100% — не мой метод. Поэтому перед тем, как скармливать готовый old_dos_quotes.txt питоновскому скрипту для генерации JSON, я сделала финальную проверку на чистоту прямо в терминале с помощью старой доброй классики:

```bash
sort old_dos_quotes.txt | uniq -d
```

Эта команда прогоняет текст через пайп, сортирует его и выводит на экран только те строки, которые повторяются больше одного раза. В итоге 1980 собранных цитат были абсолютно уникальными и базу можно было смело отправлять на форматирование.

Сырая база была готова. Дальше её ждало превращение в стройный JSON для Mastodon-бота.

## Акт II. Укрощение спецсимволов, или Нафига мне сдался JSON

Сырая база из почти двух тысяч цитат лежала в текстовом файле, и на первый взгляд казалось, что её можно просто скармливать боту построчно. Возникает резонный вопрос: нафига вообще было городить огород с конвертацией в JSON?

Причин было две, и обе критические:
* Учёт баянов. Чтобы бот не постил одно и то же по кругу, ему нужно как-то запоминать, что он уже отправлял. Запоминать целые строки — глупо и неэффективно. Каждой цитате нужен был свой уникальный числовой id.
* Ад со спецсимволами. Фидошный юмор — это минное поле для любого парсера. Текст буквально кишит кавычками, обратными слешами и псевдографикой. Чего только стоят строчки вроде ~!@#$%^&*( - это pаскладка клавиатypы, а не то, что вы подyмали! , (A)bort, (R)etry, (I)gnore == Haфиг, Heфиг, Пoфиг или 2b|!2b. Если попытаться скормить это напрямую в Rust, программа просто подавится на первом же неэкранированном символе и выдаст ошибку.

JSON решает обе проблемы изящно и раз и навсегда.

Для этой трансформации я написала компактный скрипт на Python:

```python
import json
import re

with open("old_dos_quotes.txt", "r", encoding="utf-8") as f:
    content = f.read()

content = re.sub(r'\\s*', '', content)
lines = [line.strip() for line in content.split('\n') if line.strip()]

with open("quotes_formatted.txt", "w", encoding="utf-8") as f:
    for i, line in enumerate(lines, 1):
        text_json = json.dumps(line, ensure_ascii=False)
        f.write(f'  {{ "id": {i}, "text": {text_json} }},\n')

print("Фсё кончено! Файлик готовченко!")
```

Как работает эта магия:
* Сначала скрипт открывает и целиком считывает наш файл спасённого наследия. Дальше в дело вступает модуль регулярных выражений re. Строка re.sub(r'\\s*', '', content) и последующий генератор списков нужны для первичной чистки: они вырезают из текста случайный мусор, отсекают лишние пробелы по краям и удаляют пустые строки, оставляя только чистую суть.
* Затем начинается самое важное — цикл записи нового файла. Скрипт перебирает каждую строчку, автоматически присваивая ей порядковый номер i (тот самый id). Но главную работу под капотом делает функция json.dumps(). Она берет на себя всю грязную работу по экранированию: сама расставляет обратные слеши перед двойными кавычками, безопасно обрабатывает спецсимволы и гарантирует, что на выходе получится абсолютно валидный формат.
* После всё это аккуратно оборачивается в фигурные скобки и записывается в итоговый quotes_formatted.txt.
* В результате буквально за долю секунды сырая масса текста превращается в красивую и безопасную для строгого парсера структуру, где каждая шутка, будь то кусок лога A bad day: "transfer completed (5720468 bytes, 1 CPS)"  или философское наблюдение про падающий маздай, лежит на своей полочке с инвентарным номером.
* И база данных готова, и теперь можно было с чистой совестью переходить к написанию публикующего бота на Rust.

Скрипт выплюнул просто список объектов через запятую. Чтобы строгий парсер в Rust не сошел с ума и смог прочитать это как массив, нужно открыть получившийся файл и ручками поставить квадратную скобку `[` в самой первой строке и `]` в самой последней (заодно убрав висячую запятую у последней цитаты).

Должно получится как-то так:
```json
[
  { "id": 1, "text": "Настоящие программисты не пишут на Паскале..." },
  { "id": 2, "text": "A bad day: transfer completed (5720468 bytes, 1 CPS)" },
...
  { "id": 1980, "text": "У меня мышь в коме" }
]
```

## Акт III. Бот, который не повторяется: пишем логику на Rust

Когда данные собраны и аккуратно упакованы в quotes.json, остаётся самое главное — заставить всё это работать автономно. Логика бота должна быть надёжной: выбирать случайную цитату, не допускать баянов в пределах одного цикла (а это, на минуточку, почти два года), прикреплять картинки, если они есть, и уметь удалять свои же старые посты, чтобы не превращать сервер в свалку дубликатов.

Проект собирался с минимальным набором зависимостей. Для работы с сетью используется reqwest с легковесным rustls — это критично для успешной кросс-компиляции под слабую архитектуру ARMv6 без головной боли с библиотеками C.

Файл конфигурации Cargo.toml выглядит максимально аскетично:

```toml
[package]
name = "olddos-quote-bot"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = { version = "0.11", features = ["json", "blocking", "rustls-tls", "multipart"], default-features = false }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenv = "0.15"
rand = "0.8"
anyhow = "1.0"
```
### Секретные материалы: прячем ключи от Mastodon

Чтобы бот мог отправлять посты, ему нужен доступ к нашему аккаунту в Mastodon. Вшивать токены прямо в исходный код на Rust — плохая практика.

Для этого в проекте используется библиотека dotenv, которая умеет подтягивать секреты из локального файла. Всё, что нужно сделать на сервере (в нашем случае на Raspberry Pi), — это создать файл с именем .env ровно в той же директории, из которой запускается сам бинарник скрипта.

Например, для моей структуры папок я просто создаю файл по пути /home/rachel/Mastodon/rust/olddos/.env и прописываю в нём две строки:

```
MASTODON_INSTANCE_URL="https://mastodon.social"
MASTODON_ACCESS_TOKEN="МОЙ_ТОКЕН_ОТ_СЛОНОЗАВРА"
```

Сам код (src/main.rs) уместился в сотню строк. Он читает базу, сверяется со стейтом в published.json, выбирает свежую шутку, форматирует её в виде классического C-комментария и отправляет в Федиверс:

```rust
use anyhow::{Context, Result};
use rand::seq::SliceRandom;
use reqwest::blocking::{multipart, Client};
use serde::{Deserialize, Serialize};
use std::{env, fs, path::Path};

#[derive(Serialize, Deserialize, Clone, Debug)]
struct Quote {
    id: u32,
    text: String,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
struct PostRecord {
    quote_id: u32,
    last_post_id: String,
    posted_this_cycle: bool,
}

fn main() -> Result<()> {
    dotenv::dotenv().ok();

    let instance_url = env::var("MASTODON_INSTANCE_URL")
        .context("MASTODON_INSTANCE_URL missing in environment")?;
    let token = env::var("MASTODON_ACCESS_TOKEN")
        .context("MASTODON_ACCESS_TOKEN missing in environment")?;

    let quotes_path = "quotes.json";
    let state_path = "published.json";
    let images_dir = "images";

    let quotes_data = fs::read_to_string(quotes_path)
        .context("Failed to read quotes.json. Ensure the file exists.")?;
    let all_quotes: Vec<Quote> = serde_json::from_str(&quotes_data)
        .context("Failed to parse quotes.json structure.")?;

    let mut state: Vec<PostRecord> = if Path::new(state_path).exists() {
        let data = fs::read_to_string(state_path)?;
        serde_json::from_str(&data).unwrap_or_default()
    } else {
        Vec::new()
    };

    let mut available_quotes: Vec<Quote> = all_quotes
        .iter()
        .filter(|q| !state.iter().any(|r| r.quote_id == q.id && r.posted_this_cycle))
        .cloned()
        .collect();

    if available_quotes.is_empty() {
        println!("🔄 Cycle complete. Resetting 'posted_this_cycle' flags.");
        for record in &mut state {
            record.posted_this_cycle = false;
        }
        fs::write(state_path, serde_json::to_string_pretty(&state)?)?;
        available_quotes = all_quotes.clone();
    }

    let selected_quote = available_quotes
        .choose(&mut rand::thread_rng())
        .context("No available quotes found to post.")?;

    let client = Client::builder().use_rustls_tls().build()?;

    if let Some(old_record) = state.iter().find(|r| r.quote_id == selected_quote.id) {
        if !old_record.last_post_id.is_empty() {
            println!("🗑 Deleting old post version (ID: {})...", old_record.last_post_id);
            let delete_url = format!("{}/api/v1/statuses/{}", instance_url, old_record.last_post_id);
            let _ = client.delete(&delete_url).bearer_auth(&token).send();
        }
    }

    let image_path = format!("{}/{}.jpg", images_dir, selected_quote.id);
    let mut media_id = None;

    if Path::new(&image_path).exists() {
        println!("📸 Found image for quote #{}. Uploading...", selected_quote.id);
        let form = multipart::Form::new().file("file", &image_path)?;

        let media_res = client
            .post(format!("{}/api/v1/media", instance_url))
            .bearer_auth(&token)
            .multipart(form)
            .send()?;

        if let Ok(m_data) = media_res.json::<serde_json::Value>() {
            if let Some(id) = m_data["id"].as_str() {
                media_id = Some(id.to_string());
                println!("✅ Media uploaded (ID: {})", id);
            }
        }
    }

    let status_text = format!(
        "\"{}\"\n\n/* Цитата №{} с сайта old-dos.ru */",
        selected_quote.text, selected_quote.id
    );
    let mut form_data = vec![("status", status_text)];

    if let Some(ref id) = media_id {
        form_data.push(("media_ids[]", id.clone()));
    }

    let res = client
        .post(format!("{}/api/v1/statuses", instance_url))
        .bearer_auth(&token)
        .form(&form_data)
        .send()?;

    if res.status().is_success() {
        let res_data: serde_json::Value = res.json()?;
        let new_post_id = res_data["id"].as_str().unwrap_or_default().to_string();

        println!("🚀 Quote #{} successfully posted! New Post ID: {}", selected_quote.id, new_post_id);

        if let Some(record) = state.iter_mut().find(|r| r.quote_id == selected_quote.id) {
            record.last_post_id = new_post_id;
            record.posted_this_cycle = true;
        } else {
            state.push(PostRecord {
                quote_id: selected_quote.id,
                last_post_id: new_post_id,
                posted_this_cycle: true,
            });
        }

        fs::write(state_path, serde_json::to_string_pretty(&state)?)
            .context("Failed to save state to published.json")?;
    } else {
        eprintln!("❌ API Error: {} - {}", res.status(), res.text().unwrap_or_default());
    }

    Ok(())
}
```

### Что под капотом? Разбираем логику

Код может показаться монолитным, но на деле он работает как швейцарские часы и решает сразу несколько неочевидных проблем.
* Учёт состояния (published.json)
Бот не просто держит в памяти 1980 цитат. Рядом с основной базой (quotes.json) лежит динамический файл состояния. В нём записывается, какие цитаты уже были опубликованы в текущем «круге», и под какими ID эти посты лежат в Mastodon. При каждом запуске скрипт считывает оба файла и отсеивает то, что уже публиковалось.
* Бесконечная карусель
Что произойдёт, когда бот запостит последнюю, 1980-ю шутку? Ничего не сломается. В коде есть проверка: если пул доступных цитат пуст, скрипт понимает, что цикл завершён. Он автоматически сбрасывает все флаги posted_this_cycle на false и просто начинает новый круг. При частоте публикаций 3 раза в день один такой цикл занимает почти два года.
* Самоочистка ленты (защита от дублей)
Это моя любимая фича. Когда бот заходит на второй (или третий) круг и выбирает случайную цитату, он смотрит в свой published.json. Если он видит, что два года назад уже постил эту шутку, он берёт её старый last_post_id и отправляет в Mastodon API запрос DELETE. Так лента аккаунта всегда остаётся чистой, актуальной и не превращается в бесконечную свалку дубликатов.
* Поддержка картинок
У цитат могут быть графические версии или тематические мемы, которые я предполагаю накидать в папку позже. Бот перед публикацией проверяет, есть ли в папке images/ файл с именем <id_цитаты>.jpg. Если файл найден, скрипт сначала загружает его через Media API, получает media_id от сервера, и только потом прикрепляет к тексту поста.
* Олдскульное форматирование
Сам текст отправляется не просто голым куском. С помощью макроса format! бот элегантно оборачивает подпись в классический синтаксис языка Си. В ленте это выглядит так, будто кто-то оставил комментарий в исходном коде:
/* Цитата №432 с сайта old-dos.ru */

Благодаря reqwest с легковесным бэкендом rustls-tls, весь этот механизм отлично компилируется под ARMv6 через утилиту cross прямо на моей Fedora. Никакой возни с сишным OpenSSL и зависимостями — на выходе получается один статический бинарник, который я просто закинула на малинку и повесила в crontab.

Код получился монолитным и пуленепробиваемым. Оставалось только собрать его под архитектуру Raspberry Pi Zero.

## Акт IV. Ржавчина на Малинке, или Кросс-компиляция без боли

Писать код — это полдела. Настоящее веселье начинается, когда этот код нужно запустить на целевом железе. Моим сервером выступает Raspberry Pi Zero первой ревизии. Это отличная, безотказная железка, но у неё скромный процессор архитектуры ARMv6 и всего 512 МБ оперативной памяти.

Собирать проект на Rust прямо на ней — затея для сильных духом. Компиляция одного только крейта reqwest со всеми зависимостями заняла бы вечность, а скорее всего, процесс просто был бы убит системой из-за нехватки ОЗУ (OOM Killer). Значит, собирать бинарник нужно на основном ПК с Fedora, а на Raspberry отправлять уже готовый исполняемый файл.

Тут возникает классическая проблема: моя Fedora работает на десктопной архитектуре x86_64, а малинка — это ARMv6. Если просто сделать дефолтный cargo build --release, сервер этот файл даже не прочитает. Нужна кросс-компиляция.

Раньше настройка тулчейна для кросс-компиляции в Linux напоминала пляски с бубном: нужно было качать правильные версии gcc, прописывать пути, бороться с несовместимостью версий системной библиотеки glibc... Но в экосистеме Rust есть потрясающая утилита cross. Она работает поверх контейнеров (Podman или Docker) и берет всю грязную работу по подтягиванию нужных зависимостей на себя.

Ставится она элементарно:

```bash
cargo install cross
```

Дальше нужно было выбрать правильный таргет (целевую платформу). Для старых Raspberry Pi идеальным вариантом является сборка со статической линковкой библиотеки musl. Это монолитный подход: все нужные зависимости вшиваются прямо в бинарник, что гарантирует его запуск на сервере вообще без оглядки на то, какие системные пакеты там стоят. Мой таргет назывался arm-unknown-linux-musleabihf.

Запускаем сборку:

```bash
cross build --target arm-unknown-linux-musleabihf --release
```

Пару минут шуршания кулерами на десктопе, утилита сама скачала нужный контейнер с окружением ARM, скомпилировала код, и в папке target появился заветный, полностью статичный исполняемый файл olddos-quote-bot.

Оставалось только перекинуть его по SSH на малинку в подготовленную папку:

```bash
scp olddos-quote-bot rachel@<ip_малинки>:/home/rachel/Mastodon/rust/olddos/
```

### Автоматизация рутины

Чтобы бот жил своей жизнью и регулярно радовал Федиверс олдскулом, его нужно было автоматизировать. Возиться с systemd ради скрипта, который отрабатывает за две секунды и засыпает, не было смысла. Старый добрый cron тут подходит идеально.

Я зашла на малинку, открыла расписание командой crontab -e и добавила туда строчку, чтобы бот публиковал посты три раза в день.

Всё. Механизм запущен. Скрипт просыпается по расписанию, читает спрятанные в .env ключи, выбирает шутку, проверяет её на уникальность в текущем цикле и отправляет в Mastodon.

## Акт IV. Эстетика терминала: как заставить коалу говорить по-русски и не ломать облачко

Бот тихо шуршал в фоне на «малинке», отправляя посты по расписанию, но мне этого показалось мало. Имея на руках почти две тысячи отборных олдскульных цитат, было бы преступлением не интегрировать их в свою повседневную рутину. Душа просила классики: я хотела, чтобы при каждом открытии консоли меня встречала случайная шутка, выведенная через утилиты fortune и cowsay. И раз уж исторически сложилось, что мой вес 53 кило на момент написания статьи, а так же принимая во внимание, что мне всё пофигу, то вещать должена была не корова, а малышка ASCII-коала.

Сначала нужно было скормить нашу текстовую базу утилите fortune. Ей не нужен JSON, но у неё есть свой строгий формат: каждая цитата должна отделяться строкой, содержащей единственный символ %.

Делать это руками на 1980 строк — безумие, поэтому в дело вступил awk. Я набросала простенький скрипт, который брал исходный файл old_dos_quotes.txt, вычищал возможный мусор и расставлял разделители:

```bash
awk '
{
    if ($0 ~ /[^ \t]/) {
        print $0
        print "%"
    }
}' old_dos_quotes.txt > olddos
```

На выходе получился готовый текстовый файл olddos. Но fortune не читает текст напрямую, ему нужен бинарный индекс для быстрого поиска. Создается он одной командой:

```bash
strfile olddos olddos.dat
```

Затем оба файла (olddos и olddos.dat) отправились в системную директорию /usr/share/games/fortune/ (пути могут немного отличаться, иногда это /usr/share/fortune/).

Казалось бы, всё готово. Я предвкушала триумф, пишу: fortune olddos | cowsay -f koala... и получаю визуальную катастрофу.

Облачко текста развалилось. Правая граница улетела куда-то далеко вправо, текст съехал.

```bash
_________________________________________
/ Целый час я кэшировал \
| клавиатуру на                |
\ запись...                    /
 -----------------------------------------
   \
    \
       ___
     {~._.~}
      ( Y )
     ()~*~()
     (_)-(_)
```

### Разбор полётов и битва кодировок

Проблема оказалась до боли банальной, но от того не менее интересной. Утилита cowsay была написана на Perl еще в конце 90-х. Она живет в мире однобайтового ASCII и вообще ничего не знает про современный юникод.

В нашей современной UTF-8 одна русская буква кириллицы занимает 2 байта. Cowsay тупо считает байты, чтобы вычислить длину самой длинной строки и нарисовать рамку. Для неё слово «запись» состоит не из 6 символов, а из 12 байт. Она отсчитывает 12 позиций, ставит правую границу |, а сам терминал отрисовывает визуально короткое слово. Вот рамка и уезжает вправо, оставляя дыры.

Решение напрашивалось само собой: нужно было вернуть коалу в её родную эпоху. Нужна была однобайтовая кодировка, где одна русская буква честно равна одному байту. Идеальный кандидат — легендарная KOI8-R.

Я собрала вот такой конвейер (pipeline) с помощью утилиты iconv:

```bash
fortune olddos | iconv -f utf8 -t koi8-r | cowsay -f koala | iconv -f koi8-r -t utf8
```

Как работает эта магия:
* fortune olddos выплевывает случайную цитату в современном UTF-8.
* iconv -f utf8 -t koi8-r перехватывает её на лету и жестко конвертирует в древнюю однобайтовую KOI8-R.
* В таком виде текст попадает в cowsay -f koala. Перловский скрипт радостно видит, что 1 буква = 1 байт, идеально точно высчитывает ширину строк и рисует ровненькое, красивое облачко с коалой (само ASCII-животное состоит из стандартных английских символов, поэтому не страдает).
* Весь этот сгенерированный арт (текст + животное) всё ещё в KOI8-R. Если вывести его так, современный терминал покажет кракозябры. Поэтому финальный iconv -f koi8-r -t utf8 переводит всю картинку обратно в понятный системе UTF-8.

Результат — идеально ровное облачко с кириллицей. Хак сработал безупречно.

```bash
 _________________________________
/ Если вам надоели кошки, собаки, \
\ тараканы - заводите хакера!     /
 ---------------------------------
  \
   \
       ___  
     {~._.~}
      ( Y )
     ()~*~()   
     (_)-(_)  
```
Чтобы не вводить эту страшную колбасу из пайпов руками, я просто прописала её как алиас в свой ~/.bashrc и добавила вызов в конец файла:

```bash
alias koala='fortune olddos | iconv -f utf8 -t koi8-r | cowsay -f koala | iconv -f koi8-r -t utf8'
koala
```

Теперь каждый раз, когда я открываю новую вкладку терминала или логинюсь по SSH, меня встречает невозмутимая коала с ровненьким диалоговым окном, выдающая что-то вроде: "Настоящие программисты не пишут на Паскале, потому что там нет команды PEEK и POKE".

Шалость удалась. Наследие old-dos.ru сохранено, расфасовано, автоматизировано и теперь живет сразу в двух мирах: в Федиверсе и в моем локальном терминале.

## Эпилог: О цензуре, маце и наследии эпохи

Было бы нечестно сказать, что весь этот текстовый архив состоит сплошь из милых гиковских баек и безобидного фидошного сленга. Парсинг старого интернета — это как археология: иногда откапываешь то, от чего хочется вымыть руки с мылом.

Прогоняя базу через скрипты, я наткнулась на такой перл: "Комсомольцу не к лицу: пить, курить и есть мацу...".

Признаюсь, в первую секунду рука сама потянулась написать скрипт, чтобы поставить old-dos.ru на уютный DDoS, а автора этого шедевра пустить по кругу точно так же, как мой бот пускает эти цитаты в Mastodon. Но, немного остыв и поразмыслив, я пришла к логичному выводу: а ведь и правда, еврею быть комсомольцем — как-то стыдно.

В итоге я решила не играть в цензора и оставила эту цитату в обойме. В конце концов, из песни слов не выкинешь, это тоже слепок времени и наследие той эпохи. Да и кто знает — может, кому-то в ленте она попадётся на глаза и будет очень приятна как тёплое семейное воспоминание о том, что именно его дед на полставки охранял Освенцим и на вторые пол ставки ГУЛАГ.

---
=> /raspberry/index.gmi Перейти в секцию Raspberry Pi & Hardware
=> / Назад на Главную страницу
