Знаете эти милые аватарки с животными в общих Google-документах? Они появляются у каждого, кто открывает файл по ссылке. Выбрать себе зверюшку заранее или как-то на неё повлиять нельзя - ваш аватар назначается случайно. Чтобы узнать, в кого вы "превратились", придется спросить у кого-нибудь из тех, кто в документе вместе с вами. Хочу реализовать похожую механику на kodikapusta.ru
История появления

Как рассказала в Mental Floss представительница Google Кайри Хармон, у этих зверушек давняя история. Ещё в 2012 году сотрудникам компании захотелось оживить скучноватую платформу для работы с документами и добавить в неё немного веселья.
"Тогда анонимных пользователей обозначали длинными цифровыми кодами, вроде Аноним35123512425, - объясняет Хармон. - Команда начала экспериментировать, и во время мозгового штурма родилась аллитерация Anonymous Animals (Анонимные животные). Идею подхватили дизайнеры и нарисовали первые аватарки".
По словам Хармон, уже никто не помнит, с каких именно животных всё началось, но это были самые обычные звери. Со временем список стал пополняться - в нём появились и более необычные существа, включая даже мифических. Так среди аватарок оказались капибара, аксолотль, улыбающаяся саламандра, знаменитый летающий кот Nyan Cat в форме печенья и даже кракен - гигантское морское чудовище из скандинавских легенд.
Разработчики продолжали фантазировать: добавляли, например, исчезнувшие виды вроде кваги - подвида зебры, вымершего ещё в XIX веке. (С тех пор учёные не раз пытались восстановить её черты, скрещивая разных зебр.)
К 2016 году коллекция разрослась до 68 животных. Тогда в ней ещё не было ни лося, ни тигра, ни ламы, не было и джекалопа - легендарного рогатого зайца, чучела которого до сих пор иногда встречаются на американском Западе. Этих зверей добавили значительно позже.
Сейчас в списке 73 животных, и, кажется, на этом решили остановиться. В Google не переживают, что будет, если документ откроют сразу 74 человека - такое случается настолько редко, что придумывать новых зверей просто нет смысла.
Я хочу сделать что-то похожее, только для сайта kodikapusta.ru
Описание реализации
Бекенд буду пилить на Rust. Я все еще стараюсь выучить этот язык и использовать его для чего-то полезного. Неплохо начать с таких игрушечных проектов.
Определился с основным принципом работы. При заходе пользователя на сайт будет срабатывать JS, который сделает вызов нашего бекенда. На бекенде будет сгенерировано новое животное со своим именем, которое сохранится в базу и вернется мне обратно в скрипт. Чтобы отслеживать только активных пользователей и их животных - будем обновлять поле updated_at каждые 30 секунд для каждого активного пользователя.
Сервис будет очень маленьким и с очень простым API.
Запрос на получение списка всех животных:
GET http://localhost/animals
Content-Type: application/json
Ответ:
[
{
"created_at": "2026-01-04T13:57:42.213816023Z",
"id": 65,
"image": "зебра_.png",
"name": "утренняя зебра",
"updated_at": "2026-01-11T04:18:04.894859093Z"
},
{
"created_at": "2026-01-04T13:57:42.213816023Z",
"id": 27,
"image": "лань_.png",
"name": "быстрая лань",
"updated_at": "2026-01-11T04:18:04.894859093Z"
}
]
Запрос на добавление нового животного:
POST http://localhost/animals
Content-Type: application/json
Ответ:
{
"created_at": "2026-01-11T04:19:58.697597054Z",
"id": 669,
"image": "выдра_.png",
"name": "гордая выдра",
"updated_at": "2026-01-11T04:19:58.697600669Z"
}
Запрос на обновление updated_at у животного с id 65:
PUT http://localhost/animals
Content-Type: application/json
{
"id":65
}
Генерация нового животного - это присвоение случайного названия и сохранение в базу. Название формируется из двух случайных слов - прилагательного и названия животного, например: "утренняя зебра". Картинка для животного подбирается в зависимости от названия животного.
Реализация на Rust
Я буду использовать axum как и в прошлой статье. Список активных животных будет храниться в простой SQLite базе в табличке animals. При старте приложения если таблички нет, то она будет создаваться автоматически.
use sqlx::{Pool, Sqlite};
use sqlx::Executor;
pub async fn db_pool() -> Pool<Sqlite>{
let opt = sqlx::sqlite::SqliteConnectOptions::new().filename("data/animals.db").create_if_missing(true);
let pool = sqlx::sqlite::SqlitePool::connect_with(opt).await.unwrap();
pool.execute("
CREATE TABLE if not exists animals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
image TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
").await.unwrap();
pool
}
Нужно реализовать генератор случайных названий для животных. Эту логику я вынес в отдельный трейт и реализовал в файле generator.rs.
use rand::rng;
use rand::seq::{IndexedRandom};
#[derive(Clone)]
pub struct AnimalNameGenerator {
adjectives: Vec<&'static str>,
animals: Vec<&'static str>,
}
impl AnimalNameGenerator {
pub fn new() -> Self {
AnimalNameGenerator {
adjectives: vec![
"быстрая", "медленная", "умная", "хитрая", "гордая",
"смелая", "тихая", "громкая", "пушистая", "грациозная",
"ловкая", "сильная", "неуклюжая", "зоркая", "верная",
"игривая", "горячая", "холодная", "солнечная", "лунная",
"северная", "южная", "лесная", "горная", "речная",
"морская", "степная", "пустынная", "ночная", "утренняя",
"зимняя", "весенняя", "летняя", "осенняя", "радужная",
"серебристая", "золотая", "бронзовая", "мраморная", "полосатая",
],
animals: vec![
"лань", "рысь", "лиса", "волчица", "пантера",
"пума", "гепард", "антилопа", "зебра", "газель",
"ласка", "куница", "соболь", "выдра", "норка",
],
}
}
pub fn generate(&self) -> String {
let mut rng = rng();
let adjective = self.adjectives.choose(&mut rng).unwrap_or(&"быстрая");
let animal = self.animals.choose(&mut rng).unwrap_or(&"лань");
format!("{} {}", adjective, animal)
}
}
Логика генерации очень простая - в случайном порядке выбирается одно название животного и одно прилагательное для этого животного. Я не стал заморачиваться с родами и правильными окончаниями, так получается даже веселее. Пример как можно использовать генератор:
let generator = AnimalNameGenerator::new();
let name = generator.generate();
Все картинки животных собраны в папку /assets - там лежат как маленькие иконки, названия которых сохраняются в базу вместе с названиями животных, так и большие картинки для наглядности.
Чтобы в методах иметь доступ к базе и генератору имен животных я добавил AppState, который пробрасывается во все наши ручки. Структура содержит всего два поля:
#[derive(Clone)]
pub struct AppState {
pub db: Pool<Sqlite>,
pub generator: AnimalNameGenerator,
}
Создается стейт в main.rs и сразу пробрасывается в ручки через механизм axum.
let state = AppState {
db: db_pool().await,
generator: AnimalNameGenerator::new(),
};
let router = Router::new()
.route("/", get(home))
.route("/animals", post(add))
.route("/animals", put(update))
.route("/animals", get(animals))
.nest_service("/assets", ServeDir::new("assets"))
.layer(
CorsLayer::new()
.allow_methods(Any)
.allow_headers(Any)
.allow_origin(Any),
)
.with_state(state);
Тут нужно чуть больше пояснений. Код .nest_service("/assets", ServeDir::new("assets")) нужен для раздачи статичных файлов из папки /assets, в которой лежат картинки животных. А дальше настраиваются CORS.
.layer(
CorsLayer::new()
.allow_methods(Any)
.allow_headers(Any)
.allow_origin(Any),
)
Это нужно чтобы спокойно сервить нашу API-шку с животными на отдельном домене.
Осталось реализовать три ручки для добавления, изменения и получения списка животных. Я не буду тут приводить весь код (его можно посмотреть на gitflic), покажу только как добавляется новое животное с помощью sqlx.
pub async fn add(State(state): State<AppState>) -> impl IntoResponse {
let name = state.generator.generate();
let parts: Vec<&str> = name.split_whitespace().collect();
let image = format!("{}_.png", parts[1]);
let res = sqlx::query("INSERT INTO animals (name, image, created_at, updated_at) VALUES ($1, $2, $3, $4)")
.bind(name)
.bind(image)
.bind(Utc::now())
.bind(Utc::now())
.execute(&state.db)
.await;
match res {
Ok(res) => {
let id = res.last_insert_rowid();
let animal = sqlx::query_as::<_, Animal>("SELECT id, name, image, created_at, updated_at FROM animals WHERE id = ?")
.bind(id)
.fetch_one(&state.db)
.await;
match animal {
Ok(animal) => {
return (StatusCode::OK, Json(json!(animal))).into_response();
}
Err(animal) => {
println!("{}", animal);
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", animal)).into_response();
}
}
}
Err(res) => {
println!("{}", res);
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", res)).into_response();
}
}
}
Первым шагом генерируем название животного из прилагательного и существительного, затем выбираем подходящую картинку. Следующим шагом пытаемся сохранить все в базе данных. Если при сохранении получаем ошибку, то отдаем ее в ответе:
// ...
Err(res) => {
println!("{}", res);
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", res)).into_response();
}
При успешном сохранении достаем животное из базы через получение последнего сохраненного ID.
let id = res.last_insert_rowid();
let animal = sqlx::query_as::<_, Animal>("SELECT id, name, image, created_at, updated_at FROM animals WHERE id = ?")
.bind(id)
.fetch_one(&state.db)
.await;
И еще раз обрабатываем ошибку. Теперь, при удачном результате отдаем структуру Animal в ответе:
Ok(animal) => {
return (StatusCode::OK, Json(json!(animal))).into_response();
}
Эта структура реализована в отдельном файле models.rs:
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use chrono::{DateTime, Utc};
#[derive(Deserialize, Serialize, FromRow)]
pub struct Animal {
pub id: i64,
pub name: String,
pub image: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
И еще интересно посмотреть, как в axum мы получаем JSON из запроса. Например, вот так это реализовано в методе update.
#[derive(Deserialize, Serialize)]
pub struct UpdateRequest {
pub id: i64
}
pub async fn update(State(state): State<AppState>, Json(json): Json<UpdateRequest>) -> impl IntoResponse {
//...
}
Для этого нужно сделать отдельную структуру UpdateRequest и указать ее в параметрах обработчика через использование дженерика: Json(json): Json<UpdateRequest>.
Фронтенд
Есть готовый сервис, в котором реализовано нужное API. Осталось все это прикрутить к сайту.
Для начала нужно иметь возможность сохранять идентификатор животного в куках. Это нужно для реализации цикла обновления животного для конкретного пользователя, чтобы понимать, что пользователь все еще на сайте. Для этого реализуем простой хелпер для работы с куками:
const API_BASE_URL = 'https://animals.kodikapusta.ru/animals';
const ASSETS_BASE_URL = 'https://animals.kodikapusta.ru/assets';
const COOKIE_NAME = 'animal_id';
const UPDATE_INTERVAL = 10000; // 10 секунд
const LIST_REFRESH_INTERVAL = 30000; // 30 секунд
// Утилиты для работы с куками
const CookieManager = {
setCookie(name, value, days = 365) {
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
const expires = "expires=" + date.toUTCString();
document.cookie = `${name}=${value};${expires};path=/`;
},
getCookie(name) {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for(let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length);
}
return null;
},
deleteCookie(name) {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
}
};
Теперь добавим клиент для работы с нашим API.
// API клиент
const AnimalAPI = {
async createAnimal() {
try {
const response = await fetch(API_BASE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Ошибка при создании животного:', error);
throw error;
}
},
async updateAnimal(id) {
try {
const response = await fetch(API_BASE_URL, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
"id": parseInt(id)
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error(`Ошибка при обновлении животного ${id}:`, error);
throw error;
}
},
async getAllAnimals() {
try {
const response = await fetch(API_BASE_URL);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Ошибка при получении списка животных:', error);
throw error;
}
}
};
И еще один хелпер, который отрисует список животных в нашем DOM-дереве.
// DOM манипуляции
const DOMManager = {
updateAllAnimals(animals, currentAnimalId = null) {
const element = document.getElementById('allAnimalsList');
if (animals && animals.length > 0) {
let html = '<div class="animal-list">';
animals.forEach(animal => {
const isCurrent = currentAnimalId == animal.id;
const animalName = animal.name;
const initials = getInitials(animalName);
html += `
<div class="animal-circle ${isCurrent ? 'current-animal' : ''}" data-id="${animal.id}">
<div class="animal-avatar">
${animal.image
? `<img src="${ASSETS_BASE_URL}/${animal.image}" alt="${animalName}" class="animal-image">`
: `<div class="animal-initials">${initials}</div>`
}
</div>
<div class="animal-tooltip">${animalName}${isCurrent ? ' (вы)' : ''}</div>
</div>
`;
});
html += '</div>';
element.innerHTML = html;
// Добавляем обработчики клика по животным
document.querySelectorAll('.animal-circle').forEach(circle => {
circle.addEventListener('click', function() {
const animalId = this.getAttribute('data-id');
console.log('Выбрано животное:', animalId);
});
});
} else {
element.innerHTML = '<div class="status">Нет активных животных</div>';
}
}
};
Осталось реализовать логику периодических обновлений и запустить все нужные таймеры:
// Основная логика приложения
class AnimalApp {
constructor() {
this.myAnimalId = null;
this.updateInterval = null;
this.listRefreshInterval = null;
}
async init() {
console.log('Инициализация приложения...');
// Получаем ID из куки или создаем новое животное
this.myAnimalId = CookieManager.getCookie(COOKIE_NAME);
if (this.myAnimalId) {
console.log('Найдено животное в куках:', this.myAnimalId);
await this.refreshMyAnimal();
} else {
console.log('Создаем новое животное...');
await this.createNewAnimal();
}
// Загружаем список всех животных
await this.refreshAllAnimals();
// Запускаем таймеры
this.startTimers();
}
async createNewAnimal() {
try {
const animal = await AnimalAPI.createAnimal();
this.myAnimalId = animal.id;
CookieManager.setCookie(COOKIE_NAME, animal.id);
console.log('Создано новое животное:', animal);
} catch (error) {
DOMManager.updateMyAnimalStatus('Ошибка при создании животного', true);
}
}
async refreshMyAnimal() {
try {
const animal = await AnimalAPI.updateAnimal(this.myAnimalId);
console.log('Животное обновлено:', animal);
} catch (error) {
// Если животное не найдено (например, удалено), создаем новое
if (error.message.includes('404')) {
console.log('Животное не найдено, создаем новое...');
CookieManager.deleteCookie(COOKIE_NAME);
await this.createNewAnimal();
} else {
DOMManager.updateMyAnimalStatus('Ошибка при обновлении животного', true);
}
}
}
async refreshAllAnimals() {
try {
const animals = await AnimalAPI.getAllAnimals();
DOMManager.updateAllAnimals(animals, this.myAnimalId);
console.log('Список животных обновлен:', animals.length, 'животных');
} catch (error) {
console.log(error);
}
}
startTimers() {
// Очищаем старые таймеры если есть
if (this.updateInterval) clearInterval(this.updateInterval);
if (this.listRefreshInterval) clearInterval(this.listRefreshInterval);
// Таймер для обновления моего животного
this.updateInterval = setInterval(async () => {
await this.refreshMyAnimal();
}, UPDATE_INTERVAL);
// Таймер для обновления списка всех животных
this.listRefreshInterval = setInterval(async () => {
await this.refreshAllAnimals();
}, LIST_REFRESH_INTERVAL);
console.log('Таймеры запущены');
}
destroy() {
if (this.updateInterval) clearInterval(this.updateInterval);
if (this.listRefreshInterval) clearInterval(this.listRefreshInterval);
console.log('Приложение остановлено');
}
}
// Инициализация приложения при загрузке страницы
document.addEventListener('DOMContentLoaded', () => {
window.animalApp = new AnimalApp();
window.animalApp.init();
});
// Очистка при закрытии страницы
window.addEventListener('beforeunload', () => {
if (window.animalApp) {
window.animalApp.destroy();
}
});
Тут обратите внимание на логику:
this.myAnimalId = CookieManager.getCookie(COOKIE_NAME);
if (this.myAnimalId) {
console.log('Найдено животное в куках:', this.myAnimalId);
await this.refreshMyAnimal();
} else {
console.log('Создаем новое животное...');
await this.createNewAnimal();
}
Новое животное создается только если не смогли ничего найти в куках.
На этом все. Результат работы сервиса можно посмотреть прямо на этой странице.

Ссылки
- Проект на github: github.com/akovardin/anonimus-animals
- Фреймворк axum: github.com/tokio-rs/axum