<!DOCTYPE html><html lang="uk"><head><base target="_top"><meta charset="utf-8"><style>
*{box-sizing:border-box}body{margin:0;padding:12px;background:#f3f6f9;color:#203744;font:13px Arial,sans-serif}h1{font-size:20px;margin:4px 0 12px}button,input,select{font:inherit;border-radius:8px;padding:10px;width:100%}button{border:0;background:#126479;color:white;cursor:pointer;margin:4px 0}button:disabled{opacity:.5;cursor:wait}.secondary{background:#e5edf2;color:#214656}.danger{background:#a63242}input,select{border:1px solid #bdcdd6;background:white}label{display:block;font-weight:bold;margin:12px 0 5px}.check{display:flex;align-items:center;gap:8px;font-weight:normal;line-height:1.4}.check input{width:17px;height:17px;flex-shrink:0}.row{display:flex;gap:8px}.row>*{flex:1;min-width:0}.card{background:white;border:1px solid #dbe5eb;border-radius:12px;padding:12px;margin:10px 0}.grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.metric{background:#f3f7f9;padding:10px;border-radius:8px;min-width:0}.metric b{display:block;font-size:21px;line-height:1.2;margin-bottom:5px}.metric span{font-size:11px;color:#526877;line-height:1.35;display:block;overflow-wrap:anywhere}.metric.error b{color:#a63242}.metric.good b{color:#147361}progress{width:100%;height:12px;accent-color:#188878}#state{font-weight:bold;font-size:15px;margin-bottom:10px}.progress-title{display:flex;justify-content:space-between;gap:6px;font-size:12px;margin-top:12px}.detail{font-size:11px;color:#526877;line-height:1.4;margin-top:4px}.time-row{display:grid;grid-template-columns:100px 1fr;gap:8px;padding:8px 0;border-bottom:1px solid #edf1f3;line-height:1.4}.time-row:last-child{border:0}.time-row span{color:#526877}.time-row b{font-size:12px;text-align:right;overflow-wrap:anywhere}.section-title{font-size:12px;color:#526877;margin:2px 0 10px;font-weight:bold}#msg{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;line-height:1.45;margin:10px 0 0}#msg:empty{display:none}#currentWork{overflow-wrap:anywhere}details{margin:10px 0}summary{cursor:pointer;font-weight:bold;line-height:1.5}#zoneGroups details{border-top:1px solid #edf1f3;padding:7px 0;margin:4px 0}#zoneSummary{overflow-wrap:anywhere;font-size:11px;background:#edf6f8;padding:8px;border-radius:6px;line-height:1.5}.note{font-size:11px;color:#526877;line-height:1.45}.row button{font-size:12px;padding:10px 5px}.muted{font-size:11px;color:#526877}
</style></head><body><h1>Broken Link Hunter</h1>
<button class="secondary" onclick="setup()">Налаштувати таблицю</button>
<div class="row"><button onclick="call('bhStart')">▶ Продовжити</button><button class="secondary" onclick="call('bhPause')">⏸ Пауза</button></div>
<div class="card"><div id="state">Завантаження…</div><div id="overview" class="grid"></div>
<div id="donorProgress" class="grid"></div><div id="activeDonor" class="detail"></div><div class="progress-title"><span>Сторінки у збереженій черзі</span><b id="pagePercent">—</b></div><progress id="pageProgress" max="100" value="0"></progress><div id="pageLabel" class="detail"></div>
<div class="progress-title"><span>Домени</span><b id="domainPercent">—</b></div><progress id="domainProgress" max="100" value="0"></progress><div id="domainLabel" class="detail"></div><div id="msg" role="status"></div><div id="currentWork" class="detail"></div></div>
<div class="card"><div class="section-title">Результати та діагностика</div><div id="diagnostics" class="grid"></div><p id="sitemapNote" class="note"></p><div class="row"><button onclick="openTab('bhOpenResults')">Кандидати</button><button class="secondary" onclick="openTab('bhOpenManual')">Ручна перевірка</button></div></div>
<div class="card"><div class="section-title">Час роботи</div><div class="time-row"><span>Від початку</span><b id="elapsed">—</b></div><div class="time-row"><span>Без пауз</span><b id="active">—</b></div><div class="time-row"><span>Залишилося</span><b id="eta">—</b></div><div class="time-row"><span>Останній пакет</span><b id="last">—</b></div></div>
<div class="card"><div class="section-title">Місце в таблиці</div><progress id="storageBar" max="100" value="0"></progress><div id="storageText" class="detail"></div><div id="cleanupText" class="detail"></div></div>
<details class="card"><summary>Налаштування сканування</summary><p class="note">Донори: вкладка «BLH Донори», починаючи з A2.</p>
<label>Режим</label><select id="mode"><option value="site">Обхід сайту</option><option value="page">Лише вказані сторінки</option></select>
<label class="check"><input type="checkbox" id="fullCrawl" checked onchange="crawlMode()">Повний обхід</label>
<details id="limitOptions"><summary>Обмеження обходу</summary><div class="row"><div><label>Глибина</label><input id="depth" type="number" min="0" max="10" value="4"></div><div><label>Сторінок/донор</label><input id="maxPages" type="number" min="0" max="5000" value="500"></div></div><label>Загальний ліміт</label><input id="maxTotal" type="number" min="1" max="20000" value="5000"></details>
<label class="check"><input type="checkbox" id="sitemaps" checked>Шукати сторінки через Sitemap</label><label class="check"><input type="checkbox" id="rdapAll" checked>RDAP для всіх доменів</label><label>Перевірок за пакет</label><input id="batch" type="number" min="1" max="30" value="15">
<label>Доменні зони</label><label class="check"><input type="checkbox" id="allZones" onchange="zoneChanged()">Усі зони</label><div id="zonePicker"><div class="row"><button type="button" class="secondary" onclick="selectZones('base')">com · net · org</button><button type="button" class="secondary" onclick="selectZones('none')">Зняти всі</button></div><div class="row"><button type="button" class="secondary" onclick="selectZones('pt')">Базові + PT</button><button type="button" class="secondary" onclick="selectZones('nl')">Базові + NL</button></div><div id="zoneGroups"></div><label>Інші зони</label><input id="customZones" placeholder="eu, co.uk, com.au" oninput="zoneChanged()"></div><input type="hidden" id="tlds" value="com,net,org"><p id="zoneSummary"></p><button onclick="save()">Зберегти налаштування</button></details>
<details class="card"><summary>Очистити дані вручну</summary><p class="note">Видалить результати, ручну перевірку й чергу. Донори та налаштування залишаться.</p><button class="danger" onclick="clearResults()">Очистити результати</button></details>
<script>
let loaded=false,busy=false,refreshing=false;const fields=['mode','depth','maxPages','maxTotal','batch','tlds'];const names={READY:'Готовий до налаштування',RUNNING:'Пошук працює',PAUSED:'Призупинено',DONE:'Чергу завершено'};
function num(v){return Number(v||0).toLocaleString('uk-UA');}
function metrics(id,items){const box=document.getElementById(id);box.textContent='';items.forEach(([value,label,tone])=>{const cell=document.createElement('div');cell.className='metric'+(tone?' '+tone:'');const v=document.createElement('b');v.textContent=num(value);const l=document.createElement('span');l.textContent=label;cell.appendChild(v);cell.appendChild(l);box.appendChild(cell);});}
function duration(ms){if(ms===null||ms===undefined)return 'Ще невідомо';const m=Math.floor(ms/60000);return m<1?'Менше хвилини':m<60?m+' хв':Math.floor(m/60)+' год '+m%60+' хв';}
function render(s){if(!s)return;const p=s.progress;const dr=s.donors||{};metrics('donorProgress',[[dr.done,'Донорів завершено','good'],[dr.left,'Залишилось донорів']]);document.getElementById('activeDonor').textContent=(dr.cleaning?'Прибирання: ':'Зараз: ')+(dr.active||'—')+' · Завершено з пропусками/помилками: '+num(dr.withIssues);document.getElementById('state').textContent=names[s.state]||s.state;document.getElementById('msg').textContent=s.message||'';metrics('overview',[[s.counts.donors,'Донорів'],[p.pages.left,'Сторінок очікують'],[s.counts.domains,'Доменів'],[s.counts.links,'Посилань']]);
 [['page',p.pages],['domain',p.domains]].forEach(([k,v])=>{document.getElementById(k+'Progress').value=v.percent;document.getElementById(k+'Percent').textContent=v.percent+'%';document.getElementById(k+'Label').textContent=num(v.done)+' / '+num(v.total)+' оброблено · залишилось '+num(v.left);});
 metrics('diagnostics',[[p.successful,'Успішних сторінок','good'],[p.savedCandidates,'У списку кандидатів','good'],[p.savedManual,'У ручній перевірці'],[p.watch,'На спостереженні'],[p.sitemapsPending,'Sitemap очікують'],[p.sitemapsErrors,'Помилки Sitemap',p.sitemapsErrors?'error':''],[p.pageErrors,'Помилки сторінок',p.pageErrors?'error':''],[p.domainErrors,'Помилки доменів',p.domainErrors?'error':''],[p.skipped,'Пропуски / robots'],[p.pages.delayed+p.domains.delayed,'Повтори із затримкою']]);
 document.getElementById('sitemapNote').textContent=p.sitemapsHeld?'Нові Sitemap очікують зменшення черги сторінок.':'';
 document.getElementById('elapsed').textContent=duration(p.elapsedMs);document.getElementById('active').textContent=duration(p.activeMs);document.getElementById('eta').textContent=duration(p.etaMs);document.getElementById('last').textContent=s.last?new Date(s.last).toLocaleString('uk-UA'):'—';document.getElementById('currentWork').textContent=p.current||'';
 const st=p.storage||{};document.getElementById('storageBar').value=st.limit?Math.min(100,100*st.cells/st.limit):0;document.getElementById('storageText').textContent=num(st.cells)+' / '+num(st.limit)+' клітинок';document.getElementById('cleanupText').textContent='Тимчасових записів оброблених адрес: '+num(p.archived)+'.';
 if(!loaded){fields.forEach(k=>document.getElementById(k).value=s.config[k]);loadZones(s.config.tlds);['rdapAll','sitemaps'].forEach(k=>document.getElementById(k).checked=s.config[k]!==false);document.getElementById('fullCrawl').checked=s.config.fullCrawl===true;crawlMode();loaded=true;}}
function crawlMode(){const full=document.getElementById('fullCrawl').checked;['depth','maxPages','maxTotal'].forEach(k=>document.getElementById(k).disabled=full);document.getElementById('limitOptions').hidden=full;}
function enabled(v){busy=!v;document.querySelectorAll('button').forEach(b=>b.disabled=!v);}
function call(fn,arg){enabled(false);google.script.run.withSuccessHandler(s=>{enabled(true);if(s)render(s);else refresh();}).withFailureHandler(e=>{enabled(true);document.getElementById('msg').textContent=e.message;})[fn](arg);}
function setup(){loaded=false;call('bhSetup');}
function openTab(fn){google.script.run.withFailureHandler(e=>document.getElementById('msg').textContent=e.message)[fn]();}
function save(){if(!document.getElementById('allZones').checked&&!selectedZones().length){document.getElementById('msg').textContent='Оберіть доменні зони.';return;}if(!selectedZones().every(z=>/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(z))){document.getElementById('msg').textContent='Зони вводьте без URL: co.uk, com.au';return;}zoneChanged();let c={};fields.forEach(k=>c[k]=document.getElementById(k).value);['rdapAll','fullCrawl','sitemaps'].forEach(k=>c[k]=document.getElementById(k).checked);call('bhSaveConfig',c);}
function clearResults(){if(confirm('Видалити ВСІ результати, кандидатів, ручну перевірку, чергу та історію обходу? Донори й налаштування залишаться.'))call('bhClear');}
function refresh(){if(busy||refreshing)return;refreshing=true;google.script.run.withSuccessHandler(s=>{refreshing=false;render(s);}).withFailureHandler(e=>{refreshing=false;document.getElementById('msg').textContent=e.message;}).bhStatus();}
const zoneGroups=[["Міжнародні", [["com", "🌐 .com"], ["net", "🌐 .net"], ["org", "🌐 .org"]]], ["Європа", [["at", "🇦🇹 Австрія"], ["be", "🇧🇪 Бельгія"], ["uk", "🇬🇧 Велика Британія"], ["gr", "🇬🇷 Греція"], ["dk", "🇩🇰 Данія"], ["ee", "🇪🇪 Естонія"], ["ie", "🇮🇪 Ірландія"], ["es", "🇪🇸 Іспанія"], ["it", "🇮🇹 Італія"], ["lt", "🇱🇹 Литва"], ["nl", "🇳🇱 Нідерланди"], ["de", "🇩🇪 Німеччина"], ["no", "🇳🇴 Норвегія"], ["pl", "🇵🇱 Польща"], ["pt", "🇵🇹 Португалія"], ["ro", "🇷🇴 Румунія"], ["sk", "🇸🇰 Словаччина"], ["si", "🇸🇮 Словенія"], ["hu", "🇭🇺 Угорщина"], ["fi", "🇫🇮 Фінляндія"], ["fr", "🇫🇷 Франція"], ["cz", "🇨🇿 Чехія"], ["ch", "🇨🇭 Швейцарія"], ["se", "🇸🇪 Швеція"]]], ["Північна Америка", [["ca", "🇨🇦 Канада"]]], ["Південна Америка", [["ve", "🇻🇪 Венесуела"], ["ec", "🇪🇨 Еквадор"], ["pe", "🇵🇪 Перу"]]], ["Океанія", [["au", "🇦🇺 Австралія"], ["nz", "🇳🇿 Нова Зеландія"]]]];
function zoneBoxes(){return Array.from(document.querySelectorAll('[data-zone]'));}
function zoneTokens(v){return Array.from(new Set(String(v||'').toLowerCase().split(/[\s,;]+/).map(z=>z.replace(/^\./,'')).filter(Boolean)));}
function selectedZones(){return Array.from(new Set(zoneBoxes().filter(b=>b.checked).map(b=>b.value).concat(zoneTokens(document.getElementById('customZones').value))));}
function zoneChanged(){const all=document.getElementById('allZones').checked;const zones=selectedZones();document.getElementById('tlds').value=all?'':zones.join(',');document.getElementById('zonePicker').hidden=all;document.getElementById('zoneSummary').textContent=all?'Усі зони без фільтра':zones.length?'Обрано: '+zones.map(z=>'.'+z).join(', '):'Зони не обрано — збереження заблоковано.';}
function loadZones(value){const wanted=zoneTokens(value),known=zoneBoxes().map(b=>b.value);zoneBoxes().forEach(b=>b.checked=wanted.includes(b.value));document.getElementById('customZones').value=wanted.filter(z=>!known.includes(z)).join(',');document.getElementById('allZones').checked=!wanted.length;zoneChanged();}
function selectZones(preset){loadZones(preset==='none'?'':('com,net,org'+(preset==='base'?'':','+preset)));document.getElementById('allZones').checked=false;zoneChanged();}
function mountZones(){const parent=document.getElementById('zoneGroups');zoneGroups.forEach(([title,items],i)=>{const group=document.createElement('details');group.open=i===0;const summary=document.createElement('summary');summary.textContent=title;group.appendChild(summary);items.forEach(([zone,label])=>{const row=document.createElement('label');row.className='check';const box=document.createElement('input');box.type='checkbox';box.value=zone;box.setAttribute('data-zone',zone);box.onchange=zoneChanged;row.appendChild(box);row.appendChild(document.createTextNode(label+(i===0?'':' — .'+zone)));group.appendChild(row);});parent.appendChild(group);});loadZones('com,net,org');}
mountZones();
refresh();setInterval(refresh,60000);
</script></body></html>
