// ==UserScript==
// @name HeroesWM Clan Crafters v3 + sort (Draggable, Resizable Table – Last Column Drag)
// @namespace http://tampermonkey.net/
// @version 5.9
// @description Парсинг крафтеров с сортировкой, выбором режима и изменяемой по высоте таблицей. Заголовок состоит из 6 столбцов (последний – с кнопкой закрытия). В теле таблицы весь последний столбец (ячейки (1,6), (2,6), …) является областью для перетаскивания, которая позволяет перемещать таблицу без резких скачков.
// @author Your Name
// @include /^https{0,1}:\/\/(www|my|mirror)\.(heroeswm|178\.248\.235\.15|lordswm)\.(ru|com)\/(clan_info)\.php*
// @grant GM_addStyle
// @grant GM_log
// @connect heroeswm.ru
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const CLAN_ID = new URL(window.location.href).searchParams.get('id');
const STORAGE_KEY = `heroeswm-crafters-${CLAN_ID}`;
const isMobile = window.matchMedia('(max-width: 768px)').matches;
// Переменные для сортировки
let currentData = null;
let currentSortKey = null;
let currentSortDirection = 'asc';
GM_addStyle(`
/* Контейнер таблицы */
#craftersTable {
position: fixed;
left: 10px;
top: 50%;
transform: translateY(-50%);
background: white;
border: 2px solid #8b4513;
z-index: 9999;
padding: 0;
box-shadow: 3px 3px 5px rgba(0,0,0,0.3);
max-width: calc(100vw - 20px);
max-height: calc(100vh - 20px);
overflow: auto;
}
/* Ресайз-хэндлы для изменения высоты */
.resize-handle {
height: 5px;
width: 100%;
cursor: ns-resize;
background: transparent;
}
.resize-handle:hover {
background: rgba(139,69,19,0.2);
}
/* Таблица внутри контейнера */
#craftersTable table {
border-collapse: collapse;
font-size: 12px;
width: 100%;
}
#craftersTable th, #craftersTable td {
border: 1px solid #8b4513;
padding: 4px 8px;
text-align: center;
word-break: break-word;
}
#craftersTable th {
background-color: #deb887;
cursor: pointer;
}
/* Панель управления */
.anvil-btn {
cursor: pointer;
background: #deb887;
padding: 5px 10px;
border: 2px solid #8b4513;
border-radius: 3px;
margin: 2px;
display: inline-flex;
align-items: center;
font-size: 14px;
}
.buttons-container {
display: flex;
gap: 5px;
margin: 10px 0;
justify-content: center;
flex-wrap: wrap;
}
.progress {
background: #deb887;
color: black;
padding: 5px 10px;
border: 2px solid #8b4513;
border-radius: 3px;
font-size: 14px;
display: inline-flex;
align-items: center;
}
/* Заголовок таблицы – одна строка с 6 столбцами.
Последний столбец содержит только кнопку закрытия */
thead tr {
height: 30px;
line-height: 30px;
user-select: none;
background: #deb887;
}
th.close-btn {
width: 30px;
height: 30px;
padding: 0;
margin: 0;
cursor: pointer;
background: #deb887;
border: 2px solid #8b4513;
border-radius: 3px;
font-size: 16px;
text-align: center;
vertical-align: middle;
}
/* Тело таблицы: каждая строка имеет 6 ячеек; последний столбец пустой */
@media (max-width: 768px) {
#craftersTable {
position: relative;
left: auto;
top: auto;
transform: none;
margin: 10px auto;
width: 100%;
max-width: 100%;
}
}
`);
function findClanBalanceContainer() {
return document.querySelector('td:has(img[src*="gold.gif"])')?.closest('tr') || document.body;
}
// Создаем панель управления
const buttonsContainer = document.createElement('div');
buttonsContainer.className = 'buttons-container';
const viewBtn = createButton('⚒', 'Показать/скрыть таблицу', 'viewBtn');
const parseBtn = createButton('↺', 'Запустить парсинг', 'parseBtn');
// Чекбокс для режима последовательного выполнения (по умолчанию не отмечен = параллельно)
const seqLabel = document.createElement('label');
seqLabel.className = 'anvil-btn';
seqLabel.style.userSelect = 'none';
const sequentialCheckbox = document.createElement('input');
sequentialCheckbox.type = 'checkbox';
sequentialCheckbox.id = 'sequentialMode';
sequentialCheckbox.style.marginRight = '5px';
seqLabel.appendChild(sequentialCheckbox);
seqLabel.appendChild(document.createTextNode('Последовательно'));
const progress = document.createElement('div');
progress.className = 'progress';
progress.textContent = 'Обработано: 0/0, Время: 0.000 сек';
buttonsContainer.append(parseBtn, viewBtn, seqLabel, progress);
if (isMobile) {
const balanceContainer = findClanBalanceContainer();
if (balanceContainer) balanceContainer.after(buttonsContainer);
} else {
document.body.appendChild(buttonsContainer);
}
function createButton(icon, title, id) {
const btn = document.createElement('div');
btn.innerHTML = icon;
btn.className = 'anvil-btn';
btn.id = id;
btn.title = title;
return btn;
}
// fetchDocument с декодированием из windows-1251
function fetchDocument(url) {
const startTime = performance.now();
return fetch(url, { credentials: 'include' })
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.arrayBuffer();
})
.then(arrayBuffer => {
const decoder = new TextDecoder('windows-1251');
const text = decoder.decode(arrayBuffer);
const networkTime = performance.now() - startTime;
const parseStart = performance.now();
const doc = new DOMParser().parseFromString(text, 'text/html');
const parseTime = performance.now() - parseStart;
const totalTime = performance.now() - startTime;
return { doc, text, networkTime, parseTime, totalTime };
});
}
async function processPlayerLink(link) {
try {
const result = await fetchDocument(link);
const { doc } = result;
const playerName = extractPlayerName(doc);
const crafts = parseCrafts(doc);
const playerData = hasActiveCrafts(crafts) ? createPlayerData(playerName, crafts) : null;
return { playerData, totalTime: result.totalTime };
} catch(e) {
GM_log(`Ошибка парсинга ${link}: ${e}`);
return { playerData: null, totalTime: 0 };
}
}
async function parseClanCrafters() {
const startTime = performance.now();
let countRequests = 0;
try {
parseBtn.style.pointerEvents = 'none';
progress.style.display = 'inline-flex';
progress.textContent = 'Обработано: 0/0, Время: 0.000 сек';
const clanResult = await fetchDocument(window.location.href);
const clanPage = clanResult.doc;
const playerLinks = getPlayerLinks(clanPage);
if (!playerLinks.length) {
alert('Не найдено игроков!');
return;
}
progress.textContent = `Обработано: 0/${playerLinks.length}, Время: 0.000 сек`;
const sequentialMode = sequentialCheckbox.checked;
let responses = [];
if (sequentialMode) {
for (const link of playerLinks) {
const res = await processPlayerLink(link);
responses.push(res);
countRequests++;
progress.textContent = `Обработано: ${countRequests}/${playerLinks.length}, Время: ${((performance.now()-startTime)/1000).toFixed(3)} сек`;
}
} else {
const promises = playerLinks.map(link =>
processPlayerLink(link).then(res => {
countRequests++;
progress.textContent = `Обработано: ${countRequests}/${playerLinks.length}, Время: ${((performance.now()-startTime)/1000).toFixed(3)} сек`;
return res;
})
);
responses = await Promise.all(promises);
}
const results = responses
.map(r => r.playerData)
.filter(item => item !== null);
saveAndDisplay(results);
const overallTime = ((performance.now()-startTime)/1000).toFixed(3);
progress.textContent = `Обработано: ${playerLinks.length}/${playerLinks.length}, Время: ${overallTime} сек`;
} catch(e) {
alert('Ошибка: ' + e.message);
} finally {
parseBtn.style.pointerEvents = 'auto';
}
}
function getPlayerLinks(doc) {
try {
return [...doc.querySelectorAll('a[href*="pl_info.php?id="]')]
.map(a => new URL(a.href, window.location.href).toString())
.filter((v, i, a) => a.indexOf(v) === i);
} catch(e) {
GM_log('Ошибка извлечения ссылок:', e);
return [];
}
}
function extractPlayerName(doc) {
const title = doc.querySelector('title')?.textContent || '';
const match = title.match(/^([^|│]+)/);
return match?.[1]?.trim() || 'Неизвестно';
}
function parseCrafts(doc) {
const crafts = { blacksmith: 0, craft: 0, weapons: 0, armor: 0, jeweler: 0 };
const nodes = doc.evaluate(
"//td[contains(., 'Гильдия') or contains(., 'Мастер') or contains(., 'Ювелир')]",
doc,
null,
XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
null
);
for (let i = 0; i < nodes.snapshotLength; i++) {
const td = nodes.snapshotItem(i);
const text = td.textContent.trim();
updateCrafts(crafts, text);
}
return crafts;
}
function updateCrafts(crafts, text) {
const patterns = {
blacksmith: /Гильдия Кузнецов.*?(\d+)/,
craft: /Гильдия Оружейников.*?(\d+)/,
weapons: /Мастер оружия.*?(\d+)/,
armor: /Мастер доспехов.*?(\d+)/,
jeweler: /Ювелир.*?(\d+)/
};
for (const [key, regex] of Object.entries(patterns)) {
const match = text.match(regex);
if (match) crafts[key] = Math.max(crafts[key], parseInt(match[1]));
}
}
function createPlayerData(name, crafts) {
const guildCraft = crafts.craft;
return {
name: name,
crafts: crafts,
totalScore: crafts.blacksmith + guildCraft + crafts.weapons + crafts.armor + crafts.jeweler,
blacksmithScore: Math.min(10 + crafts.blacksmith * 10, 90),
weaponScore: Math.min(1 + guildCraft, 5) * Math.min(1 + crafts.weapons, 12),
armorScore: Math.min(1 + guildCraft, 5) * Math.min(1 + crafts.armor, 12),
jewelerScore: Math.min(1 + guildCraft, 5) * Math.min(1 + crafts.jeweler, 12)
};
}
function hasActiveCrafts(crafts) {
return Object.values(crafts).some(v => v > 0);
}
function saveAndDisplay(data) {
data.sort((a, b) => b.totalScore - a.totalScore);
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
showResultsTable(data);
}
function showResultsTable(data) {
currentData = data;
// Сохраняем позицию таблицы (если уже существует)
let savedLeft = null, savedTop = null;
const existing = document.getElementById('craftersTable');
if (existing) {
savedLeft = existing.style.left;
savedTop = existing.style.top;
existing.remove();
}
if (!data.length) {
alert('Нет данных для отображения');
return;
}
const headers = ['Игрок', 'Кузня', 'Оружие', 'Броня', 'Ювелирка', 'Крестик'];
const sortKeys = ['name', 'blacksmithScore', 'weaponScore', 'armorScore', 'jewelerScore'];
let headerHTML = `<tr>`;
headers.forEach((h, i) => {
if (i === headers.length - 1) {
headerHTML += `<th class="close-btn" id="closeBtn">✖</th>`;
} else {
let arrow = '';
if (currentSortKey === sortKeys[i]) {
arrow = currentSortDirection === 'asc' ? ' ▲' : ' ▼';
}
headerHTML += `<th>${h}${arrow}</th>`;
}
});
headerHTML += `</tr>`;
// Формируем тело таблицы (последний столбец остаётся пустым)
let tbodyHTML = data.map((player) => {
return `<tr>
<td>${player.name}</td>
<td>${calcForge(player.crafts.blacksmith)}</td>
<td>${formatCraft(player.crafts.craft, player.crafts.weapons)}</td>
<td>${formatCraft(player.crafts.craft, player.crafts.armor)}</td>
<td>${formatCraft(player.crafts.craft, player.crafts.jeweler, 12)}</td>
<td></td>
</tr>`;
}).join('');
const container = document.createElement('div');
container.id = 'craftersTable';
// Используем сохранённые координаты, если они есть
container.style.left = savedLeft ? savedLeft : '10px';
container.style.top = savedTop ? savedTop : '50%';
if (!container.style.height) {
container.style.height = '400px';
}
container.innerHTML = `
<div class="resize-handle top"></div>
<table>
<thead>
${headerHTML}
</thead>
<tbody>
${tbodyHTML}
</tbody>
</table>
<div class="resize-handle bottom"></div>
`;
document.body.appendChild(container);
// Обработчик кнопки закрытия
const closeBtn = container.querySelector('#closeBtn');
closeBtn.addEventListener('click', function(e) {
e.stopPropagation();
container.remove();
});
// Прикрепляем обработчик перетаскивания ко всем ячейкам последнего столбца тела таблицы
attachDragToLastColumn(container);
// Обработчики клика по заголовкам для сортировки (исключая крестик)
container.querySelectorAll('th').forEach((th, index) => {
if (!th.classList.contains('close-btn')) {
th.addEventListener('click', () => handleSort(index));
}
});
addResizeHandlers(container);
}
function attachDragToLastColumn(container) {
const cells = container.querySelectorAll('tbody tr td:last-child');
cells.forEach(cell => {
cell.style.cursor = 'move';
cell.addEventListener('pointerdown', function(e) {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const initialLeft = container.offsetLeft;
const initialTop = container.offsetTop;
cell.setPointerCapture(e.pointerId);
function onPointerMove(e) {
e.preventDefault();
container.style.left = (initialLeft + (e.clientX - startX)) + 'px';
container.style.top = (initialTop + (e.clientY - startY)) + 'px';
}
function onPointerUp(e) {
e.preventDefault();
cell.releasePointerCapture(e.pointerId);
cell.removeEventListener('pointermove', onPointerMove);
cell.removeEventListener('pointerup', onPointerUp);
}
cell.addEventListener('pointermove', onPointerMove);
cell.addEventListener('pointerup', onPointerUp);
});
});
}
function handleSort(columnIndex) {
if (!currentData) return;
const sortKeys = ['name', 'blacksmithScore', 'weaponScore', 'armorScore', 'jewelerScore'];
const sortKey = sortKeys[columnIndex];
if (currentSortKey === sortKey) {
currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc';
} else {
currentSortKey = sortKey;
currentSortDirection = 'asc';
}
const sortedData = [...currentData].sort((a, b) => {
let aValue, bValue;
if (sortKey === 'name') {
aValue = a.name.toLowerCase();
bValue = b.name.toLowerCase();
return currentSortDirection === 'asc'
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
} else {
aValue = a[sortKey];
bValue = b[sortKey];
return currentSortDirection === 'asc'
? aValue - bValue
: bValue - aValue;
}
});
showResultsTable(sortedData);
}
function calcForge(level) {
return Math.min(10 + level * 10, 90) + '%';
}
function formatCraft(guildLevel, masterLevel, max = 12) {
if (guildLevel === 0 || masterLevel === 0) return '-';
const g = Math.min(1 + guildLevel, 5);
const m = Math.min(1 + masterLevel, max);
const suffix = masterLevel >= max ? '+р' : '';
return `${g}×${m}%${suffix}`;
}
// Функция изменения высоты (ресайз) остаётся без изменений
function addResizeHandlers(container) {
const topHandle = container.querySelector('.resize-handle.top');
const bottomHandle = container.querySelector('.resize-handle.bottom');
if (!container.style.height) {
container.style.height = container.offsetHeight + 'px';
}
topHandle.addEventListener('mousedown', function(e) {
e.preventDefault();
const startY = e.clientY;
const startHeight = container.offsetHeight;
const startTop = container.offsetTop;
function onMouseMove(e) {
const dy = e.clientY - startY;
const newHeight = startHeight - dy;
container.style.height = newHeight + 'px';
container.style.top = (startTop + dy) + 'px';
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
bottomHandle.addEventListener('mousedown', function(e) {
e.preventDefault();
const startY = e.clientY;
const startHeight = container.offsetHeight;
function onMouseMove(e) {
const dy = e.clientY - startY;
const newHeight = startHeight + dy;
container.style.height = newHeight + 'px';
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
}
viewBtn.addEventListener('click', () => {
const table = document.getElementById('craftersTable');
if (table) {
table.remove();
} else {
const data = JSON.parse(localStorage.getItem(STORAGE_KEY));
data ? showResultsTable(data) : alert('Данные не найдены!');
}
});
parseBtn.addEventListener('click', () => {
if (confirm('Обновить данные? Это может занять несколько минут.')) {
parseClanCrafters();
}
});
window.addEventListener('load', () => {
if (localStorage.getItem(STORAGE_KEY)) viewBtn.style.display = 'block';
});
})();