CW: Report Builder

Автосоставитель отчётов CatWar, пока-что только Речного племени. Форматы отчётов адаптированы из CW:Shed ReiReiRei / Тис (MIT).

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;', "'":'&#39;' }[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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;', "'":'&#39;' }[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();
  }
})();