Автосоставитель отчётов CatWar, пока-что только Речного племени. Форматы отчётов адаптированы из CW:Shed ReiReiRei / Тис (MIT).
// ==UserScript==
// @name CW: Report Builder
// @namespace https://catwar.su/
// @version 1.0.3
// @description Автосоставитель отчётов CatWar, пока-что только Речного племени. Форматы отчётов адаптированы из CW:Shed ReiReiRei / Тис (MIT).
// @author Поблажка 1695136
// @license MIT
// @match *://catwar.su/blog*
// @match *://catwar.net/blog*
// @match *://*.catwar.net/blog*
// @match *://catwar.su/cw3*
// @match *://catwar.net/cw3*
// @match *://*.catwar.net/cw3*
// @grant none
// @run-at document-idle
// ==/UserScript==
(() => {
'use strict';
const BLOGS = { 13664: 'Охрана границ', 13219: 'Чистильщики', 24395: 'Хранители трав', 51844: 'Охотники', 562586: 'Вожатые' };
const blogId = Number(location.pathname.match(/^\/blog(\d+)/)?.[1]);
const isGame = /^\/cw3(?:\/|$)/.test(location.pathname) && !/^\/cw3\/(?:jagd|kns)/.test(location.pathname);
if (!BLOGS[blogId] && !isGame) return;
if (isGame) {
initCleanerHistory();
return;
}
const comment = document.querySelector('#comment');
const anchor = document.querySelector('#send_comment');
if (!comment || !anchor) return;
const css = `
#cwr-panel{--cwr-deep:#173f3c;--cwr-leaf:#2c655c;--cwr-paper:#f3f6f4;position:relative;overflow:hidden;margin:16px 0;padding:20px;border:1px solid rgba(255,255,255,.5);border-radius:16px;color:#f7fbf8;background:linear-gradient(125deg,rgba(20,61,57,.98),rgba(48,103,93,.96));box-shadow:0 8px 26px rgba(18,42,38,.25);font:13px Verdana,sans-serif}
#cwr-panel:before{content:'';position:absolute;inset:-60px -80px auto auto;width:230px;height:230px;border-radius:50%;background:rgba(210,241,224,.12);filter:blur(2px)} #cwr-panel>*{position:relative} #cwr-panel h3{margin:0 0 14px;font:700 18px Georgia,serif;color:#fff;text-shadow:0 1px 2px #102b28}
#cwr-panel label{display:block;margin:8px 0 4px;font-weight:600;color:#edf7f2} #cwr-panel input,#cwr-panel select,#cwr-panel textarea{box-sizing:border-box;width:100%;max-width:100%;margin-top:4px;padding:7px 9px;color:#1a302d;background:rgba(255,255,255,.94);border:1px solid rgba(255,255,255,.8);border-radius:8px;box-shadow:inset 0 1px 1px rgba(0,0,0,.08)}
#cwr-panel textarea{min-height:82px;resize:vertical} #cwr-panel small{display:block;margin-top:8px;color:#d9eae3;line-height:1.45} #cwr-panel .cwr-row{display:flex;gap:10px;flex-wrap:wrap;align-items:end} #cwr-panel .cwr-row>label{flex:1;min-width:135px}
#cwr-panel button{margin-top:12px;padding:7px 12px;color:#fff;background:linear-gradient(135deg,#1b4e48,#2e7468);border:1px solid rgba(255,255,255,.42);border-radius:18px;box-shadow:0 2px 5px rgba(0,0,0,.18);font:700 13px Georgia,serif;cursor:pointer;transition:transform .15s,background .15s} #cwr-panel button:hover{background:linear-gradient(135deg,#255f57,#398678);transform:translateY(-1px)}
#cwr-panel .cwr-tab{margin:0 5px 13px 0;padding:6px 14px;background:rgba(255,255,255,.16)} #cwr-panel .cwr-form{padding:2px 0} #cwr-panel .cwr-form[hidden]{display:none} #cwr-status{display:inline-block;margin:10px 0 0 8px;color:#fff4bd;font-style:italic}
#cwr-watch-result{margin-top:10px;padding:10px;background:rgba(255,255,255,.12);border-radius:8px;line-height:1.6;white-space:pre-wrap;display:none}`;
document.head.append(Object.assign(document.createElement('style'), { textContent: css }));
const esc = (v) => String(v).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"', "'":''' }[c]));
const localDate = date => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const today = () => localDate(new Date());
const nowTime = () => { const d = new Date(); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; };
const nearestBorderPatrol = (now = new Date()) => {
const slots = [9, 11, 15, 18, 21, 23];
for (let day = 0; day < 2; day++) {
for (const hour of slots) {
const candidate = new Date(now);
candidate.setDate(candidate.getDate() + day);
candidate.setHours(hour, 0, 0, 0);
if (candidate >= now) return { date: localDate(candidate), time: `${String(hour).padStart(2, '0')}:00` };
}
}
};
const dateRu = (s, short = false) => { const [y,m,d] = (s || today()).split('-'); return `${d}.${m}.${short ? y.slice(-2) : ''}`.replace(/\.$/, ''); };
const dateTimeRu = (dateStr, timeStr) => `${dateRu(dateStr)}, ${timeStr}`;
const idMask = id => `[cat${id}] [${id}]`;
const ownId = () => {
const el = panel.querySelector('[name=myid]');
return el ? Number(el.value) : 0;
};
const saveId = () => { const id = ownId(); if (id) localStorage.setItem('cwr_my_id', id); return id; };
const put = text => { comment.value += comment.value.trim() ? `\n\n${text}` : text; comment.focus(); comment.scrollIntoView({ block: 'center', behavior: 'smooth' }); };
const status = text => { const el = panel.querySelector('#cwr-status'); if (el) el.textContent = text; };
const resolve = async value => {
const name = value.trim();
if (/^\d+$/.test(name)) return { name: '', id: name };
try {
const r = await fetch('/ajax/top_cat', {
method: 'POST',
headers: { 'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8' },
body: `name=${encodeURIComponent(name)}`,
credentials: 'same-origin'
});
if (!r.ok) return { name, id: null };
const id = (await r.text()).trim();
return /^\d+$/.test(id) ? { name, id } : { name, id: null };
} catch (e) {
console.error('resolve error:', e);
return { name, id: null };
}
};
const cleanCatName = value => String(value || '')
.replace(/^\s*Профиль игрока\s+/i, '')
.replace(/\s*[\/|—–\-]\s*(?:CatWar|Кэтвар)\b[\s\S]*$/i, '')
.trim();
const leaderNameCache = new Map();
const resolveLeader = async () => {
const id = saveId();
if (!id) return { id: null, name: 'Некорректный ID' };
if (leaderNameCache.has(id)) return { id, name: leaderNameCache.get(id) };
try {
const html = await (await fetch(`/cat${id}`, { credentials: 'same-origin' })).text();
const doc = new DOMParser().parseFromString(html, 'text/html');
// Настоящее имя на странице профиля CatWar лежит в .profile-text > big.
const domName = doc.querySelector('.profile-text big, .profile-text .big, [itemprop="name"], .cat_name, #cat_name')?.textContent.trim();
const heading = [...doc.querySelectorAll('#main h1, #content h1, h1')]
.map(node => node.textContent.trim())
.find(name => name && !/CatWar|Профиль|Анкета/i.test(name));
const name = cleanCatName(domName || heading || doc.title) || `Кот ${id}`;
leaderNameCache.set(id, name);
return { id, name };
} catch {
return { id, name: `Кот ${id}` };
}
};
const resolveKittenLines = async raw => {
const lines = raw.split(';').map(line => line.trim()).filter(Boolean), result = [], bad = [];
for (const line of lines) {
const match = line.match(/^(.*?)\s+(\d+(?:-\d+)?)\s+(ПУ|ЛУ|ЗУ)$/iu);
if (!match) { bad.push(line); continue; }
const [, name, gain, skill] = match;
const cat = await resolve(name);
if (cat.id) result.push(`${name} [${cat.id}] — ${gain} ${skill}`); else bad.push(name);
}
return { result, bad };
};
const resolveTaggedNames = async raw => {
const names = raw.split(',').map(name => name.trim()).filter(Boolean), result = [], bad = [];
for (const item of names) {
const match = item.match(/^(.*?)\s*\((наблюдатель|север|ветер)\)$/iu);
const name = (match ? match[1] : item).trim(), tag = match ? ` (${match[2]})` : '';
const cat = await resolve(name);
if (cat.id) result.push(`${name} [${cat.id}]${tag}`); else bad.push(name);
}
return { result, bad };
};
const resolveList = async (raw, numbered = false) => {
const entries = raw.split(/[\n,]/).map(x => x.trim()).filter(Boolean);
const bad = [], good = [];
for (const entry of entries) {
const m = numbered ? entry.match(/^(.*?)(?:\s+\(?([0-9]+)\)?)?$/) : null;
const name = numbered ? m[1].trim() : entry;
const count = numbered ? (m[2] || '5') : '';
const got = await resolve(name);
if (got.id) good.push(`${got.name || name} [${got.id}]${numbered ? ` (${count})` : ''}`); else bad.push(name);
}
return { good, bad };
};
const panel = document.createElement('section');
panel.id = 'cwr-panel';
panel.innerHTML = `<h3>Составитель отчётов — ${BLOGS[blogId]}</h3><div class="cwr-row"><label>Ваш ID<input name="myid" inputmode="numeric" pattern="[0-9]+" value="${esc(localStorage.getItem('cwr_my_id') || '')}" placeholder="123456"></label></div><div id="cwr-forms"></div><span id="cwr-status"></span>`;
anchor.append(panel);
const forms = panel.querySelector('#cwr-forms');
const form = (id, html) => { forms.insertAdjacentHTML('beforeend', `<div class="cwr-form" data-form="${id}">${html}</div>`); return forms.querySelector(`[data-form="${id}"]`); };
const button = (root, label, handler) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.addEventListener('click', handler); root.append(b); };
const buildSentryForm = (host, searchBlogId) => {
const sentry = form('watch', `
<div class="cwr-row">
<label>Тип отчёта
<select name="sentry-type">
<option value="start">Начало дозора</option>
<option value="end">Окончание дозора</option>
</select>
</label>
</div>
<div class="cwr-row">
<label>Дата начала<input name="start-date" type="date" value="${today()}"></label>
<label>Время начала<input name="start-time" type="time" value="${nowTime()}"></label>
</div>
<div class="cwr-row" id="sentry-end-row">
<label>Дата конца<input name="end-date" type="date" value="${today()}"></label>
<label>Время конца<input name="end-time" type="time" value="${nowTime()}"></label>
</div>
<label>Место дозора (локация или маршрут)<input name="sentry-place" placeholder="Травянистый берег / 1 маршрут"></label>
<small>Имя участника подставляется автоматически из поля «Ваш ID» выше.</small>
`);
const sentryType = sentry.querySelector('[name=sentry-type]');
const endRow = sentry.querySelector('#sentry-end-row');
const toggleEndRow = () => { endRow.hidden = sentryType.value !== 'end'; };
sentryType.addEventListener('change', toggleEndRow);
toggleEndRow();
button(sentry, 'Составить отчёт', async () => {
const type = sentryType.value;
const startDate = sentry.querySelector('[name=start-date]').value;
const startTime = sentry.querySelector('[name=start-time]').value;
const place = sentry.querySelector('[name=sentry-place]').value.trim();
if (!place) return status('Укажите место дозора.');
status('Нахожу имя участника…');
const leader = await resolveLeader();
const participant = `${leader.name} [${leader.id || 'Некорректный ID'}]`;
const startStr = dateTimeRu(startDate, startTime);
let text;
if (type === 'start') {
text =
`[u][b]Дозор[/b][/u]\n` +
`[b]Дата и время начала:[/b] ${startStr};\n` +
`[b]Занятое место:[/b] ${place};\n` +
`[b]Участник:[/b] ${participant}.`;
} else {
const endDate = sentry.querySelector('[name=end-date]').value;
const endTime = sentry.querySelector('[name=end-time]').value;
const endStr = dateTimeRu(endDate, endTime);
text =
`[u][b]Дозор[/b][/u]\n` +
`[b]Дата и время начала:[/b] ${startStr};\n` +
`[b]Дата и время конца:[/b] ${endStr};\n` +
`[b]Место дозора:[/b] ${place};\n` +
`[b]Участник:[/b] ${participant}.`;
}
put(text);
status('Готово');
});
const watchResultBox = document.createElement('div');
watchResultBox.id = 'cwr-watch-result';
sentry.append(watchResultBox);
const parseReports = text => {
const reports = [];
const re = /Дозор[\s\S]{0,700}?Участник:\s*([^\n.]+?)\s*\./gi;
let m;
while ((m = re.exec(text)) !== null) {
const block = m[0];
const startM = block.match(/Дата и время начала:\s*([^;\n]+)/i);
const endM = block.match(/Дата и время конца:\s*([^;\n]+)/i);
const placeM = block.match(/(?:Занятое место|Место дозора):\s*([^;\n.]+)/i);
const participant = m[1].trim();
const idMatch = participant.match(/\[(\d+)\]/);
reports.push({
participant,
pid: idMatch ? idMatch[1] : participant.toLowerCase(),
startTime: startM ? startM[1].trim() : '',
place: placeM ? placeM[1].trim() : '',
hasEnd: !!endM
});
}
return reports;
};
button(sentry, '🔍 Найти дозорных в блоге', async () => {
status('Загружаю комментарии блога…');
watchResultBox.style.display = 'block';
watchResultBox.textContent = 'Загружаю…';
try {
const texts = [];
texts.push(document.body.innerText || document.body.textContent || '');
for (let page = 2; page <= 8; page++) {
let resp;
try { resp = await fetch(`/blog${searchBlogId}?page=${page}`, { credentials: 'same-origin' }); }
catch { break; }
if (!resp.ok) break;
const html = await resp.text();
if (/id="form"|name="pass"|action="login"/.test(html)) break;
const doc = new DOMParser().parseFromString(html, 'text/html');
const pageText = doc.body ? (doc.body.textContent || '') : '';
if (!/Дозор/i.test(pageText)) break;
texts.push(pageText);
}
const all = [];
texts.forEach(t => all.push(...parseReports(t)));
const seen = new Set();
const reports = all.filter(r => {
const key = `${r.pid}|${r.startTime}|${r.hasEnd}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const ended = new Set(reports.filter(r => r.hasEnd).map(r => `${r.pid}|${r.startTime}`));
const active = reports.filter(r => !r.hasEnd && !ended.has(`${r.pid}|${r.startTime}`));
if (!reports.length) {
watchResultBox.textContent = 'Отчётов о дозоре в блоге не найдено. Откройте нужную страницу блога и повторите.';
} else if (!active.length) {
watchResultBox.textContent = 'Активных дозорных не найдено — все смены закрыты.';
} else {
const lines = active.map(s => `• ${s.participant}\n Начало: ${s.startTime || '—'}\n Место: ${s.place || '—'}`);
watchResultBox.textContent = `Активные дозорные (${active.length}):\n\n${lines.join('\n\n')}`;
}
status('Поиск завершён');
} catch (e) {
watchResultBox.textContent = `Ошибка при загрузке: ${e.message}`;
status('Ошибка');
}
});
return sentry;
};
if (blogId === 13664) {
forms.insertAdjacentHTML('afterbegin', '<button class="cwr-tab" data-tab="watch">Дозор</button><button class="cwr-tab" data-tab="patrol">Патруль</button>');
const watch = buildSentryForm(forms, blogId);
const nextPatrol = nearestBorderPatrol();
const patrol = form('patrol', `<div class="cwr-row"><label>Маршрут<select name="route"><option value="">Выберите</option><option>1</option><option>2</option></select></label><label>Дата<input name="date" type="date" value="${nextPatrol.date}"></label><label>Время<input name="time" type="time" value="${nextPatrol.time}" step="3600"></label></div><small>Автоматически выбрано ближайшее время пограничного патруля: ${nextPatrol.time}.</small><label>Участники через запятую<textarea name="members" placeholder="Синяя Звезда, Огонёк"></textarea></label>`);
patrol.hidden = true;
panel.querySelectorAll('.cwr-tab').forEach(b => b.onclick = () => { watch.hidden = b.dataset.tab !== 'watch'; patrol.hidden = b.dataset.tab !== 'patrol'; });
button(patrol, 'Составить отчёт', async () => { status('Ищу ID участников…'); const id = saveId(), got = await resolveList(patrol.querySelector('[name=members]').value); const hour = Number(patrol.querySelector('[name=time]').value.slice(0,2)); let text = `[u][b]Патруль[/b][/u]\n[b]Дата и время:[/b] ${dateRu(patrol.querySelector('[name=date]').value)}, ${String(hour).padStart(2,'0')}:00;\n[b]Маршрут:[/b] ${patrol.querySelector('[name=route]').value || 'Не выбран маршрут'};\n[b]Ведущий:[/b] ${id ? idMask(id) : 'Некорректный ID ведущего'};\n[b]Участники:[/b] ${got.good.join(', ') || '-'}.`; if (got.bad.length) text += `\n! ! ! Не найдены: ${got.bad.join(', ')}.`; put(text); status('Готово'); });
}
if (blogId === 24395) {
const herbHour = new Date().getHours(), herbDefault = herbHour >= 17 ? 'мховник' : herbHour >= 16 ? 'травник' : 'веточник';
const f = form('herbs', `<div class="cwr-row"><label>Дата<input name="date" type="date" value="${today()}"></label><label>Тип травника<select name="type"><option${herbDefault === 'веточник' ? ' selected' : ''}>веточник</option><option${herbDefault === 'травник' ? ' selected' : ''}>травник</option><option${herbDefault === 'мховник' ? ' selected' : ''}>мховник</option></select></label></div><label>Локации и участники<textarea name="locations" placeholder="Дубрава: Шкодочка\nОстров туманов: Марина\nПлакучая ива, Скользкие камни: Созвездие Колоса (наблюдатель)\nИлистая тропа: Голубчик (север)"></textarea></label><small>Тип выбирается по времени (12:00 — веточник, 16:00 — травник, 17:00 — мховник). Одна локация на строку: <b>локация: имена через запятую</b>. Для отметки добавляйте после имени: <i>(наблюдатель)</i>, <i>(север)</i> или <i>(ветер)</i>. ID подставятся автоматически.</small>`);
button(f, 'Составить отчёт', async () => { const rows = f.querySelector('[name=locations]').value.split('\n').map(row => row.trim()).filter(Boolean).map(row => { const i = row.indexOf(':'); return i > 0 ? { location: row.slice(0, i).trim(), names: row.slice(i + 1).trim() } : null; }); if (!rows.length || rows.includes(null)) return status('Заполняйте строки в формате «Дубрава: Шкодочка».'); status('Ищу ID участников…'); const lines = [], bad = []; for (const row of rows) { const people = await resolveTaggedNames(row.names); lines.push(`[b]${row.location}:[/b] ${people.result.join(', ') || '—'};`); bad.push(...people.bad); } let text = `[b]Дата:[/b] ${dateRu(f.querySelector('[name=date]').value)};\n[b]Тип травника:[/b] ${f.querySelector('[name=type]').value};\n${lines.join('\n')}`; if (bad.length) text += `\n! ! ! Не найдены: ${bad.join(', ')}.`; put(text); status('Готово'); });
}
if (blogId === 51844) {
forms.insertAdjacentHTML('afterbegin', '<button class="cwr-tab" data-tab="hunt-patrol">Патрульная охота</button><button class="cwr-tab" data-tab="hunt-free">Свободная охота</button>');
const patrol = form('hunt-patrol', `<div class="cwr-row"><label>Дата<input name="date" type="date" value="${today()}"></label><label>Вид<select name="type"><option>утренняя</option><option>вечерняя</option></select></label><label>Место<select name="place"><option>шелестящий тростник</option><option>пруд ужей</option><option>галечный берег</option><option>редколесье</option></select></label></div><label>Участники: имя и число добычи<textarea name="members" placeholder="Синяя Звезда 5, Огонёк 4"></textarea></label><label>Таскающие через запятую<textarea name="carriers" placeholder="Крутобок, Белка"></textarea></label><label>Север через запятую<textarea name="north" placeholder="Голубчик, Северное Сияние"></textarea></label><label>Ветер через запятую<textarea name="wind" placeholder="Ветролап, Пушинка"></textarea></label>`);
const free = form('hunt-free', `<div class="cwr-row"><label>Дата<input name="date" type="date" value="${today()}"></label><label>Вид<select name="kind"><option>свободная</option><option>на мышей</option></select></label><label>Количество добычи<input name="count" type="number" min="5" value="5"></label></div><label>История охоты<textarea name="history" placeholder="Скопируйте историю: приход на локацию, поднятие и опускание всей добычи."></textarea></label><small>Ведущий автоматически берётся из поля «Ваш ID» выше. Для отчёта нужно не меньше пяти единиц добычи.</small>`);
free.hidden = true;
panel.querySelectorAll('.cwr-tab').forEach(tab => tab.onclick = () => { patrol.hidden = tab.dataset.tab !== 'hunt-patrol'; free.hidden = tab.dataset.tab !== 'hunt-free'; });
button(patrol, 'Составить отчёт', async () => { status('Ищу ID участников…'); const id = saveId(), members = await resolveList(patrol.querySelector('[name=members]').value, true), carriers = await resolveList(patrol.querySelector('[name=carriers]').value), north = await resolveList(patrol.querySelector('[name=north]').value), wind = await resolveList(patrol.querySelector('[name=wind]').value); let text = `[b][u]Дата[/u]:[/b] ${dateRu(patrol.querySelector('[name=date]').value, true)};\n[b]Вид:[/b] ${patrol.querySelector('[name=type]').value};\n[b]Место охоты:[/b] ${patrol.querySelector('[name=place]').value};\n[b]Ведущий:[/b] ${id ? idMask(id) : 'Некорректный ID'} (5);\n[b]Участники:[/b] ${members.good.join(', ') || '-'};\n[b]Таскающие:[/b] ${carriers.good.join(', ') || '-'};\n[b]Север:[/b] ${north.good.join(', ') || '-'};\n[b]Ветер:[/b] ${wind.good.join(', ') || '-'};`; const bad = [...members.bad, ...carriers.bad, ...north.bad, ...wind.bad]; if (bad.length) text += `\n! ! ! Не найдены: ${bad.join(', ')}.`; put(text); status('Готово'); });
button(free, 'Составить отчёт', async () => { const count = Number(free.querySelector('[name=count]').value); if (!Number.isFinite(count) || count < 5) return status('Для свободной охоты укажите не менее 5 единиц добычи.'); status('Нахожу имя ведущего…'); const leader = await resolveLeader(), date = dateRu(free.querySelector('[name=date]').value), history = free.querySelector('[name=history]').value.trim(); let text = `[b][u]Дата[/u]:[/b] ${date};\n[b]Вид:[/b] ${free.querySelector('[name=kind]').value};\n[b]Участник:[/b] ${leader.name} [${leader.id || 'Некорректный ID'}] (${count});\n[header=${leader.name}]История[/header][block=${leader.name}]${history}[/block].`; put(text); status('Готово'); });
}
if (blogId === 562586) {
forms.insertAdjacentHTML('afterbegin',
'<button class="cwr-tab" data-tab="dive">Ныряние</button>' +
'<button class="cwr-tab" data-tab="willow">Посещение Ивы</button>'
);
// ── НЫРЯНИЕ ────────────────────────────────────────────────────────────
const dive = form('dive', `<label>Дата<input name="date" type="date" value="${today()}"></label><label>Заходы<textarea name="dives" placeholder="16:30 | Ленивенький 5-6 ПУ; Соловьюшка 5 ПУ\n17:45 | Ленивенький 6 ПУ"></textarea></label><small>Вожатому достаточно указать ID в поле «Ваш ID» выше. Один заход на строку: <b>время | имя котёнка прирост ПУ; …</b>. Баллы за вожатого рассчитываются автоматически.</small>`);
// ── ПОСЕЩЕНИЕ ИВЫ ─────────────────────────────────────────────────────
const willow = form('willow', `<label>Тип котят<select name="kind"><option value="experienced">опытные</option><option value="dependent">несамостоятельные</option></select></label><div class="cwr-row"><label>Время начала<input name="start" type="time"></label><label>Время ухода<input name="end" type="time"></label></div><label>Котята<textarea name="kittens" placeholder="Медолапка 3-7 ЛУ\nОгонёк 2-5 ЗУ"></textarea></label><label>Ссылка на скриншот истории (необязательно)<input name="screenshot" type="url" placeholder="https://…"></label><small>Вожатому достаточно указать ID выше. Баллы за вожатого: 1 за минуту с опытными котятами или 3 за минуту с несамостоятельными.</small>`);
willow.hidden = true;
panel.querySelectorAll('.cwr-tab').forEach(tab => tab.onclick = () => {
dive.hidden = tab.dataset.tab !== 'dive';
willow.hidden = tab.dataset.tab !== 'willow';
});
const leaderLine = async () => { const leader = await resolveLeader(); return `${leader.name} [${leader.id || 'Некорректный ID'}]`; };
button(dive, 'Составить отчёт', async () => {
const rows = dive.querySelector('[name=dives]').value.split('\n').map(row => row.trim()).filter(Boolean).map(row => { const match = row.match(/^([0-2]?\d:[0-5]\d)\s*\|\s*(.+)$/); return match ? { time: match[1], kittens: match[2].split(';').map(x => x.trim()).filter(Boolean) } : null; });
if (!rows.length || rows.includes(null)) return status('Заполните каждый заход в формате «16:30 | Ленивенький 5-6 ПУ».');
status('Ищу имена и ID котят…'); const leader = await leaderLine(); let bad = [];
const blocks = []; for (let index = 0; index < rows.length; index++) { const kittens = await resolveKittenLines(rows[index].kittens.join(';')); bad.push(...kittens.bad); const count = kittens.result.length; const base = Math.min(70, 30 + count * 5); const bonus = index === 0 ? 0 : index === 1 ? 10 : index === 2 ? 15 : index === 3 ? 20 : 25; const score = base + bonus; blocks.push(`${rows[index].time}\n${leader} — ${score} баллов за ныряние с котятами${bonus ? ` (+${bonus})` : ''}\n${kittens.result.join('\n')}`); }
const [year, month, day] = dive.querySelector('[name=date]').value.split('-');
let report = rows.length === 1 ? blocks[0].replace(/^.*?\n/, '') : `${day}.${month}.${year}\n${blocks.join('\n\n')}`; if (bad.length) report += `\n\n! ! ! Не найдены или неверно записаны: ${bad.join(', ')}.`;
put(report); status('Готово');
});
button(willow, 'Составить отчёт', async () => {
const start = willow.querySelector('[name=start]').value, end = willow.querySelector('[name=end]').value; if (!start || !end) return status('Укажите время начала и ухода.');
const toMinutes = value => { const [h,m] = value.split(':').map(Number); return h * 60 + m; }; let minutes = toMinutes(end) - toMinutes(start); if (minutes < 0) minutes += 24 * 60;
status('Ищу имена и ID котят…'); const dependent = willow.querySelector('[name=kind]').value === 'dependent'; const score = minutes * (dependent ? 3 : 1); const kittens = await resolveKittenLines(willow.querySelector('[name=kittens]').value.replace(/\n/g, ';')); const screenshot = willow.querySelector('[name=screenshot]').value.trim();
let report = `${await leaderLine()} — ${score} баллов за посещение Ивы с ${dependent ? 'несамостоятельными' : 'опытными'} котятами\n\n${start} — ${end}\n${kittens.result.join('\n') || '—'}`; if (screenshot) report += `\n[Скриншот истории](${screenshot})`; if (kittens.bad.length) report += `\n! ! ! Не найдены или неверно записаны: ${kittens.bad.join(', ')}.`; put(report); status(`Готово: ${minutes} мин., ${score} баллов.`);
});
}
function initCleanerHistory() {
const storageKey = 'cwr_cleaner_history_log';
const optionsKey = 'cwr_cleaner_options';
const defaults = { enabled: true, id: true, underline: false, title: true, status: false, location: true, action: false };
const options = { ...defaults, ...JSON.parse(localStorage.getItem(optionsKey) || '{}') };
const saveOptions = () => localStorage.setItem(optionsKey, JSON.stringify(options));
const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({ '&':'&','<':'<','>':'>','"':'"', "'":''' }[char]));
const titles = new Map(), statuses = new Map();
const titleForms = {
'Котёнок':'котёнка','Оруженосец':'оруженосца','Ученица целителя':'ученицу целителя','Ученик целителя':'ученика целителя','Целительница':'целительницу','Целитель':'целителя','Воительница':'воительницу','Воитель':'воителя','Старшая воительница':'старшую воительницу','Старший воитель':'старшего воителя','Старейшина':'старейшину','Глашатая':'глашатую','Глашатай':'глашатая','Предводительница':'предводительницу','Предводитель':'предводителя','Стражница':'стражницу','Страж':'стража','Охотница':'охотницу','Охотник':'охотника','Королева':'королеву','Воин':'воина','Старший воин':'старшего воина','Ученица':'ученицу','Ученик':'ученика'
};
const waitForHistory = () => {
const history = document.querySelector('#ist');
const block = document.querySelector('#history_block');
if (!history || !block) return setTimeout(waitForHistory, 500);
if (document.querySelector('#cwr-cleaner-widget')) return;
addCleanerStyle();
const widget = document.createElement('section');
widget.id = 'cwr-cleaner-widget';
widget.innerHTML = `<div class="cwr-cleaner-head"><b>История чистки</b><span><button type="button" data-action="copy">Копировать</button><button type="button" data-action="clear">Очистить</button><button type="button" data-action="settings">⚙ Настройки</button></span></div><div id="cwr-cleaner-log"></div><div class="cwr-cleaner-settings" hidden><label><input type="checkbox" data-option="enabled"> Вести историю</label><label><input type="checkbox" data-option="id"> ID кота</label><label><input type="checkbox" data-option="title"> Должность</label><label><input type="checkbox" data-option="status"> Статус при поднятии</label><label><input type="checkbox" data-option="location"> Локация</label><label><input type="checkbox" data-option="underline"> Подчёркивать «Поднял/Опустил»</label><label><input type="checkbox" data-option="action"> Проверка на действие</label><small>Записи и настройки хранятся только в браузере.</small></div>`;
(document.querySelector('#history_clean') || block).insertAdjacentElement('afterend', widget);
const log = widget.querySelector('#cwr-cleaner-log');
log.innerHTML = localStorage.getItem(storageKey) || 'История очищена.';
widget.querySelectorAll('[data-option]').forEach(input => { input.checked = options[input.dataset.option]; input.addEventListener('change', () => { options[input.dataset.option] = input.checked; saveOptions(); }); });
widget.addEventListener('click', async event => {
const action = event.target.dataset.action;
if (action === 'settings') widget.querySelector('.cwr-cleaner-settings').hidden = !widget.querySelector('.cwr-cleaner-settings').hidden;
if (action === 'clear') { if (confirm('Очистить историю чистки?')) { log.textContent = 'История очищена.'; localStorage.setItem(storageKey, log.innerHTML); } }
if (action === 'copy') { const text = log.innerText; try { await navigator.clipboard.writeText(text); event.target.textContent = 'Скопировано'; setTimeout(() => event.target.textContent = 'Копировать', 1200); } catch { alert('Не удалось скопировать. Выделите текст истории вручную.'); } }
});
const rememberCat = node => {
if (!(node instanceof Element)) return;
const link = node.querySelector('a[href*="cat"]');
const id = link?.getAttribute('href')?.match(/cat(\d+)/)?.[1];
if (!id) return;
const title = node.innerHTML.match(/<div><small><i>([^<]+)<\/i><\/small><\/div>/i)?.[1]?.trim();
if (title) titles.set(id, title);
let state = node.querySelector('.online')?.textContent.trim();
if (node.querySelector('div[style*="costume/295."]')) state = '[ В подстилках ]';
if (state) statuses.set(id, state);
};
const cages = document.querySelector('#cages');
if (cages) new MutationObserver(records => records.forEach(record => record.removedNodes.forEach(rememberCat))).observe(cages, { childList: true, subtree: true });
let previous, beforePrevious, lastHtml = history.innerHTML;
const recordHistory = () => {
const html = history.innerHTML;
if (html === lastHtml) return;
lastHtml = html;
const lastRaw = html.split('.').slice(-2, -1)[0];
if (!lastRaw) return;
const id = lastRaw.match(/cat(\d+)/)?.[1];
const message = lastRaw.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
if (!options.enabled || !id || !/^(?:Поднял|Подняла|Опустил|Опустила)\b/.test(message) || !/\bкот[ау]\b/.test(message)) { beforePrevious = previous; previous = message; return; }
const pickup = /^Поднял|^Подняла/.test(message);
let entry = escapeHtml(message);
if (options.title && titles.has(id)) entry = entry.replace(/\bкота\b|\bкошку\b/g, titleForms[titles.get(id)] || titles.get(id));
if (options.underline) entry = entry.replace(/^(Поднял(?:а)?|Опустил(?:а)?)/, '<u>$1</u>');
if (options.id) entry += ` (${id})`;
if (options.status && pickup && statuses.has(id)) entry += ` ${escapeHtml(statuses.get(id))}`;
if (options.location) { const place = document.querySelector('#location')?.textContent.trim(); if (place && place !== '[ Загружается… ]') entry += ` в локации «${escapeHtml(place)}»`; }
if (options.action && pickup && previous?.includes('Отменил') && beforePrevious?.includes('по имени') && !/Поднял|Опустил/.test(beforePrevious)) { const current = message.match(/ по имени ([А-Яа-яЁё ]+)/)?.[1]?.trim(); const checked = beforePrevious.match(/ по имени ([А-Яа-яЁё ]+)/)?.[1]?.trim(); if (current && current === checked) { const noun = message.includes('кошку') ? 'кошку' : 'кота'; const verb = message.startsWith('Подняла') ? 'Проверила' : 'Проверил'; entry = `${verb} ${noun} по имени ${escapeHtml(checked)}. ${entry}`; } }
log.insertAdjacentHTML('beforeend', ` ${entry}.`);
localStorage.setItem(storageKey, log.innerHTML);
beforePrevious = previous; previous = message;
};
new MutationObserver(recordHistory).observe(history, { childList: true, subtree: true, characterData: true });
};
const addCleanerStyle = () => {
if (document.querySelector('#cwr-cleaner-style')) return;
const style = document.createElement('style'); style.id = 'cwr-cleaner-style'; style.textContent = `#cwr-cleaner-widget{margin:12px 0;padding:10px 12px;border:1px solid #78998c;border-radius:10px;background:linear-gradient(135deg,#eff7f1,#dbece1);color:#1c352d;font:13px Verdana,sans-serif} .cwr-cleaner-head{display:flex;gap:8px;align-items:center;justify-content:space-between;flex-wrap:wrap}.cwr-cleaner-head b{font:700 16px Georgia,serif}.cwr-cleaner-head button{margin-left:4px;padding:3px 7px;border:1px solid #698d7d;border-radius:8px;background:#356c5a;color:#fff;cursor:pointer}#cwr-cleaner-log{margin-top:8px;line-height:1.55}.cwr-cleaner-settings{margin-top:10px;padding-top:8px;border-top:1px solid #9bb8aa}.cwr-cleaner-settings label{display:inline-block;margin:3px 10px 3px 0}.cwr-cleaner-settings small{display:block;margin-top:5px;color:#506d60}`; document.head.append(style);
};
waitForHistory();
}
})();