Из 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, просто давятся и выдают нечитаемые кракозябры.
Файл конфигурации получился таким:
[package]
name = "olddos"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { version = "0.11", features = ["blocking"] }
scraper = "0.18"
encoding_rs = "0.8"
А вот и сам код скрипта-добытчика:
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, я сделала финальную проверку на чистоту прямо в терминале с помощью старой доброй классики:
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:
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 не сошел с ума и смог прочитать это как массив, нужно открыть получившийся файл и ручками поставить квадратную скобку `[` в самой первой строке и `]` в самой последней (заодно убрав висячую запятую у последней цитаты).
Должно получится как-то так:
[
{ "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 выглядит максимально аскетично:
[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-комментария и отправляет в Федиверс:
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("es_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) и берет всю грязную работу по подтягиванию нужных зависимостей на себя.
Ставится она элементарно:
cargo install cross
Дальше нужно было выбрать правильный таргет (целевую платформу). Для старых Raspberry Pi идеальным вариантом является сборка со статической линковкой библиотеки musl. Это монолитный подход: все нужные зависимости вшиваются прямо в бинарник, что гарантирует его запуск на сервере вообще без оглядки на то, какие системные пакеты там стоят. Мой таргет назывался arm-unknown-linux-musleabihf.
Запускаем сборку:
cross build --target arm-unknown-linux-musleabihf --release
Пару минут шуршания кулерами на десктопе, утилита сама скачала нужный контейнер с окружением ARM, скомпилировала код, и в папке target появился заветный, полностью статичный исполняемый файл olddos-quote-bot.
Оставалось только перекинуть его по SSH на малинку в подготовленную папку:
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, вычищал возможный мусор и расставлял разделители:
awk '
{
if ($0 ~ /[^ \t]/) {
print $0
print "%"
}
}' old_dos_quotes.txt > olddos
На выходе получился готовый текстовый файл olddos. Но fortune не читает текст напрямую, ему нужен бинарный индекс для быстрого поиска. Создается он одной командой:
strfile olddos olddos.dat
Затем оба файла (olddos и olddos.dat) отправились в системную директорию /usr/share/games/fortune/ (пути могут немного отличаться, иногда это /usr/share/fortune/).
Казалось бы, всё готово. Я предвкушала триумф, пишу: fortune olddos | cowsay -f koala... и получаю визуальную катастрофу.
Облачко текста развалилось. Правая граница улетела куда-то далеко вправо, текст съехал.
_________________________________________
/ Целый час я кэшировал \
| клавиатуру на |
\ запись... /
-----------------------------------------
\
\
___
{~._.~}
( Y )
()~*~()
(_)-(_)
Разбор полётов и битва кодировок
Проблема оказалась до боли банальной, но от того не менее интересной. Утилита cowsay была написана на Perl еще в конце 90-х. Она живет в мире однобайтового ASCII и вообще ничего не знает про современный юникод.
В нашей современной UTF-8 одна русская буква кириллицы занимает 2 байта. Cowsay тупо считает байты, чтобы вычислить длину самой длинной строки и нарисовать рамку. Для неё слово «запись» состоит не из 6 символов, а из 12 байт. Она отсчитывает 12 позиций, ставит правую границу |, а сам терминал отрисовывает визуально короткое слово. Вот рамка и уезжает вправо, оставляя дыры.
Решение напрашивалось само собой: нужно было вернуть коалу в её родную эпоху. Нужна была однобайтовая кодировка, где одна русская буква честно равна одному байту. Идеальный кандидат — легендарная KOI8-R.
Я собрала вот такой конвейер (pipeline) с помощью утилиты iconv:
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.
Результат — идеально ровное облачко с кириллицей. Хак сработал безупречно.
_________________________________
/ Если вам надоели кошки, собаки, \
\ тараканы - заводите хакера! /
---------------------------------
\
\
___
{~._.~}
( Y )
()~*~()
(_)-(_)
Чтобы не вводить эту страшную колбасу из пайпов руками, я просто прописала её как алиас в свой ~/.bashrc и добавила вызов в конец файла:
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 Pi & Hardware
/raspberry/