Загрузка данных
(async function () {
'use strict';
// ============ КОНФИГ ============
const DELAY_BETWEEN_ROWS = 1500; // пауза между заявками (мс)
const PANEL_LOAD_TIMEOUT = 20000; // максимум ждём загрузки панели
const COMMENTS_TIMEOUT = 15000; // максимум ждём догрузки комментариев
const SCROLL_STEP = 300; // шаг скролла при сборе списка
const SCROLL_WAIT = 600; // ожидание после скролла
const MAX_IDLE_SCROLLS = 3; // шагов без новых строк — стоп
const MAX_SCROLL_ITER = 500; // защита от бесконечного цикла
const RETRY_FAILED = true; // пересобирать заявки, где недобрали комментарии
// ============ УТИЛИТЫ ============
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
function log(...args) {
console.log('%c[TFS-Report]', 'color:#0a0;font-weight:bold', ...args);
}
function warn(...args) {
console.warn('%c[TFS-Report]', 'color:#c80;font-weight:bold', ...args);
}
function waitFor(selector, { root = document, timeout = 10000 } = {}) {
return new Promise((resolve, reject) => {
const found = root.querySelector(selector);
if (found) return resolve(found);
const obs = new MutationObserver(() => {
const el = root.querySelector(selector);
if (el) { obs.disconnect(); clearTimeout(timer); resolve(el); }
});
obs.observe(root, { childList: true, subtree: true });
const timer = setTimeout(() => {
obs.disconnect();
reject(new Error('timeout waiting for: ' + selector));
}, timeout);
});
}
function waitForCondition(checkFn, { timeout = 10000, label = '', pollMs = 300 } = {}) {
return new Promise((resolve, reject) => {
let done = false;
let lastDiag = '';
const check = () => {
try {
const v = checkFn();
if (v === true || (v && v.ok)) return { ok: true, value: v };
if (v && typeof v === 'object' && v.diag) lastDiag = v.diag;
} catch (e) {
lastDiag = 'exception: ' + e.message;
}
return { ok: false };
};
const tryResolve = () => {
if (done) return;
const r = check();
if (r.ok) { done = true; cleanup(); resolve(r.value); }
};
const obs = new MutationObserver(tryResolve);
obs.observe(document.body, {
childList: true, subtree: true, characterData: true, attributes: true
});
const poll = setInterval(tryResolve, pollMs);
function cleanup() {
obs.disconnect();
clearInterval(poll);
clearTimeout(timer);
}
const timer = setTimeout(() => {
if (done) return;
done = true;
cleanup();
reject(new Error(`timeout: ${label}${lastDiag ? ` (lastDiag="${lastDiag}")` : ''}`));
}, timeout);
tryResolve();
});
}
// ============ 1. СКРОЛЛ-КОНТЕЙНЕР СПИСКА ============
function ancestors(el, depth = 8) {
const arr = [];
let p = el;
for (let i = 0; i < depth && p; i++) { p = p.parentElement; if (p) arr.push(p); }
return arr;
}
function getScroller() {
const spacer = document.querySelector('.grid-content-spacer');
if (!spacer) {
warn('grid-content-spacer не найден');
return document.scrollingElement;
}
for (const el of [spacer, spacer.parentElement, ...ancestors(spacer, 8)]) {
if (!el || el === document.body || el === document.documentElement) continue;
const st = getComputedStyle(el);
if ((st.overflowY === 'auto' || st.overflowY === 'scroll') &&
el.scrollHeight > el.clientHeight + 5) return el;
}
let el = spacer.parentElement;
while (el && el !== document.body) {
if (el.scrollHeight > el.clientHeight + 5) return el;
el = el.parentElement;
}
return spacer.parentElement || document.scrollingElement;
}
async function scrollToTop(scroller) {
if (!scroller) return;
scroller.scrollTop = 0;
scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
await sleep(SCROLL_WAIT);
}
// ============ 2. СБОР СПИСКА ЗАЯВОК ============
function readVisibleRows() {
const result = [];
document.querySelectorAll('.grid-row').forEach(row => {
const a = row.querySelector('a.work-item-title-link');
if (!a) return;
const m = (a.getAttribute('href') || '').match(/\/edit\/(\d+)/);
if (!m) return;
result.push({
id: m[1],
title: a.textContent.trim(),
href: a.getAttribute('href')
});
});
return result;
}
async function collectAllRows(scroller) {
log('Сбор списка заявок...');
const map = new Map();
let idle = 0;
let iter = 0;
await scrollToTop(scroller);
while (iter++ < MAX_SCROLL_ITER) {
const before = map.size;
readVisibleRows().forEach(r => { if (!map.has(r.id)) map.set(r.id, r); });
if (map.size > before) idle = 0;
const atBottom = !scroller ||
scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 10;
if (atBottom) {
idle++;
if (idle >= MAX_IDLE_SCROLLS) break;
scroller.scrollTop = scroller.scrollTop + 5;
scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
await sleep(SCROLL_WAIT);
continue;
}
scroller.scrollTop = Math.min(scroller.scrollTop + SCROLL_STEP, scroller.scrollHeight);
scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
await sleep(SCROLL_WAIT);
}
readVisibleRows().forEach(r => { if (!map.has(r.id)) map.set(r.id, r); });
log(`Найдено заявок: ${map.size}`);
return Array.from(map.values());
}
// ============ 3. ЧТЕНИЕ ПОЛЕЙ ПАНЕЛИ ============
const findInput = (panel, aria) => panel.querySelector(`input[aria-label="${aria}"]`);
const getPanel = () => document.querySelector('.rightPane');
function readInputOrDisplay(panel, aria, displaySelector) {
const v = findInput(panel, aria)?.value?.trim();
if (v) return v;
return panel.querySelector(displaySelector)?.textContent?.trim() || '';
}
function getTitle(panel) {
const v = findInput(panel, 'Поле заголовка')?.value?.trim()
|| panel.querySelector('#witc_221_txt')?.value?.trim();
if (v) return v;
return panel.querySelector('.workitem-info-bar .caption')?.textContent?.trim() || '';
}
const getState = (panel) => readInputOrDisplay(panel, 'Поле состояния', '.workitem-state-value');
const getAssignedTo = (panel) =>
panel.querySelector('.work-item-form-assignedTo .identity-picker-resolved-name')?.textContent?.trim() || '';
function getArea(panel) {
const inp = findInput(panel, 'Путь области') || panel.querySelector('#witc_225_txt');
return inp?.value?.trim() || '';
}
function getIteration(panel) {
const inp = findInput(panel, 'Путь итерации') || panel.querySelector('#witc_229_txt');
return inp?.value?.trim() || '';
}
function getChangedBy(panel) {
for (const el of panel.querySelectorAll('.freshness-indicator')) {
const t = el.textContent.trim();
if (t && el.offsetParent !== null) return t;
}
return '';
}
// ============ 4. КОММЕНТАРИИ ============
function getExpectedCommentCount(panel) {
const adorn = panel.querySelector('.discussion-adornment-control');
if (adorn) {
const s = (adorn.getAttribute('aria-label') || '') + ' ' + (adorn.textContent || '');
const m = s.match(/(\d+)/);
if (m) return parseInt(m[1], 10);
}
const badge = panel.querySelector('.discussion-adornment-container .text');
if (badge) {
const m = badge.textContent.match(/(\d+)/);
if (m) return parseInt(m[1], 10);
}
const tabCount = panel.querySelector('.work-item-form-tab .link-count, .work-item-form-tab .attachment-count');
if (tabCount) {
const m = tabCount.textContent.match(/(\d+)/);
if (m) return parseInt(m[1], 10);
}
return null;
}
async function ensureDiscussionExpanded(panel) {
const header = Array.from(panel.querySelectorAll('.tfs-collapsible-header'))
.find(h => /Обсуждени/i.test(h.textContent));
if (!header) return;
const content = header.parentElement?.querySelector('.tfs-collapsible-content');
if (content && content.style.display === 'none') {
(header.querySelector('.headerlabel') || header).click();
await sleep(500);
}
const discTab = Array.from(panel.querySelectorAll('.work-item-form-tab'))
.find(t => /обсужд|discussion/i.test(t.getAttribute('aria-labelledby') || t.textContent || ''));
if (discTab && !discTab.classList.contains('selected-tab')) {
const discVisible = content && content.offsetParent !== null;
if (!discVisible) {
discTab.click();
await sleep(500);
}
}
}
function parseRelativeTime(s) {
if (!s) return Infinity;
const m = s.toLowerCase().match(/(\d+)\s*(сек|мин|ч|дн|час|день|недел)/);
if (!m) return Infinity;
const n = parseInt(m[1], 10);
const u = m[2];
if (/сек/.test(u)) return n;
if (/мин/.test(u)) return n * 60;
if (/ч|час/.test(u)) return n * 3600;
if (/дн|день/.test(u)) return n * 86400;
if (/недел/.test(u)) return n * 604800;
return Infinity;
}
function extractCommentBody(ci) {
const content = ci.querySelector('.comment-content');
if (content) {
const inner = content.querySelector('.rooster-editor, .lean-rooster');
const txt = (inner ? inner.innerText : content.innerText || '').trim();
if (txt) return txt;
}
const rooster = ci.querySelector('.rooster-editor, .lean-rooster');
if (rooster) {
const txt = (rooster.innerText || '').trim();
if (txt) return txt;
}
const clone = ci.cloneNode(true);
clone.querySelectorAll('.comment-header, .comment-toolbar, .comment-timestamp, .user-display-name')
.forEach(n => n.remove());
return (clone.innerText || '').trim();
}
function extractCommentData(ci) {
const author = ci.querySelector('.user-display-name')?.textContent.trim() || '';
const tsRaw = ci.querySelector('.comment-timestamp')?.textContent.trim() || '';
const body = extractCommentBody(ci);
const idAttr = ci.querySelector('[data-comment-id]')?.getAttribute('data-comment-id');
const id = idAttr ? parseInt(idAttr, 10) : null;
return { id, author, tsRaw, ts: parseRelativeTime(tsRaw), body };
}
function pickLatest(comments) {
if (!comments.length) return null;
const withId = comments.filter(c => c.id != null);
if (withId.length) return withId.reduce((a, b) => (b.id > a.id ? b : a));
return comments.reduce((a, b) => (b.ts < a.ts ? b : a));
}
function findDiscussionScroller(panel) {
const roots = [
panel.querySelector('.discussion-messages'),
panel.querySelector('.discussion-messages-container'),
panel.querySelector('.discussion-control'),
panel.querySelector('.witform-layout-scrollable-frame')
].filter(Boolean);
for (const root of roots) {
let el = root;
while (el && el !== document.body) {
const st = getComputedStyle(el);
if ((st.overflowY === 'auto' || st.overflowY === 'scroll') &&
el.scrollHeight > el.clientHeight + 2) {
return el;
}
el = el.parentElement;
}
}
return null;
}
async function scrollDiscussionToBottom(scroller, { step = 200, pause = 120 } = {}) {
if (!scroller) return;
let stable = 0;
for (let i = 0; i < 50; i++) {
const before = scroller.scrollHeight;
scroller.scrollTop = Math.min(scroller.scrollTop + step, scroller.scrollHeight);
scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
await sleep(pause);
if (scroller.scrollHeight === before) stable++; else stable = 0;
const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 5;
if (atBottom && stable >= 2) break;
}
scroller.scrollTop = scroller.scrollHeight;
scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
await sleep(300);
}
async function loadAllComments(panel, { timeout = COMMENTS_TIMEOUT, previousCommentIds = [] } = {}) {
await ensureDiscussionExpanded(panel);
let expected = getExpectedCommentCount(panel);
let lastExpected = expected;
let expectedStable = 0;
for (let i = 0; i < 10; i++) {
await sleep(200);
const cur = getExpectedCommentCount(panel);
if (cur === lastExpected) expectedStable++;
else { expectedStable = 0; lastExpected = cur; }
expected = cur;
if (expectedStable >= 2) break;
}
const prevSet = new Set(previousCommentIds.map(String));
if (expected === 0) {
for (let t = 0; t < 15; t++) {
const items = panel.querySelectorAll('.comment-item');
const hasStale = Array.from(panel.querySelectorAll('.comment-item [data-comment-id]'))
.some(el => prevSet.has(el.getAttribute('data-comment-id')));
if (items.length === 0 && !hasStale) break;
await sleep(200);
}
expected = getExpectedCommentCount(panel);
if (expected === 0) return [];
}
const messagesRoot =
panel.querySelector('.discussion-messages') ||
panel.querySelector('.discussion-messages-container') ||
panel.querySelector('.discussion-control');
if (!messagesRoot) return [];
const scroller = findDiscussionScroller(panel) || messagesRoot;
const started = performance.now();
const maxTicks = Math.ceil(timeout / 300);
let lastCount = -1;
let stableTicks = 0;
for (let tick = 0; tick < maxTicks; tick++) {
const items = Array.from(panel.querySelectorAll('.comment-item'));
const count = items.length;
const bodies = items.map(extractCommentBody);
const emptyBodies = bodies.filter(b => !b).length;
const hasStale = Array.from(panel.querySelectorAll('.comment-item [data-comment-id]'))
.some(el => prevSet.has(el.getAttribute('data-comment-id')));
const curExpected = getExpectedCommentCount(panel);
if (curExpected != null) expected = curExpected;
const countOk = expected != null
? count >= expected
: (count > 0 && stableTicks >= 3);
if (countOk && emptyBodies === 0 && !hasStale) break;
if (tick % 3 === 0) {
log(` [tick ${tick}] expected=${expected}, count=${count}, empty=${emptyBodies}, stale=${hasStale}`);
}
await scrollDiscussionToBottom(scroller, { step: 200, pause: 120 });
if (count === lastCount) stableTicks++; else stableTicks = 0;
lastCount = count;
if (performance.now() - started > timeout) break;
}
let all = Array.from(panel.querySelectorAll('.comment-item')).map(extractCommentData);
if (all.length === 0) {
warn(` Комментарии не собрались, жму "Обновить" панели...`);
const btn = document.querySelector('[command="refresh-work-item"]');
if (btn) {
btn.click();
await sleep(1500);
await ensureDiscussionExpanded(panel);
await scrollDiscussionToBottom(scroller, { step: 200, pause: 120 });
all = Array.from(panel.querySelectorAll('.comment-item')).map(extractCommentData);
}
}
if (expected != null && all.length < expected) {
warn(` ⚠ Собрано ${all.length} из ${expected} комментариев`);
}
const empty = all.filter(c => !c.body).length;
if (empty) warn(` ⚠ ${empty} комментариев с пустым текстом`);
return all;
}
async function waitForComments(panel, opts = {}) {
const all = await loadAllComments(panel, opts);
return { all, latest: pickLatest(all) };
}
// ============ 5. ОЖИДАНИЕ ЗАГРУЗКИ ПАНЕЛИ ============
async function waitForPanelLoaded(expectedId, previousCommentIds = [], { timeout = PANEL_LOAD_TIMEOUT } = {}) {
await waitFor('.rightPane', { timeout });
const started = performance.now();
await waitForCondition(() => {
const panel = document.querySelector('.rightPane');
if (!panel) return { ok: false, diag: 'no panel' };
const caption = panel.querySelector('.workitem-info-bar .caption');
if (!caption) return { ok: false, diag: 'no caption' };
const m = caption.textContent.match(/(\d{4,})/);
if (!m) return { ok: false, diag: 'caption without id' };
if (expectedId && m[1] !== String(expectedId)) {
return { ok: false, diag: `caption id ${m[1]} != ${expectedId}` };
}
const stateText =
(findInput(panel, 'Поле состояния')?.value || '').trim() ||
(panel.querySelector('#witc_223_txt')?.value || '').trim() ||
(panel.querySelector('.workitem-state-value')?.textContent || '').trim();
if (!stateText) return { ok: false, diag: 'state empty' };
if (panel.querySelectorAll('.work-item-form-tab').length < 3) {
return { ok: false, diag: 'tabs < 3' };
}
if (!panel.querySelector('.work-item-form-tab.selected-tab')) {
return { ok: false, diag: 'no active tab' };
}
if (!panel.querySelector('.discussion-control, .discussion-messages-container')) {
return { ok: false, diag: 'no discussion' };
}
if (previousCommentIds.length) {
const prevSet = new Set(previousCommentIds.map(String));
const currentIds = Array.from(panel.querySelectorAll('.comment-item [data-comment-id]'))
.map(el => el.getAttribute('data-comment-id'));
const stale = currentIds.filter(id => prevSet.has(id));
if (stale.length) {
return { ok: false, diag: `stale comments from prev work item (${stale.length})` };
}
}
return { ok: true };
}, { timeout, label: 'panel fields ready' });
log(`Панель загружена за ${Math.round(performance.now() - started)} мс (id=${expectedId})`);
}
// ============ 6. FULL-SCREEN ЗАЩИТА ============
async function ensurePanelNotFullScreen() {
const panel = document.querySelector('.rightPane');
if (!panel) return;
const isFull =
panel.classList.contains('full-screen') ||
document.body.classList.contains('full-screen') ||
document.querySelector('.work-item-form.full-screen') ||
document.querySelector('.work-item-form-main[style*="position: fixed"]');
if (!isFull) return;
warn(' Панель в полноэкранном режиме — сворачиваю');
const btn = document.querySelector('[command="toggle-full-screen"]');
if (btn) {
btn.click();
await sleep(600);
}
}
// ============ 7. КЛИК ПО СТРОКЕ ============
async function findRowLink(itemId, scroller) {
const sel = `a.work-item-title-link[href*="/edit/${itemId}"]`;
let link = document.querySelector(sel);
if (link) return link;
if (scroller) {
scroller.scrollTop = 0;
scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
await sleep(300);
for (let pos = 0; pos <= scroller.scrollHeight; pos += SCROLL_STEP) {
scroller.scrollTop = pos;
scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
await sleep(250);
link = document.querySelector(sel);
if (link) return link;
}
}
return null;
}
async function clickRowSmart(link) {
const r = link.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const base = {
bubbles: true,
cancelable: true,
view: window,
button: 0,
buttons: 1,
clientX: cx,
clientY: cy
};
try {
link.dispatchEvent(new PointerEvent('pointerdown', { ...base, pointerType: 'mouse', isPrimary: true, pointerId: 1 }));
link.dispatchEvent(new MouseEvent('mousedown', base));
link.dispatchEvent(new PointerEvent('pointerup', { ...base, pointerType: 'mouse', isPrimary: true, pointerId: 1, buttons: 0 }));
link.dispatchEvent(new MouseEvent('mouseup', { ...base, buttons: 0 }));
link.dispatchEvent(new MouseEvent('click', { ...base, buttons: 0 }));
} catch (_) {
link.dispatchEvent(new MouseEvent('mousedown', base));
link.dispatchEvent(new MouseEvent('mouseup', { ...base, buttons: 0 }));
link.dispatchEvent(new MouseEvent('click', { ...base, buttons: 0 }));
}
}
async function clickRefreshInPanel() {
const btn = document.querySelector('[command="refresh-work-item"]');
if (!btn) return false;
btn.click();
return true;
}
async function openWorkItem(item, scroller, previousCommentIds = []) {
await ensurePanelNotFullScreen();
const link = await findRowLink(item.id, scroller);
if (!link) throw new Error(`Не найдена строка для заявки ${item.id}`);
link.scrollIntoView({ block: 'center' });
await sleep(200);
await clickRowSmart(link);
await waitForCondition(() => {
const cur = document.querySelector('.grid-row.grid-row-current');
if (!cur) return { ok: false, diag: 'no grid-row-current' };
const a = cur.querySelector('a.work-item-title-link');
const m = (a?.getAttribute('href') || '').match(/\/edit\/(\d+)/);
if (!m) return { ok: false, diag: 'current row without id' };
if (m[1] !== String(item.id)) return { ok: false, diag: `current ${m[1]} != ${item.id}` };
return { ok: true };
}, { timeout: 5000, label: `row ${item.id} becomes current` });
try {
await waitForPanelLoaded(item.id, previousCommentIds, { timeout: 6000 });
} catch (e1) {
warn(` Панель не обновилась за 6с (${e1.message}), жму "Обновить"...`);
const ok = await clickRefreshInPanel();
if (!ok) throw e1;
await sleep(500);
await waitForPanelLoaded(item.id, previousCommentIds, { timeout: PANEL_LOAD_TIMEOUT });
}
await ensurePanelNotFullScreen();
}
// ============ 8. ОДНА ЗАЯВКА ============
async function processWorkItem(item, scroller, previousCommentIds) {
await openWorkItem(item, scroller, previousCommentIds);
const panel = getPanel();
const { all: comments, latest } = await waitForComments(panel, { previousCommentIds });
const expectedCount = getExpectedCommentCount(panel);
const missing = expectedCount != null ? Math.max(0, expectedCount - comments.length) : 0;
return {
id: item.id,
title: getTitle(panel),
state: getState(panel),
assignedTo: getAssignedTo(panel),
area: getArea(panel),
iteration: getIteration(panel),
changedBy: getChangedBy(panel),
commentsCount: comments.length,
expectedComments: expectedCount,
missingComments: missing,
latestComment: latest ? latest.body : '',
latestAuthor: latest ? latest.author : '',
latestTime: latest ? latest.tsRaw : '',
comments
};
}
// ============ 9. ГРУППИРОВКА ============
function groupByComment(report) {
const grouped = {};
const noComment = [];
const failed = [];
for (const r of report) {
if (r.error) {
failed.push({ id: r.id, got: 0, expected: r.expectedComments ?? null, error: r.error });
continue;
}
const text = (r.latestComment || '').trim();
const missing = r.missingComments || 0;
if (missing > 0) {
failed.push({
id: r.id,
got: r.commentsCount,
expected: r.expectedComments,
error: null
});
if (text) {
if (!grouped[text]) grouped[text] = [];
grouped[text].push(r.id);
}
continue;
}
if (!text) {
noComment.push(r.id);
continue;
}
if (!grouped[text]) grouped[text] = [];
grouped[text].push(r.id);
}
for (const k of Object.keys(grouped)) {
grouped[k].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
}
noComment.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
return { grouped, noComment, failed };
}
// ============ 10. ФОРМИРОВАНИЕ TXT ============
function buildTxtReport({ grouped, noComment, failed }) {
const lines = [];
const stamp = new Date().toLocaleString('ru-RU');
lines.push(`Отчёт по заявкам TFS`);
lines.push(`Сформирован: ${stamp}`);
lines.push('');
lines.push('=== По комментариям ===');
const entries = Object.entries(grouped);
if (entries.length) {
for (const [text, ids] of entries) {
lines.push(`${ids.join(', ')} - ${text}`);
}
} else {
lines.push('(нет)');
}
if (noComment.length) {
lines.push('');
lines.push(`=== Без комментариев (${noComment.length}) ===`);
lines.push(noComment.join(', '));
}
if (failed.length) {
lines.push('');
lines.push(`=== Не удалось собрать полностью (${failed.length}) ===`);
for (const f of failed) {
lines.push(`${f.id} — собрано ${f.got}${f.expected != null ? ` из ${f.expected}` : ''}${f.error ? ` (${f.error})` : ''}`);
}
}
return lines.join('\r\n');
}
// ============ 11. RETRY ПРОВАЛЕННЫХ ============
async function retryFailed(report, itemsById, scroller) {
const failedNow = report.filter(r => !r.error && r.missingComments > 0);
if (!failedNow.length) return report;
warn(`Пересобираю ${failedNow.length} заявок, где недобрали комментарии...`);
const updated = new Map(report.map(r => [r.id, r]));
for (const r of failedNow) {
const it = itemsById[r.id];
if (!it) continue;
log(` retry ${it.id} (было ${r.commentsCount}/${r.expectedComments})`);
try {
const fresh = await processWorkItem(it, scroller, r.comments.map(c => c.id).filter(Boolean).map(String));
updated.set(it.id, fresh);
} catch (e) {
warn(` retry ${it.id} не удался: ${e.message}`);
}
await sleep(DELAY_BETWEEN_ROWS);
}
return Array.from(updated.values());
}
// ============ 12. ГЛАВНЫЙ ЦИКЛ ============
async function main() {
const scroller = getScroller() || document.scrollingElement;
log('Скролл-контейнер:', scroller);
await scrollToTop(scroller);
const items = await collectAllRows(scroller);
if (!items.length) { warn('Заявок не найдено'); return; }
const itemsById = Object.fromEntries(items.map(it => [it.id, it]));
let report = [];
let previousCommentIds = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
log(`(${i + 1}/${items.length}) Открываю ${it.id} — ${it.title}`);
try {
const data = await processWorkItem(it, scroller, previousCommentIds);
previousCommentIds = data.comments.map(c => c.id).filter(Boolean).map(String);
report.push(data);
const miss = data.missingComments > 0
? ` ⚠ недобор ${data.commentsCount}/${data.expectedComments}`
: '';
log(` ✓ ${data.state || '?'} | ${data.assignedTo || '?'} | комм.: ${data.commentsCount}${miss}, свежий: "${(data.latestComment || '').slice(0, 60)}"`);
} catch (e) {
warn(` ✗ Ошибка ${it.id}: ${e.message}`);
report.push({
id: it.id,
title: it.title,
error: e.message,
comments: [],
commentsCount: 0,
expectedComments: null,
missingComments: 0
});
}
await sleep(DELAY_BETWEEN_ROWS);
}
// Retry проваленных
if (RETRY_FAILED) {
report = await retryFailed(report, itemsById, scroller);
}
// Группировка
const groupedData = groupByComment(report);
const { grouped, noComment, failed } = groupedData;
// Консоль
console.log('%c=== grouped (комментарий → [id]) ===', 'color:#0a0;font-weight:bold');
console.dir(grouped);
console.log('%c=== Без комментариев ===', 'color:#888;font-weight:bold');
console.log(noComment);
if (failed.length) {
console.log('%c=== Не собрано полностью ===', 'color:#c00;font-weight:bold');
console.table(failed);
}
console.log('%c=== Сгруппированные строки ===', 'color:#0a0;font-weight:bold');
for (const [text, ids] of Object.entries(grouped)) {
console.log(`${ids.join(', ')} - ${text}`);
}
// Глобальные переменные
window.__tfsReport = report;
window.__tfsGrouped = grouped;
window.__tfsNoComment = noComment;
window.__tfsFailed = failed;
window.__tfsTxt = buildTxtReport(groupedData);
// Скачиваем ОДИН файл — TXT
const txt = window.__tfsTxt;
const blob = new Blob(['\ufeff' + txt], { type: 'text/plain;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `tfs_report_${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
log('Готово. TXT скачан. window.__tfsReport / __tfsGrouped / __tfsTxt');
}
main().catch(e => console.error('[TFS-Report] FATAL:', e));
})();