MediaWiki:ScmcScanner.js: различия между версиями
Страница интерфейса MediaWiki
Дополнительные действия
Defer (обсуждение | вклад) Новая страница: «(function () { if (!document.querySelector('.scmc-link-scanner')) return; var STATUS = { waiting: 'Ожидает запуска', scanning: 'Сканирование...', done: 'Готово', error: 'Ошибка' }; var EXCLUDED_PREFIXES = [ 'Файл:', 'File:', 'Категория:', 'Category:', 'Шаблон:', 'Template:', 'Участник:',...» |
Defer (обсуждение | вклад) Нет описания правки |
||
| (не показано 6 промежуточных версий этой же участницы) | |||
| Строка 1: | Строка 1: | ||
(function () { | (function () { | ||
var scannerRoot = document.querySelector('.scmc-link-scanner'); | |||
if (!scannerRoot) return; | |||
var EXCLUDED_PREFIXES = [ | var EXCLUDED_PREFIXES = [ | ||
| Строка 32: | Строка 26: | ||
return String(title || '') | return String(title || '') | ||
.replace(/_/g, ' ') | .replace(/_/g, ' ') | ||
.replace(/\s+/g, ' ') | |||
.trim(); | .trim(); | ||
} | } | ||
function | function titleKey(title) { | ||
return normalizeTitle(title).toLowerCase(); | |||
} | |||
function titleToUrl(title) { | |||
return mw.util.getUrl(title); | return mw.util.getUrl(title); | ||
} | } | ||
function titleToEditUrl(title) { | |||
return mw.util.getUrl(title, { action: 'edit' }); | |||
} | |||
function makePageLink(title, text, className) { | |||
var link = document.createElement('a'); | |||
link.href = titleToUrl(title); | |||
link.textContent = text || title; | |||
if (className) { | |||
link.className = className; | |||
} | |||
return link; | |||
} | |||
function makeEditLink(title, text, className) { | |||
var link = document.createElement('a'); | |||
link.href = titleToEditUrl(title); | |||
link.textContent = text || 'код'; | |||
if (className) { | |||
link.className = className; | |||
} | } | ||
function | link.target = '_blank'; | ||
link.rel = 'noopener'; | |||
return link; | |||
} | |||
function makeEl(tag, className, text) { | |||
var el = document.createElement(tag); | var el = document.createElement(tag); | ||
if (className) el.className = className; | if (className) el.className = className; | ||
| Строка 57: | Строка 76: | ||
} | } | ||
function | function makeButton(text, className) { | ||
var | var btn = document.createElement('button'); | ||
btn.type = 'button'; | |||
btn.className = className || 'scmc-scan-btn'; | |||
btn.textContent = text; | |||
return | return btn; | ||
} | |||
function splitDataList(value) { | |||
return String(value || '') | |||
.split('|') | |||
.map(normalizeTitle) | |||
.filter(Boolean); | |||
} | |||
function uniqueSorted(list) { | |||
var seen = {}; | |||
list.forEach(function (item) { | |||
var title = normalizeTitle(item); | |||
if (title) seen[titleKey(title)] = title; | |||
}); | |||
return Object.keys(seen) | |||
.map(function (key) { | |||
return seen[key]; | |||
}) | |||
.sort(function (a, b) { | |||
return a.localeCompare(b, 'ru'); | |||
}); | |||
} | |||
function chunkArray(items, size) { | |||
var chunks = []; | |||
for (var i = 0; i < items.length; i += size) { | |||
chunks.push(items.slice(i, i + size)); | |||
} | |||
return chunks; | |||
} | |||
function extractAttr(tag, names) { | |||
for (var i = 0; i < names.length; i++) { | |||
var name = names[i]; | |||
var re = new RegExp(name + '\\s*=\\s*(["\\\'])(.*?)\\1', 'i'); | |||
var match = tag.match(re); | |||
if (match) { | |||
return match[2]; | |||
} | |||
} | |||
return ''; | |||
} | |||
function isStopCatalogRow(tag) { | |||
var scanValue = normalizeTitle(extractAttr(tag, ['data-scan', 'data_scan'])).toLowerCase(); | |||
var classValue = ' ' + normalizeTitle(extractAttr(tag, ['class'])).toLowerCase() + ' '; | |||
return scanValue === 'stop' || | |||
classValue.indexOf(' scmc-scan-stop ') !== -1 || | |||
classValue.indexOf(' scan-stop ') !== -1 || | |||
classValue.indexOf(' stop ') !== -1; | |||
} | |||
function encodeAttr(value) { | |||
return String(value || '') | |||
.replace(/&/g, '&') | |||
.replace(/"/g, '"') | |||
.replace(/</g, '<') | |||
.replace(/>/g, '>'); | |||
} | |||
function getLevelNumberFromClass(className) { | |||
var match = String(className || '').match(/\bscmc-level-([1-5])\b/); | |||
return match ? parseInt(match[1], 10) : 1; | |||
} | |||
function normalizeLevel(level) { | |||
level = parseInt(level, 10); | |||
if (!level || level < 1) return 1; | |||
if (level > 5) return 5; | |||
return level; | |||
} | |||
function cleanCatalogClass(level) { | |||
return 'scmc-catalog-row scmc-level-' + normalizeLevel(level); | |||
} | |||
function titleFromPage(page) { | |||
return normalizeTitle(page).replace(/_/g, ' '); | |||
} | |||
function buildCatalogRowLine(data) { | |||
var page = normalizeTitle(data.page); | |||
var title = normalizeTitle(data.title || titleFromPage(page)); | |||
var section = normalizeTitle(data.section); | |||
var level = normalizeLevel(data.level); | |||
var status = String(data.status || '').trim(); | |||
var scan = data.scan === 'stop' ? 'stop' : ''; | |||
var note = normalizeTitle(data.note || ''); | |||
var out = '<div class="' + encodeAttr(cleanCatalogClass(level)) + '"'; | |||
out += ' data-section="' + encodeAttr(section) + '"'; | |||
out += ' data-page="' + encodeAttr(page) + '"'; | |||
out += ' data-title="' + encodeAttr(title) + '"'; | |||
if (scan) { | |||
out += ' data-scan="stop"'; | |||
} | |||
out += ' data-status="' + encodeAttr(status) + '"'; | |||
if (note) { | |||
out += ' data-note="' + encodeAttr(note) + '"'; | |||
} | |||
if (/^https?:\/\//i.test(page)) { | |||
out += '>[' + page + ' ' + title + ']</div>'; | |||
} else { | |||
out += '>[[' + page + '|' + title + ']]</div>'; | |||
} | |||
return out; | |||
} | } | ||
| Строка 69: | Строка 208: | ||
} | } | ||
function uniqueSorted( | function resolveTitles(api, titles) { | ||
var | var unique = uniqueSorted(titles); | ||
var chunks = chunkArray(unique, 45); | |||
var finalResult = {}; | |||
var allRedirects = {}; | |||
function followRedirect(title, redirectMap) { | |||
var current = normalizeTitle(title); | |||
var guard = 0; | |||
while (redirectMap[titleKey(current)] && guard < 10) { | |||
current = redirectMap[titleKey(current)]; | |||
guard++; | |||
} | |||
return current; | |||
} | |||
function processChunk(chunk) { | |||
if (!chunk.length) return $.Deferred().resolve().promise(); | |||
return api.get({ | |||
action: 'query', | |||
titles: chunk.join('|'), | |||
redirects: 1, | |||
formatversion: 2 | |||
}).then(function (data) { | |||
var redirectMap = {}; | |||
var pagesByKey = {}; | |||
if (data.query && data.query.redirects) { | |||
data.query.redirects.forEach(function (redirect) { | |||
var from = normalizeTitle(redirect.from); | |||
var to = normalizeTitle(redirect.to); | |||
redirectMap[titleKey(from)] = to; | |||
allRedirects[from] = to; | |||
}); | |||
} | |||
if (data.query && data.query.pages) { | |||
data.query.pages.forEach(function (page) { | |||
var pageTitle = normalizeTitle(page.title); | |||
pagesByKey[titleKey(pageTitle)] = { | |||
title: pageTitle, | |||
exists: page.missing === undefined | |||
}; | |||
}); | |||
} | |||
chunk.forEach(function (requested) { | |||
var requestedTitle = normalizeTitle(requested); | |||
var finalTitle = followRedirect(requestedTitle, redirectMap); | |||
var page = pagesByKey[titleKey(finalTitle)]; | |||
finalResult[titleKey(requestedTitle)] = { | |||
requestedTitle: requestedTitle, | |||
finalTitle: page ? page.title : finalTitle, | |||
exists: page ? page.exists : null | |||
}; | |||
}); | |||
}); | |||
} | |||
var chain = $.Deferred().resolve().promise(); | |||
chunks.forEach(function (chunk) { | |||
chain = chain.then(function () { | |||
return processChunk(chunk); | |||
}); | |||
}); | }); | ||
return | |||
return | return chain.then(function () { | ||
return { | |||
items: finalResult, | |||
redirects: allRedirects | |||
}; | |||
}); | }); | ||
} | } | ||
function | function loadCatalog(api, catalogTitle) { | ||
return api.get({ | |||
action: 'parse', | |||
page: catalogTitle, | |||
prop: 'wikitext', | |||
formatversion: 2 | |||
}).then(function (data) { | |||
var wikitext = ''; | |||
if (data.parse && typeof data.parse.wikitext === 'string') { | |||
wikitext = data.parse.wikitext; | |||
} else if (data.parse && data.parse.wikitext && data.parse.wikitext['*']) { | |||
wikitext = data.parse.wikitext['*']; | |||
} | |||
var rawPages = []; | |||
var rawStopPages = {}; | |||
var catalogRows = []; | |||
var rowRe = /<div\b[^>]*scmc-catalog-row[^>]*>/gi; | |||
var match; | |||
while ((match = rowRe.exec(wikitext)) !== null) { | |||
var tag = match[0]; | |||
var className = extractAttr(tag, ['class']); | |||
var page = normalizeTitle(extractAttr(tag, ['data-page', 'data_page'])); | |||
var title = normalizeTitle(extractAttr(tag, ['data-title', 'data_title'])) || page; | |||
var section = normalizeTitle(extractAttr(tag, ['data-section', 'data_section'])); | |||
var level = getLevelNumberFromClass(className); | |||
if (!page) continue; | |||
rawPages.push(page); | |||
var rowData = { | |||
page: page, | |||
title: title, | |||
section: section, | |||
level: level, | |||
position: catalogRows.length + 1, | |||
scan: isStopCatalogRow(tag) ? 'stop' : '', | |||
status: normalizeTitle(extractAttr(tag, ['data-status', 'data_status'])) | |||
}; | |||
catalogRows.push(rowData); | |||
if (rowData.scan === 'stop') { | |||
rawStopPages[titleKey(page)] = true; | |||
} | |||
} | |||
return resolveTitles(api, rawPages).then(function (resolved) { | |||
var knownPages = {}; | |||
var stopPages = {}; | |||
var redirects = resolved.redirects; | |||
var rowsByKey = {}; | |||
var sectionMap = {}; | |||
var sections = []; | |||
catalogRows.forEach(function (rowData) { | |||
var rawPage = rowData.page; | |||
var rawKey = titleKey(rawPage); | |||
var resolvedItem = resolved.items[rawKey]; | |||
var finalTitle = resolvedItem ? resolvedItem.finalTitle : rawPage; | |||
var finalKey = titleKey(finalTitle); | |||
var enriched = Object.assign({}, rowData, { | |||
finalTitle: finalTitle | |||
}); | |||
knownPages[rawKey] = rawPage; | |||
knownPages[finalKey] = finalTitle; | |||
rowsByKey[rawKey] = enriched; | |||
rowsByKey[finalKey] = enriched; | |||
if (rowData.section && !sectionMap[rowData.section]) { | |||
sectionMap[rowData.section] = true; | |||
sections.push(rowData.section); | |||
} | |||
if (rawStopPages[rawKey]) { | |||
stopPages[rawKey] = true; | |||
stopPages[finalKey] = true; | |||
} | |||
}); | |||
var preferredOrder = [ | |||
'Морпехи', | |||
'Ксеноморфы', | |||
'Правила и процедуры', | |||
'Справочник', | |||
'Лор и материалы', | |||
'Общее' | |||
]; | |||
sections.sort(function (a, b) { | |||
var ai = preferredOrder.indexOf(a); | |||
var bi = preferredOrder.indexOf(b); | |||
if (ai !== -1 || bi !== -1) { | |||
if (ai === -1) return 1; | |||
if (bi === -1) return -1; | |||
return ai - bi; | |||
} | |||
return a.localeCompare(b, 'ru'); | |||
}); | |||
return { | |||
knownPages: knownPages, | |||
stopPages: stopPages, | |||
redirects: redirects, | |||
catalogRows: catalogRows, | |||
rowsByKey: rowsByKey, | |||
sections: sections, | |||
count: Object.keys(knownPages).length | |||
}; | |||
}); | |||
}); | |||
} | } | ||
function | function buildScanner(container) { | ||
var | var rootTitle = normalizeTitle(container.getAttribute('data-root') || 'Marine_Corps'); | ||
var | var catalogTitle = normalizeTitle(container.getAttribute('data-catalog') || 'MC:Страницы'); | ||
var maxDepth = parseInt(container.getAttribute('data-depth') || '5', 10); | |||
var maxPages = parseInt(container.getAttribute('data-max-pages') || '300', 10); | |||
var extraExcludedTitles = splitDataList(container.getAttribute('data-exclude')); | |||
var latestCatalogData = null; | |||
function | function getCatalogSource(api) { | ||
return api.get({ | |||
action: 'query', | action: 'query', | ||
prop: ' | prop: 'revisions', | ||
titles: | titles: catalogTitle, | ||
rvprop: 'content', | |||
rvslots: 'main', | |||
formatversion: 2 | |||
}).then(function (data) { | |||
var pageData = data.query && data.query.pages ? data.query.pages[0] : null; | |||
var rev = pageData && pageData.revisions ? pageData.revisions[0] : null; | |||
if (rev && rev.slots && rev.slots.main && typeof rev.slots.main.content === 'string') { | |||
return rev.slots.main.content; | |||
} | |||
if (rev && typeof rev.content === 'string') { | |||
return rev.content; | |||
} | |||
throw new Error('Не удалось прочитать код каталога.'); | |||
}); | |||
} | |||
function saveCatalogSource(api, text) { | |||
return api.postWithToken('csrf', { | |||
action: 'edit', | |||
title: catalogTitle, | |||
text: text, | |||
summary: 'Добавление страницы в каталог Marine Corps из сканера', | |||
minor: true, | |||
formatversion: 2 | formatversion: 2 | ||
}); | |||
} | |||
function getCatalogRowsFromSource(source) { | |||
var rows = []; | |||
var re = /<div\b[^>]*\bscmc-catalog-row\b[^>]*>[\s\S]*?<\/div>/gi; | |||
var match; | |||
while ((match = re.exec(source)) !== null) { | |||
var full = match[0]; | |||
var open = full.match(/^<div\b[^>]*>/i); | |||
if (!open) continue; | |||
var tag = open[0]; | |||
var page = normalizeTitle(extractAttr(tag, ['data-page', 'data_page'])); | |||
var title = normalizeTitle(extractAttr(tag, ['data-title', 'data_title'])) || page; | |||
var section = normalizeTitle(extractAttr(tag, ['data-section', 'data_section'])); | |||
var level = getLevelNumberFromClass(extractAttr(tag, ['class'])); | |||
if (!page) continue; | |||
rows.push({ | |||
start: match.index, | |||
end: match.index + full.length, | |||
full: full, | |||
data: { | |||
page: page, | |||
title: title, | |||
section: section, | |||
level: level, | |||
position: rows.length + 1 | |||
} | |||
}); | |||
} | |||
return rows; | |||
} | |||
function normalizePlaceInput(value, count) { | |||
var text = String(value || '').trim(); | |||
if (!text) { | |||
return count; | |||
} | |||
if (!/^\d+$/.test(text)) { | |||
throw new Error('Место должно быть целым числом или пустым полем.'); | |||
} | |||
var place = parseInt(text, 10); | |||
if (!place || place < 1) { | |||
throw new Error('Место должно быть числом от 1 или пустым полем.'); | |||
} | |||
if (place > count + 1) { | |||
return count; | |||
} | |||
return place - 1; | |||
} | |||
function findParentDefaults(info) { | |||
var result = { | |||
section: '', | |||
level: '', | |||
place: '' | |||
}; | }; | ||
if ( | if (!latestCatalogData || !info || !info.from) { | ||
return result; | |||
} | } | ||
var parent = latestCatalogData.rowsByKey[titleKey(info.from)]; | |||
if (!parent) { | |||
return result; | |||
} | |||
result.section = parent.section || ''; | |||
result.level = String(Math.min(5, normalizeLevel(parent.level + 1))); | |||
if (parent.position) { | |||
result.place = String(parent.position + 1); | |||
} | |||
return result; | |||
} | |||
function showModalError(modal, text) { | |||
var error = modal.querySelector('.scmc-modal-error'); | |||
if (error) error.textContent = text || ''; | |||
} | |||
function closeModal() { | |||
var old = document.querySelector('.scmc-modal-backdrop'); | |||
if (old) old.remove(); | |||
} | |||
function openAddCandidateModal(info, button) { | |||
closeModal(); | |||
var defaults = findParentDefaults(info); | |||
var backdrop = makeEl('div', 'scmc-modal-backdrop'); | |||
var modal = makeEl('div', 'scmc-modal'); | |||
var title = makeEl('div', 'scmc-modal-title', 'Добавить страницу в каталог'); | |||
var subtitle = makeEl('div', 'scmc-modal-subtitle', 'Кандидат найден из: ' + (info.from || 'неизвестно')); | |||
var form = makeEl('div', 'scmc-modal-form'); | |||
var error = makeEl('div', 'scmc-modal-error'); | |||
var actions = makeEl('div', 'scmc-modal-actions'); | |||
var cancelButton = makeButton('Отмена', 'scmc-modal-btn scmc-modal-btn-secondary'); | |||
var submitButton = makeButton('Добавить', 'scmc-modal-btn'); | |||
function makeField(labelText, input) { | |||
var field = makeEl('label', 'scmc-modal-field'); | |||
var label = makeEl('span', '', labelText); | |||
field.appendChild(label); | |||
field.appendChild(input); | |||
return field; | |||
} | |||
var pageInput = document.createElement('input'); | |||
pageInput.type = 'text'; | |||
pageInput.value = info.finalTitle || info.requestedTitle || ''; | |||
pageInput.readOnly = true; | |||
var titleInput = document.createElement('input'); | |||
titleInput.type = 'text'; | |||
titleInput.value = titleFromPage(info.finalTitle || info.requestedTitle || ''); | |||
titleInput.placeholder = 'Название страницы в каталоге'; | |||
var sectionSelect = document.createElement('select'); | |||
sectionSelect.appendChild(new Option('Выберите раздел', '')); | |||
(latestCatalogData && latestCatalogData.sections ? latestCatalogData.sections : []).forEach(function (section) { | |||
sectionSelect.appendChild(new Option(section, section)); | |||
}); | |||
sectionSelect.value = defaults.section || ''; | |||
var levelSelect = document.createElement('select'); | |||
levelSelect.appendChild(new Option('Выберите уровень', '')); | |||
[1, 2, 3, 4, 5].forEach(function (level) { | |||
levelSelect.appendChild(new Option('Уровень ' + level, String(level))); | |||
}); | |||
levelSelect.value = defaults.level || ''; | |||
var statusSelect = document.createElement('select'); | |||
statusSelect.appendChild(new Option('Выберите статус', '')); | |||
statusSelect.appendChild(new Option('🟢 Готово', 'green')); | |||
statusSelect.appendChild(new Option('🟡 Нужно обновить', 'yellow')); | |||
statusSelect.appendChild(new Option('🔴 Нет страницы', 'red')); | |||
statusSelect.appendChild(new Option('🔵 Заморожено', 'blue')); | |||
var placeInput = document.createElement('input'); | |||
placeInput.type = 'text'; | |||
placeInput.inputMode = 'numeric'; | |||
placeInput.placeholder = 'Пусто = в конец, число = место в каталоге'; | |||
placeInput.value = defaults.place || ''; | |||
var scanLabel = makeEl('label', 'scmc-modal-check'); | |||
var scanInput = document.createElement('input'); | |||
scanInput.type = 'checkbox'; | |||
scanLabel.appendChild(scanInput); | |||
scanLabel.appendChild(document.createTextNode(' Scan OFF')); | |||
var noteInput = document.createElement('textarea'); | |||
noteInput.rows = 3; | |||
noteInput.placeholder = 'Заметка, если нужна'; | |||
form.appendChild(makeField('Страница', pageInput)); | |||
form.appendChild(makeField('Название', titleInput)); | |||
form.appendChild(makeField('Раздел *', sectionSelect)); | |||
form.appendChild(makeField('Уровень *', levelSelect)); | |||
form.appendChild(makeField('Статус *', statusSelect)); | |||
form.appendChild(makeField('Место', placeInput)); | |||
form.appendChild(scanLabel); | |||
form.appendChild(makeField('Заметка', noteInput)); | |||
actions.appendChild(cancelButton); | |||
actions.appendChild(submitButton); | |||
modal.appendChild(title); | |||
modal.appendChild(subtitle); | |||
modal.appendChild(form); | |||
modal.appendChild(error); | |||
modal.appendChild(actions); | |||
backdrop.appendChild(modal); | |||
document.body.appendChild(backdrop); | |||
cancelButton.addEventListener('click', closeModal); | |||
backdrop.addEventListener('click', function (event) { | |||
if (event.target === backdrop) closeModal(); | |||
}); | |||
submitButton.addEventListener('click', function () { | |||
var values = { | |||
page: normalizeTitle(pageInput.value), | |||
title: normalizeTitle(titleInput.value) || titleFromPage(pageInput.value), | |||
section: normalizeTitle(sectionSelect.value), | |||
level: levelSelect.value, | |||
status: statusSelect.value, | |||
place: placeInput.value, | |||
scan: scanInput.checked ? 'stop' : '', | |||
note: normalizeTitle(noteInput.value) | |||
}; | |||
if (!values.section) { | |||
showModalError(modal, 'Выберите раздел.'); | |||
return; | |||
} | |||
if (!values.level) { | |||
showModalError(modal, 'Выберите уровень.'); | |||
return; | |||
} | |||
if (!values.status) { | |||
showModalError(modal, 'Выберите статус.'); | |||
return; | |||
} | |||
showModalError(modal, ''); | |||
submitButton.disabled = true; | |||
cancelButton.disabled = true; | |||
submitButton.textContent = 'Сохраняю...'; | |||
addCandidateToCatalog(values).then(function () { | |||
closeModal(); | |||
if (button) { | |||
button.textContent = '✓ Добавлено'; | |||
button.disabled = true; | |||
button.classList.add('is-added'); | |||
} | |||
}).catch(function (err) { | |||
submitButton.disabled = false; | |||
cancelButton.disabled = false; | |||
submitButton.textContent = 'Добавить'; | |||
showModalError(modal, err && err.message ? err.message : 'Не удалось добавить страницу. Проверь права редактора.'); | |||
}); | |||
}); | |||
} | |||
function rebuildLatestCatalogIndexes() { | |||
if (!latestCatalogData || !latestCatalogData.catalogRows) return; | |||
latestCatalogData.rowsByKey = latestCatalogData.rowsByKey || {}; | |||
latestCatalogData.catalogRows.forEach(function (rowData, index) { | |||
rowData.position = index + 1; | |||
latestCatalogData.rowsByKey[titleKey(rowData.page)] = rowData; | |||
if (rowData.finalTitle) { | |||
latestCatalogData.rowsByKey[titleKey(rowData.finalTitle)] = rowData; | |||
} | } | ||
}); | |||
} | |||
function addCandidateToCatalog(values) { | |||
var api = getApi(); | |||
return getCatalogSource(api).then(function (source) { | |||
var rows = getCatalogRowsFromSource(source); | |||
if (!rows.length) { | |||
throw new Error('Не нашёл строки каталога в коде страницы.'); | |||
} | } | ||
var pageKey = titleKey(values.page); | |||
return | var duplicate = rows.some(function (row) { | ||
return titleKey(row.data.page) === pageKey; | |||
}); | |||
if (duplicate) { | |||
throw new Error('Эта страница уже есть в каталоге.'); | |||
} | } | ||
return { | var insertIndex = normalizePlaceInput(values.place, rows.length); | ||
title: | var newLine = buildCatalogRowLine(values); | ||
var lines = rows.map(function (row) { | |||
return row.full; | |||
}); | |||
}; | |||
lines.splice(insertIndex, 0, newLine); | |||
var first = rows[0]; | |||
var last = rows[rows.length - 1]; | |||
var newText = source.slice(0, first.start) + lines.join('\n') + source.slice(last.end); | |||
return saveCatalogSource(api, newText).then(function () { | |||
var rowData = { | |||
page: values.page, | |||
title: values.title, | |||
section: values.section, | |||
level: normalizeLevel(values.level), | |||
position: insertIndex + 1, | |||
scan: values.scan, | |||
status: values.status, | |||
finalTitle: values.page | |||
}; | |||
if (latestCatalogData) { | |||
latestCatalogData.catalogRows.splice(insertIndex, 0, rowData); | |||
latestCatalogData.knownPages[titleKey(values.page)] = values.page; | |||
latestCatalogData.rowsByKey[titleKey(values.page)] = rowData; | |||
if (values.scan === 'stop') { | |||
latestCatalogData.stopPages[titleKey(values.page)] = true; | |||
} | |||
if (latestCatalogData.sections.indexOf(values.section) === -1) { | |||
latestCatalogData.sections.push(values.section); | |||
} | |||
rebuildLatestCatalogIndexes(); | |||
} | |||
}); | |||
}); | |||
} | |||
function isExcludedTitle(title) { | |||
var clean = normalizeTitle(title); | |||
if (!clean) return true; | |||
if (clean.indexOf('#') !== -1) { | |||
clean = normalizeTitle(clean.split('#')[0]); | |||
} | |||
if (extraExcludedTitles.some(function (excluded) { | |||
return titleKey(excluded) === titleKey(clean); | |||
})) { | |||
return true; | |||
} | |||
return EXCLUDED_PREFIXES.some(function (prefix) { | |||
return clean.indexOf(prefix) === 0; | |||
}); | }); | ||
} | } | ||
return request(); | function isValidLink(link) { | ||
if (!link || !link.title) return false; | |||
if (typeof link.ns === 'number' && link.ns !== 0) return false; | |||
if (isExcludedTitle(link.title)) return false; | |||
return true; | |||
} | |||
function getPageLinks(api, requestedTitle) { | |||
var links = []; | |||
var redirects = []; | |||
var normalizedRequested = normalizeTitle(requestedTitle); | |||
function request(plcontinue) { | |||
var params = { | |||
action: 'query', | |||
prop: 'links', | |||
titles: normalizedRequested, | |||
pllimit: 'max', | |||
redirects: 1, | |||
formatversion: 2 | |||
}; | |||
if (plcontinue) { | |||
params.plcontinue = plcontinue; | |||
} | |||
return api.get(params).then(function (data) { | |||
if (data.query && data.query.redirects) { | |||
data.query.redirects.forEach(function (redirect) { | |||
redirects.push({ | |||
from: normalizeTitle(redirect.from), | |||
to: normalizeTitle(redirect.to) | |||
}); | |||
}); | |||
} | |||
var pages = data.query && data.query.pages ? data.query.pages : []; | |||
var page = pages[0]; | |||
if (!page) { | |||
return { | |||
requestedTitle: normalizedRequested, | |||
finalTitle: normalizedRequested, | |||
exists: false, | |||
links: [], | |||
redirects: redirects | |||
}; | |||
} | |||
var finalTitle = normalizeTitle(page.title || normalizedRequested); | |||
if (page.missing !== undefined) { | |||
return { | |||
requestedTitle: normalizedRequested, | |||
finalTitle: finalTitle, | |||
exists: false, | |||
links: [], | |||
redirects: redirects | |||
}; | |||
} | |||
if (typeof page.ns === 'number' && page.ns !== 0) { | |||
return { | |||
requestedTitle: normalizedRequested, | |||
finalTitle: finalTitle, | |||
exists: true, | |||
links: [], | |||
redirects: redirects | |||
}; | |||
} | |||
if (page.links) { | |||
page.links.forEach(function (link) { | |||
if (isValidLink(link)) { | |||
links.push(normalizeTitle(link.title)); | |||
} | |||
}); | |||
} | |||
if (data.continue && data.continue.plcontinue) { | |||
return request(data.continue.plcontinue); | |||
} | |||
return { | |||
requestedTitle: normalizedRequested, | |||
finalTitle: finalTitle, | |||
exists: true, | |||
links: uniqueSorted(links), | |||
redirects: redirects | |||
}; | |||
}); | |||
} | |||
return request(); | |||
} | |||
var header = | var header = makeEl('div', 'scmc-scan-header'); | ||
var title = | var title = makeEl('div', 'scmc-scan-title', 'Сканер ссылок Marine Corps'); | ||
var subtitle = | var subtitle = makeEl( | ||
'div', | |||
'scmc-scan-subtitle', | |||
'Старт: ' + rootTitle + ' · каталог: ' + catalogTitle + ' · глубина: ' + maxDepth + ' · лимит страниц: ' + maxPages | |||
); | |||
header.appendChild(title); | header.appendChild(title); | ||
header.appendChild(subtitle); | header.appendChild(subtitle); | ||
var controls = | var controls = makeEl('div', 'scmc-scan-controls'); | ||
var | var scanBtn = makeButton('Сканировать ссылки'); | ||
var | var clearBtn = makeButton('Очистить', 'scmc-scan-btn scmc-scan-btn-secondary'); | ||
controls.appendChild( | controls.appendChild(scanBtn); | ||
controls.appendChild( | controls.appendChild(clearBtn); | ||
var | var statusBox = makeEl('div', 'scmc-scan-status', 'Ожидает запуска'); | ||
var | var statsBox = makeEl('div', 'scmc-scan-stats'); | ||
var | var resultWrap = makeEl('div', 'scmc-scan-result'); | ||
var treeBox = | var treeBox = makeEl('div', 'scmc-scan-box'); | ||
var listBox = | var listBox = makeEl('div', 'scmc-scan-box'); | ||
var candidatesBox = makeEl('div', 'scmc-scan-box scmc-scan-box-wide'); | |||
var redirectBox = makeEl('div', 'scmc-scan-box scmc-scan-box-wide'); | |||
resultWrap.appendChild(treeBox); | |||
resultWrap.appendChild(listBox); | |||
resultWrap.appendChild(candidatesBox); | |||
resultWrap.appendChild(redirectBox); | |||
container.innerHTML = ''; | container.innerHTML = ''; | ||
container.appendChild(header); | container.appendChild(header); | ||
container.appendChild(controls); | container.appendChild(controls); | ||
container.appendChild( | container.appendChild(statusBox); | ||
container.appendChild( | container.appendChild(statsBox); | ||
container.appendChild( | container.appendChild(resultWrap); | ||
function setStatus(text, mode) { | function setStatus(text, mode) { | ||
statusBox.textContent = text; | |||
statusBox.setAttribute('data-mode', mode || ''); | |||
} | } | ||
function | function clearResults() { | ||
treeBox.innerHTML = ''; | treeBox.innerHTML = ''; | ||
listBox.innerHTML = ''; | listBox.innerHTML = ''; | ||
candidatesBox.innerHTML = ''; | |||
setStatus( | redirectBox.innerHTML = ''; | ||
statsBox.innerHTML = ''; | |||
setStatus('Ожидает запуска', ''); | |||
} | } | ||
function | function renderTree(root, childrenMap, pageInfo, firstParent, alsoLinkedFrom) { | ||
treeBox.innerHTML = ''; | |||
treeBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Дерево известных страниц')); | |||
function makeNode(title, path) { | |||
var key = titleKey(title); | |||
var info = pageInfo[key]; | |||
var node = makeEl('div', 'scmc-scan-node'); | |||
var line = makeEl('div', 'scmc-scan-node-line'); | |||
var link = document.createElement('a'); | |||
link.href = titleToUrl(title); | |||
link.textContent = title; | |||
line.appendChild(link); | |||
if (info) { | |||
line.appendChild(makeEl('span', 'scmc-scan-depth', 'ур. ' + info.depth)); | |||
if (info.requestedTitle && titleKey(info.requestedTitle) !== titleKey(info.finalTitle)) { | |||
line.appendChild(makeEl('span', 'scmc-scan-redirect-mini', info.requestedTitle + ' → ' + info.finalTitle)); | |||
} | |||
if (info.candidate) { | |||
line.appendChild(makeEl('span', 'scmc-scan-candidate', 'кандидат')); | |||
} | |||
if (info.catalogStop) { | |||
line.appendChild(makeEl('span', 'scmc-scan-stopped', 'остановлено каталогом')); | |||
} | |||
if (info.depthStop) { | |||
line.appendChild(makeEl('span', 'scmc-scan-stopped', 'предел глубины')); | |||
} | |||
if (info.exists === false) { | |||
line.appendChild(makeEl('span', 'scmc-scan-missing', 'нет страницы')); | |||
} | |||
} | |||
node.appendChild(line); | |||
if (path[key]) { | |||
node.appendChild(makeEl('div', 'scmc-scan-loop', '↳ уже встречалась выше')); | |||
return node; | |||
} | |||
var extraLinks = alsoLinkedFrom[key] || []; | |||
if (extraLinks.length) { | |||
node.appendChild(makeEl('div', 'scmc-scan-also', 'Ещё ссылки из: ' + extraLinks.join(', '))); | |||
} | |||
var nextPath = Object.assign({}, path); | |||
nextPath[key] = true; | |||
var children = childrenMap[key] || []; | |||
if (children.length) { | |||
var childrenWrap = makeEl('div', 'scmc-scan-children'); | |||
children.forEach(function (childTitle) { | |||
var childKey = titleKey(childTitle); | |||
if (firstParent[childKey] && titleKey(firstParent[childKey]) !== key) { | |||
var refNode = makeEl('div', 'scmc-scan-node'); | |||
var refLine = makeEl('div', 'scmc-scan-node-line scmc-scan-node-ref'); | |||
var refLink = document.createElement('a'); | |||
refLink.href = titleToUrl(childTitle); | |||
refLink.textContent = childTitle; | |||
refLine.appendChild(refLink); | |||
refLine.appendChild(makeEl('span', 'scmc-scan-ref', 'уже найдено в: ' + firstParent[childKey])); | |||
refNode.appendChild(refLine); | |||
childrenWrap.appendChild(refNode); | |||
return; | |||
} | |||
childrenWrap.appendChild(makeNode(childTitle, nextPath)); | |||
}); | |||
node.appendChild(childrenWrap); | |||
} | |||
return node; | |||
} | |||
treeBox.appendChild(makeNode(root, {})); | |||
} | |||
function renderKnownList(pageInfo) { | |||
listBox.innerHTML = ''; | listBox.innerHTML = ''; | ||
listBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Известные страницы из каталога')); | |||
var | var pages = Object.keys(pageInfo) | ||
.map(function (key) { | |||
return pageInfo[key]; | |||
}) | |||
.filter(function (info) { | |||
return !info.candidate; | |||
}) | |||
.sort(function (a, b) { | |||
if (a.depth !== b.depth) return a.depth - b.depth; | |||
return a.finalTitle.localeCompare(b.finalTitle, 'ru'); | |||
}); | |||
if (!pages.length) { | |||
listBox.appendChild(makeEl('div', 'scmc-scan-empty', 'Пока пусто.')); | |||
return; | |||
} | |||
var list = makeEl('ol', 'scmc-scan-page-list'); | |||
pages.forEach(function (info) { | |||
var li = document.createElement('li'); | var li = document.createElement('li'); | ||
var | var link = document.createElement('a'); | ||
link.href = titleToUrl(info.finalTitle); | |||
link.textContent = info.finalTitle; | |||
li.appendChild(link); | |||
li.appendChild(makeEl('span', 'scmc-scan-depth', 'ур. ' + info.depth)); | |||
if (info.requestedTitle && titleKey(info.requestedTitle) !== titleKey(info.finalTitle)) { | |||
li.appendChild(makeEl('span', 'scmc-scan-redirect-mini', 'найдено как: ' + info.requestedTitle)); | |||
} | |||
if (info.catalogStop) { | |||
li.appendChild(makeEl('span', 'scmc-scan-stopped', 'остановлено каталогом')); | |||
} | |||
if (info.depthStop) { | |||
li.appendChild(makeEl('span', 'scmc-scan-stopped', 'предел глубины')); | |||
} | |||
list.appendChild(li); | list.appendChild(li); | ||
}); | }); | ||
listBox.appendChild(list); | listBox.appendChild(list); | ||
} | |||
function renderCandidates(candidates) { | |||
candidatesBox.innerHTML = ''; | |||
candidatesBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Кандидаты: ссылки не из каталога')); | |||
var pages = Object.keys(candidates) | |||
.map(function (key) { | |||
return candidates[key]; | |||
}) | |||
.sort(function (a, b) { | |||
if (a.depth !== b.depth) return a.depth - b.depth; | |||
return a.finalTitle.localeCompare(b.finalTitle, 'ru'); | |||
}); | |||
if ( | if (!pages.length) { | ||
candidatesBox.appendChild(makeEl('div', 'scmc-scan-empty', 'Новых кандидатов не найдено.')); | |||
return; | |||
} | } | ||
var list = makeEl('ol', 'scmc-scan-page-list'); | |||
pages.forEach(function (info) { | |||
var li = document.createElement('li'); | |||
var link = document.createElement('a'); | |||
var addButton = makeButton('+ В каталог', 'scmc-scan-add-btn'); | |||
link.href = titleToUrl(info.finalTitle); | |||
link.textContent = info.finalTitle; | |||
addButton.addEventListener('click', function () { | |||
openAddCandidateModal(info, addButton); | |||
}); | |||
li.appendChild(link); | |||
li.appendChild(makeEl('span', 'scmc-scan-depth', 'ур. ' + info.depth)); | |||
li.appendChild(makeEl('span', 'scmc-scan-candidate', 'не сканировалась')); | |||
if (info.from) { | |||
var fromRef = makeEl('span', 'scmc-scan-ref'); | |||
fromRef.appendChild(document.createTextNode('найдена из: ')); | |||
fromRef.appendChild(makePageLink(info.from, info.from, 'scmc-scan-ref-link')); | |||
fromRef.appendChild(document.createTextNode(' · ')); | |||
fromRef.appendChild(makeEditLink(info.from, 'код', 'scmc-scan-ref-link scmc-scan-ref-edit')); | |||
li.appendChild(fromRef); | |||
} | |||
if (info.requestedTitle && titleKey(info.requestedTitle) !== titleKey(info.finalTitle)) { | |||
li.appendChild(makeEl('span', 'scmc-scan-redirect-mini', info.requestedTitle + ' → ' + info.finalTitle)); | |||
} | |||
if ( | if (info.exists === false) { | ||
li.appendChild(makeEl('span', 'scmc-scan-missing', 'нет страницы')); | |||
} | } | ||
li.appendChild(addButton); | |||
list.appendChild(li); | |||
}); | |||
candidatesBox.appendChild(list); | |||
} | |||
function renderRedirects(redirectsFound) { | |||
redirectBox.innerHTML = ''; | |||
redirectBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Редиректы')); | |||
var keys = Object.keys(redirectsFound).sort(function (a, b) { | |||
return a.localeCompare(b, 'ru'); | |||
}); | |||
return | if (!keys.length) { | ||
redirectBox.appendChild(makeEl('div', 'scmc-scan-empty', 'Редиректы не найдены.')); | |||
return; | |||
} | } | ||
var list = makeEl('ol', 'scmc-scan-page-list'); | |||
keys.forEach(function (from) { | |||
var to = redirectsFound[from]; | |||
var li = document.createElement('li'); | |||
var fromLink = document.createElement('a'); | |||
fromLink.href = titleToUrl(from); | |||
fromLink.textContent = from; | |||
var toLink = document.createElement('a'); | |||
toLink.href = titleToUrl(to); | |||
toLink.textContent = to; | |||
li.appendChild(fromLink); | |||
li.appendChild(document.createTextNode(' → ')); | |||
li.appendChild(toLink); | |||
list.appendChild(li); | |||
}); | |||
redirectBox.appendChild(list); | |||
} | } | ||
function scan() { | function scan() { | ||
clearResults(); | |||
var api = getApi(); | var api = getApi(); | ||
scanBtn.disabled = true; | |||
clearBtn.disabled = true; | |||
setStatus('Читаю каталог: ' + catalogTitle, 'scanning'); | |||
loadCatalog(api, catalogTitle).then(function (catalog) { | |||
latestCatalogData = catalog; | |||
var knownPages = catalog.knownPages; | |||
var stopPages = catalog.stopPages; | |||
var redirectsFound = Object.assign({}, catalog.redirects); | |||
var queue = [{ | |||
requestedTitle: rootTitle, | |||
depth: 0, | |||
parent: null | |||
}]; | |||
var scanned = {}; | |||
var queued = {}; | |||
var pageInfo = {}; | |||
var candidates = {}; | |||
var childrenMap = {}; | |||
var firstParent = {}; | |||
var alsoLinkedFrom = {}; | |||
var stoppedByLimit = false; | |||
queued[titleKey(rootTitle)] = true; | |||
function addRedirects(redirects) { | |||
redirects.forEach(function (redirect) { | |||
if (redirect.from && redirect.to) { | |||
redirectsFound[redirect.from] = redirect.to; | |||
} | |||
}); | |||
} | } | ||
function addAlsoLinked(childTitle, parentTitle) { | |||
var childKey = titleKey(childTitle); | |||
if (!alsoLinkedFrom[childKey]) { | |||
alsoLinkedFrom[childKey] = []; | |||
} | |||
if (alsoLinkedFrom[childKey].indexOf(parentTitle) === -1) { | |||
alsoLinkedFrom[childKey].push(parentTitle); | |||
} | |||
} | } | ||
var | function upsertPageInfo(finalTitle, requestedTitle, depth, exists, options) { | ||
var finalKey = titleKey(finalTitle); | |||
var current = pageInfo[finalKey]; | |||
if (!current) { | |||
current = { | |||
return; | requestedTitle: requestedTitle || finalTitle, | ||
finalTitle: finalTitle, | |||
depth: depth, | |||
exists: exists | |||
}; | |||
pageInfo[finalKey] = current; | |||
} | |||
if (depth < current.depth) { | |||
current.depth = depth; | |||
} | |||
if (requestedTitle && titleKey(requestedTitle) !== titleKey(finalTitle)) { | |||
current.requestedTitle = requestedTitle; | |||
} | |||
if (exists === false) { | |||
current.exists = false; | |||
} | |||
if (options) { | |||
Object.keys(options).forEach(function (key) { | |||
current[key] = options[key]; | |||
}); | |||
} | |||
return current; | |||
} | } | ||
scanned[ | function step() { | ||
if (!queue.length) { | |||
finish(); | |||
return; | |||
} | |||
if (Object.keys(scanned).length >= maxPages) { | |||
stoppedByLimit = true; | |||
finish(); | |||
return; | |||
} | |||
var item = queue.shift(); | |||
var requestedTitle = normalizeTitle(item.requestedTitle); | |||
var requestedKey = titleKey(requestedTitle); | |||
if (scanned[requestedKey]) { | |||
step(); | |||
return; | |||
} | |||
scanned[requestedKey] = true; | |||
setStatus('Сканирую: ' + requestedTitle + ' · уровень ' + item.depth, 'scanning'); | |||
getPageLinks(api, requestedTitle).then(function (data) { | |||
addRedirects(data.redirects); | |||
var finalTitle = normalizeTitle(data.finalTitle || requestedTitle); | |||
var finalKey = titleKey(finalTitle); | |||
var isRoot = item.depth === 0; | |||
var isKnown = !!knownPages[finalKey] || !!knownPages[requestedKey] || isRoot; | |||
var isCatalogStop = !isRoot && (!!stopPages[finalKey] || !!stopPages[requestedKey]); | |||
upsertPageInfo(finalTitle, requestedTitle, item.depth, data.exists, { | |||
catalogStop: isCatalogStop | |||
}); | |||
if (!isKnown) { | |||
candidates[finalKey] = { | |||
requestedTitle: requestedTitle, | |||
finalTitle: finalTitle, | |||
depth: item.depth, | |||
exists: data.exists, | |||
from: item.parent || '', | |||
candidate: true | |||
}; | |||
upsertPageInfo(finalTitle, requestedTitle, item.depth, data.exists, { | |||
candidate: true | |||
}); | |||
setTimeout(step, 80); | |||
return; | |||
} | |||
if (isCatalogStop) { | |||
childrenMap[finalKey] = []; | |||
setTimeout(step, 80); | |||
return; | |||
} | |||
if (item.depth >= maxDepth) { | |||
pageInfo[finalKey].depthStop = true; | |||
childrenMap[finalKey] = []; | |||
setTimeout(step, 80); | |||
return; | |||
} | |||
resolveTitles(api, data.links).then(function (resolvedLinks) { | |||
Object.keys(resolvedLinks.redirects).forEach(function (from) { | |||
redirectsFound[from] = resolvedLinks.redirects[from]; | |||
}); | |||
var children = []; | |||
data.links.forEach(function (originalLinkTitle) { | |||
var originalKey = titleKey(originalLinkTitle); | |||
var resolved = resolvedLinks.items[originalKey]; | |||
var childRequested = resolved ? resolved.requestedTitle : originalLinkTitle; | |||
var childFinal = resolved ? resolved.finalTitle : originalLinkTitle; | |||
var childExists = resolved ? resolved.exists : null; | |||
var childKey = titleKey(childFinal); | |||
if (isExcludedTitle(childRequested) || isExcludedTitle(childFinal)) { | |||
return; | |||
} | |||
var childKnown = !!knownPages[childKey] || !!knownPages[originalKey]; | |||
var childStop = !!stopPages[childKey] || !!stopPages[originalKey]; | |||
children.push(childFinal); | |||
if (!firstParent[childKey]) { | |||
firstParent[childKey] = finalTitle; | |||
} else if (titleKey(firstParent[childKey]) !== finalKey) { | |||
addAlsoLinked(childFinal, finalTitle); | |||
} | |||
if (childKnown) { | |||
upsertPageInfo(childFinal, childRequested, item.depth + 1, childExists, { | |||
catalogStop: childStop | |||
}); | |||
if (!childStop && !queued[childKey]) { | |||
queued[childKey] = true; | |||
queue.push({ | |||
requestedTitle: childFinal, | |||
depth: item.depth + 1, | |||
parent: finalTitle | |||
}); | |||
} | |||
} else { | |||
candidates[childKey] = { | |||
requestedTitle: childRequested, | |||
finalTitle: childFinal, | |||
depth: item.depth + 1, | |||
exists: childExists, | |||
from: finalTitle, | |||
candidate: true | |||
}; | |||
upsertPageInfo(childFinal, childRequested, item.depth + 1, childExists, { | |||
candidate: true | |||
}); | |||
} | |||
}); | |||
childrenMap[finalKey] = uniqueSorted(children); | |||
statsBox.textContent = | |||
'Каталог: ' + Object.keys(knownPages).length + | |||
' · Просканировано: ' + Object.keys(scanned).length + | |||
' · В очереди: ' + queue.length + | |||
' · Известных найдено: ' + Object.keys(pageInfo).length + | |||
' · Кандидатов: ' + Object.keys(candidates).length + | |||
' · Редиректов: ' + Object.keys(redirectsFound).length; | |||
setTimeout(step, 100); | |||
}); | }); | ||
} | }).catch(function (error) { | ||
console.error(error); | |||
setStatus('Ошибка при сканировании: ' + requestedTitle, 'error'); | |||
scanBtn.disabled = false; | |||
clearBtn.disabled = false; | |||
}); | |||
} | |||
function finish() { | |||
var rootFinal = rootTitle; | |||
Object.keys(pageInfo).some(function (key) { | |||
var info = pageInfo[key]; | |||
if (titleKey(info.requestedTitle) === titleKey(rootTitle) || titleKey(info.finalTitle) === titleKey(rootTitle)) { | |||
rootFinal = info.finalTitle; | |||
return true; | |||
} | |||
return false; | |||
}); | |||
renderTree(rootFinal, childrenMap, pageInfo, firstParent, alsoLinkedFrom); | |||
renderKnownList(pageInfo); | |||
renderCandidates(candidates); | |||
renderRedirects(redirectsFound); | |||
var finalText = | |||
'Готово. Каталог прочитан. Просканировано: ' + Object.keys(scanned).length + | |||
'. Кандидатов: ' + Object.keys(candidates).length + | |||
'. Редиректов: ' + Object.keys(redirectsFound).length + '.'; | |||
if (stoppedByLimit) { | |||
finalText += ' Остановлено по лимиту страниц.'; | |||
} | |||
statsBox.textContent = finalText; | |||
setStatus('Готово', 'done'); | |||
scanBtn.disabled = false; | |||
clearBtn.disabled = false; | |||
} | |||
step(); | |||
}).catch(function (error) { | |||
console.error(error); | |||
setStatus('Не удалось прочитать каталог: ' + catalogTitle, 'error'); | |||
scanBtn.disabled = false; | |||
clearBtn.disabled = false; | |||
}); | |||
} | } | ||
scanBtn.addEventListener('click', scan); | |||
clearBtn.addEventListener('click', clearResults); | |||
} | } | ||
buildScanner(scannerRoot); | |||
})(); | })(); | ||
Текущая версия от 15:12, 15 июня 2026
(function () {
var scannerRoot = document.querySelector('.scmc-link-scanner');
if (!scannerRoot) return;
var EXCLUDED_PREFIXES = [
'Файл:',
'File:',
'Категория:',
'Category:',
'Шаблон:',
'Template:',
'Участник:',
'User:',
'Обсуждение:',
'Talk:',
'Служебная:',
'Special:',
'MediaWiki:',
'Модуль:',
'Module:',
'Справка:',
'Help:'
];
function normalizeTitle(title) {
return String(title || '')
.replace(/_/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function titleKey(title) {
return normalizeTitle(title).toLowerCase();
}
function titleToUrl(title) {
return mw.util.getUrl(title);
}
function titleToEditUrl(title) {
return mw.util.getUrl(title, { action: 'edit' });
}
function makePageLink(title, text, className) {
var link = document.createElement('a');
link.href = titleToUrl(title);
link.textContent = text || title;
if (className) {
link.className = className;
}
return link;
}
function makeEditLink(title, text, className) {
var link = document.createElement('a');
link.href = titleToEditUrl(title);
link.textContent = text || 'код';
if (className) {
link.className = className;
}
link.target = '_blank';
link.rel = 'noopener';
return link;
}
function makeEl(tag, className, text) {
var el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}
function makeButton(text, className) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = className || 'scmc-scan-btn';
btn.textContent = text;
return btn;
}
function splitDataList(value) {
return String(value || '')
.split('|')
.map(normalizeTitle)
.filter(Boolean);
}
function uniqueSorted(list) {
var seen = {};
list.forEach(function (item) {
var title = normalizeTitle(item);
if (title) seen[titleKey(title)] = title;
});
return Object.keys(seen)
.map(function (key) {
return seen[key];
})
.sort(function (a, b) {
return a.localeCompare(b, 'ru');
});
}
function chunkArray(items, size) {
var chunks = [];
for (var i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size));
}
return chunks;
}
function extractAttr(tag, names) {
for (var i = 0; i < names.length; i++) {
var name = names[i];
var re = new RegExp(name + '\\s*=\\s*(["\\\'])(.*?)\\1', 'i');
var match = tag.match(re);
if (match) {
return match[2];
}
}
return '';
}
function isStopCatalogRow(tag) {
var scanValue = normalizeTitle(extractAttr(tag, ['data-scan', 'data_scan'])).toLowerCase();
var classValue = ' ' + normalizeTitle(extractAttr(tag, ['class'])).toLowerCase() + ' ';
return scanValue === 'stop' ||
classValue.indexOf(' scmc-scan-stop ') !== -1 ||
classValue.indexOf(' scan-stop ') !== -1 ||
classValue.indexOf(' stop ') !== -1;
}
function encodeAttr(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '<')
.replace(/>/g, '>');
}
function getLevelNumberFromClass(className) {
var match = String(className || '').match(/\bscmc-level-([1-5])\b/);
return match ? parseInt(match[1], 10) : 1;
}
function normalizeLevel(level) {
level = parseInt(level, 10);
if (!level || level < 1) return 1;
if (level > 5) return 5;
return level;
}
function cleanCatalogClass(level) {
return 'scmc-catalog-row scmc-level-' + normalizeLevel(level);
}
function titleFromPage(page) {
return normalizeTitle(page).replace(/_/g, ' ');
}
function buildCatalogRowLine(data) {
var page = normalizeTitle(data.page);
var title = normalizeTitle(data.title || titleFromPage(page));
var section = normalizeTitle(data.section);
var level = normalizeLevel(data.level);
var status = String(data.status || '').trim();
var scan = data.scan === 'stop' ? 'stop' : '';
var note = normalizeTitle(data.note || '');
var out = '<div class="' + encodeAttr(cleanCatalogClass(level)) + '"';
out += ' data-section="' + encodeAttr(section) + '"';
out += ' data-page="' + encodeAttr(page) + '"';
out += ' data-title="' + encodeAttr(title) + '"';
if (scan) {
out += ' data-scan="stop"';
}
out += ' data-status="' + encodeAttr(status) + '"';
if (note) {
out += ' data-note="' + encodeAttr(note) + '"';
}
if (/^https?:\/\//i.test(page)) {
out += '>[' + page + ' ' + title + ']</div>';
} else {
out += '>[[' + page + '|' + title + ']]</div>';
}
return out;
}
function getApi() {
return new mw.Api();
}
function resolveTitles(api, titles) {
var unique = uniqueSorted(titles);
var chunks = chunkArray(unique, 45);
var finalResult = {};
var allRedirects = {};
function followRedirect(title, redirectMap) {
var current = normalizeTitle(title);
var guard = 0;
while (redirectMap[titleKey(current)] && guard < 10) {
current = redirectMap[titleKey(current)];
guard++;
}
return current;
}
function processChunk(chunk) {
if (!chunk.length) return $.Deferred().resolve().promise();
return api.get({
action: 'query',
titles: chunk.join('|'),
redirects: 1,
formatversion: 2
}).then(function (data) {
var redirectMap = {};
var pagesByKey = {};
if (data.query && data.query.redirects) {
data.query.redirects.forEach(function (redirect) {
var from = normalizeTitle(redirect.from);
var to = normalizeTitle(redirect.to);
redirectMap[titleKey(from)] = to;
allRedirects[from] = to;
});
}
if (data.query && data.query.pages) {
data.query.pages.forEach(function (page) {
var pageTitle = normalizeTitle(page.title);
pagesByKey[titleKey(pageTitle)] = {
title: pageTitle,
exists: page.missing === undefined
};
});
}
chunk.forEach(function (requested) {
var requestedTitle = normalizeTitle(requested);
var finalTitle = followRedirect(requestedTitle, redirectMap);
var page = pagesByKey[titleKey(finalTitle)];
finalResult[titleKey(requestedTitle)] = {
requestedTitle: requestedTitle,
finalTitle: page ? page.title : finalTitle,
exists: page ? page.exists : null
};
});
});
}
var chain = $.Deferred().resolve().promise();
chunks.forEach(function (chunk) {
chain = chain.then(function () {
return processChunk(chunk);
});
});
return chain.then(function () {
return {
items: finalResult,
redirects: allRedirects
};
});
}
function loadCatalog(api, catalogTitle) {
return api.get({
action: 'parse',
page: catalogTitle,
prop: 'wikitext',
formatversion: 2
}).then(function (data) {
var wikitext = '';
if (data.parse && typeof data.parse.wikitext === 'string') {
wikitext = data.parse.wikitext;
} else if (data.parse && data.parse.wikitext && data.parse.wikitext['*']) {
wikitext = data.parse.wikitext['*'];
}
var rawPages = [];
var rawStopPages = {};
var catalogRows = [];
var rowRe = /<div\b[^>]*scmc-catalog-row[^>]*>/gi;
var match;
while ((match = rowRe.exec(wikitext)) !== null) {
var tag = match[0];
var className = extractAttr(tag, ['class']);
var page = normalizeTitle(extractAttr(tag, ['data-page', 'data_page']));
var title = normalizeTitle(extractAttr(tag, ['data-title', 'data_title'])) || page;
var section = normalizeTitle(extractAttr(tag, ['data-section', 'data_section']));
var level = getLevelNumberFromClass(className);
if (!page) continue;
rawPages.push(page);
var rowData = {
page: page,
title: title,
section: section,
level: level,
position: catalogRows.length + 1,
scan: isStopCatalogRow(tag) ? 'stop' : '',
status: normalizeTitle(extractAttr(tag, ['data-status', 'data_status']))
};
catalogRows.push(rowData);
if (rowData.scan === 'stop') {
rawStopPages[titleKey(page)] = true;
}
}
return resolveTitles(api, rawPages).then(function (resolved) {
var knownPages = {};
var stopPages = {};
var redirects = resolved.redirects;
var rowsByKey = {};
var sectionMap = {};
var sections = [];
catalogRows.forEach(function (rowData) {
var rawPage = rowData.page;
var rawKey = titleKey(rawPage);
var resolvedItem = resolved.items[rawKey];
var finalTitle = resolvedItem ? resolvedItem.finalTitle : rawPage;
var finalKey = titleKey(finalTitle);
var enriched = Object.assign({}, rowData, {
finalTitle: finalTitle
});
knownPages[rawKey] = rawPage;
knownPages[finalKey] = finalTitle;
rowsByKey[rawKey] = enriched;
rowsByKey[finalKey] = enriched;
if (rowData.section && !sectionMap[rowData.section]) {
sectionMap[rowData.section] = true;
sections.push(rowData.section);
}
if (rawStopPages[rawKey]) {
stopPages[rawKey] = true;
stopPages[finalKey] = true;
}
});
var preferredOrder = [
'Морпехи',
'Ксеноморфы',
'Правила и процедуры',
'Справочник',
'Лор и материалы',
'Общее'
];
sections.sort(function (a, b) {
var ai = preferredOrder.indexOf(a);
var bi = preferredOrder.indexOf(b);
if (ai !== -1 || bi !== -1) {
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
}
return a.localeCompare(b, 'ru');
});
return {
knownPages: knownPages,
stopPages: stopPages,
redirects: redirects,
catalogRows: catalogRows,
rowsByKey: rowsByKey,
sections: sections,
count: Object.keys(knownPages).length
};
});
});
}
function buildScanner(container) {
var rootTitle = normalizeTitle(container.getAttribute('data-root') || 'Marine_Corps');
var catalogTitle = normalizeTitle(container.getAttribute('data-catalog') || 'MC:Страницы');
var maxDepth = parseInt(container.getAttribute('data-depth') || '5', 10);
var maxPages = parseInt(container.getAttribute('data-max-pages') || '300', 10);
var extraExcludedTitles = splitDataList(container.getAttribute('data-exclude'));
var latestCatalogData = null;
function getCatalogSource(api) {
return api.get({
action: 'query',
prop: 'revisions',
titles: catalogTitle,
rvprop: 'content',
rvslots: 'main',
formatversion: 2
}).then(function (data) {
var pageData = data.query && data.query.pages ? data.query.pages[0] : null;
var rev = pageData && pageData.revisions ? pageData.revisions[0] : null;
if (rev && rev.slots && rev.slots.main && typeof rev.slots.main.content === 'string') {
return rev.slots.main.content;
}
if (rev && typeof rev.content === 'string') {
return rev.content;
}
throw new Error('Не удалось прочитать код каталога.');
});
}
function saveCatalogSource(api, text) {
return api.postWithToken('csrf', {
action: 'edit',
title: catalogTitle,
text: text,
summary: 'Добавление страницы в каталог Marine Corps из сканера',
minor: true,
formatversion: 2
});
}
function getCatalogRowsFromSource(source) {
var rows = [];
var re = /<div\b[^>]*\bscmc-catalog-row\b[^>]*>[\s\S]*?<\/div>/gi;
var match;
while ((match = re.exec(source)) !== null) {
var full = match[0];
var open = full.match(/^<div\b[^>]*>/i);
if (!open) continue;
var tag = open[0];
var page = normalizeTitle(extractAttr(tag, ['data-page', 'data_page']));
var title = normalizeTitle(extractAttr(tag, ['data-title', 'data_title'])) || page;
var section = normalizeTitle(extractAttr(tag, ['data-section', 'data_section']));
var level = getLevelNumberFromClass(extractAttr(tag, ['class']));
if (!page) continue;
rows.push({
start: match.index,
end: match.index + full.length,
full: full,
data: {
page: page,
title: title,
section: section,
level: level,
position: rows.length + 1
}
});
}
return rows;
}
function normalizePlaceInput(value, count) {
var text = String(value || '').trim();
if (!text) {
return count;
}
if (!/^\d+$/.test(text)) {
throw new Error('Место должно быть целым числом или пустым полем.');
}
var place = parseInt(text, 10);
if (!place || place < 1) {
throw new Error('Место должно быть числом от 1 или пустым полем.');
}
if (place > count + 1) {
return count;
}
return place - 1;
}
function findParentDefaults(info) {
var result = {
section: '',
level: '',
place: ''
};
if (!latestCatalogData || !info || !info.from) {
return result;
}
var parent = latestCatalogData.rowsByKey[titleKey(info.from)];
if (!parent) {
return result;
}
result.section = parent.section || '';
result.level = String(Math.min(5, normalizeLevel(parent.level + 1)));
if (parent.position) {
result.place = String(parent.position + 1);
}
return result;
}
function showModalError(modal, text) {
var error = modal.querySelector('.scmc-modal-error');
if (error) error.textContent = text || '';
}
function closeModal() {
var old = document.querySelector('.scmc-modal-backdrop');
if (old) old.remove();
}
function openAddCandidateModal(info, button) {
closeModal();
var defaults = findParentDefaults(info);
var backdrop = makeEl('div', 'scmc-modal-backdrop');
var modal = makeEl('div', 'scmc-modal');
var title = makeEl('div', 'scmc-modal-title', 'Добавить страницу в каталог');
var subtitle = makeEl('div', 'scmc-modal-subtitle', 'Кандидат найден из: ' + (info.from || 'неизвестно'));
var form = makeEl('div', 'scmc-modal-form');
var error = makeEl('div', 'scmc-modal-error');
var actions = makeEl('div', 'scmc-modal-actions');
var cancelButton = makeButton('Отмена', 'scmc-modal-btn scmc-modal-btn-secondary');
var submitButton = makeButton('Добавить', 'scmc-modal-btn');
function makeField(labelText, input) {
var field = makeEl('label', 'scmc-modal-field');
var label = makeEl('span', '', labelText);
field.appendChild(label);
field.appendChild(input);
return field;
}
var pageInput = document.createElement('input');
pageInput.type = 'text';
pageInput.value = info.finalTitle || info.requestedTitle || '';
pageInput.readOnly = true;
var titleInput = document.createElement('input');
titleInput.type = 'text';
titleInput.value = titleFromPage(info.finalTitle || info.requestedTitle || '');
titleInput.placeholder = 'Название страницы в каталоге';
var sectionSelect = document.createElement('select');
sectionSelect.appendChild(new Option('Выберите раздел', ''));
(latestCatalogData && latestCatalogData.sections ? latestCatalogData.sections : []).forEach(function (section) {
sectionSelect.appendChild(new Option(section, section));
});
sectionSelect.value = defaults.section || '';
var levelSelect = document.createElement('select');
levelSelect.appendChild(new Option('Выберите уровень', ''));
[1, 2, 3, 4, 5].forEach(function (level) {
levelSelect.appendChild(new Option('Уровень ' + level, String(level)));
});
levelSelect.value = defaults.level || '';
var statusSelect = document.createElement('select');
statusSelect.appendChild(new Option('Выберите статус', ''));
statusSelect.appendChild(new Option('🟢 Готово', 'green'));
statusSelect.appendChild(new Option('🟡 Нужно обновить', 'yellow'));
statusSelect.appendChild(new Option('🔴 Нет страницы', 'red'));
statusSelect.appendChild(new Option('🔵 Заморожено', 'blue'));
var placeInput = document.createElement('input');
placeInput.type = 'text';
placeInput.inputMode = 'numeric';
placeInput.placeholder = 'Пусто = в конец, число = место в каталоге';
placeInput.value = defaults.place || '';
var scanLabel = makeEl('label', 'scmc-modal-check');
var scanInput = document.createElement('input');
scanInput.type = 'checkbox';
scanLabel.appendChild(scanInput);
scanLabel.appendChild(document.createTextNode(' Scan OFF'));
var noteInput = document.createElement('textarea');
noteInput.rows = 3;
noteInput.placeholder = 'Заметка, если нужна';
form.appendChild(makeField('Страница', pageInput));
form.appendChild(makeField('Название', titleInput));
form.appendChild(makeField('Раздел *', sectionSelect));
form.appendChild(makeField('Уровень *', levelSelect));
form.appendChild(makeField('Статус *', statusSelect));
form.appendChild(makeField('Место', placeInput));
form.appendChild(scanLabel);
form.appendChild(makeField('Заметка', noteInput));
actions.appendChild(cancelButton);
actions.appendChild(submitButton);
modal.appendChild(title);
modal.appendChild(subtitle);
modal.appendChild(form);
modal.appendChild(error);
modal.appendChild(actions);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
cancelButton.addEventListener('click', closeModal);
backdrop.addEventListener('click', function (event) {
if (event.target === backdrop) closeModal();
});
submitButton.addEventListener('click', function () {
var values = {
page: normalizeTitle(pageInput.value),
title: normalizeTitle(titleInput.value) || titleFromPage(pageInput.value),
section: normalizeTitle(sectionSelect.value),
level: levelSelect.value,
status: statusSelect.value,
place: placeInput.value,
scan: scanInput.checked ? 'stop' : '',
note: normalizeTitle(noteInput.value)
};
if (!values.section) {
showModalError(modal, 'Выберите раздел.');
return;
}
if (!values.level) {
showModalError(modal, 'Выберите уровень.');
return;
}
if (!values.status) {
showModalError(modal, 'Выберите статус.');
return;
}
showModalError(modal, '');
submitButton.disabled = true;
cancelButton.disabled = true;
submitButton.textContent = 'Сохраняю...';
addCandidateToCatalog(values).then(function () {
closeModal();
if (button) {
button.textContent = '✓ Добавлено';
button.disabled = true;
button.classList.add('is-added');
}
}).catch(function (err) {
submitButton.disabled = false;
cancelButton.disabled = false;
submitButton.textContent = 'Добавить';
showModalError(modal, err && err.message ? err.message : 'Не удалось добавить страницу. Проверь права редактора.');
});
});
}
function rebuildLatestCatalogIndexes() {
if (!latestCatalogData || !latestCatalogData.catalogRows) return;
latestCatalogData.rowsByKey = latestCatalogData.rowsByKey || {};
latestCatalogData.catalogRows.forEach(function (rowData, index) {
rowData.position = index + 1;
latestCatalogData.rowsByKey[titleKey(rowData.page)] = rowData;
if (rowData.finalTitle) {
latestCatalogData.rowsByKey[titleKey(rowData.finalTitle)] = rowData;
}
});
}
function addCandidateToCatalog(values) {
var api = getApi();
return getCatalogSource(api).then(function (source) {
var rows = getCatalogRowsFromSource(source);
if (!rows.length) {
throw new Error('Не нашёл строки каталога в коде страницы.');
}
var pageKey = titleKey(values.page);
var duplicate = rows.some(function (row) {
return titleKey(row.data.page) === pageKey;
});
if (duplicate) {
throw new Error('Эта страница уже есть в каталоге.');
}
var insertIndex = normalizePlaceInput(values.place, rows.length);
var newLine = buildCatalogRowLine(values);
var lines = rows.map(function (row) {
return row.full;
});
lines.splice(insertIndex, 0, newLine);
var first = rows[0];
var last = rows[rows.length - 1];
var newText = source.slice(0, first.start) + lines.join('\n') + source.slice(last.end);
return saveCatalogSource(api, newText).then(function () {
var rowData = {
page: values.page,
title: values.title,
section: values.section,
level: normalizeLevel(values.level),
position: insertIndex + 1,
scan: values.scan,
status: values.status,
finalTitle: values.page
};
if (latestCatalogData) {
latestCatalogData.catalogRows.splice(insertIndex, 0, rowData);
latestCatalogData.knownPages[titleKey(values.page)] = values.page;
latestCatalogData.rowsByKey[titleKey(values.page)] = rowData;
if (values.scan === 'stop') {
latestCatalogData.stopPages[titleKey(values.page)] = true;
}
if (latestCatalogData.sections.indexOf(values.section) === -1) {
latestCatalogData.sections.push(values.section);
}
rebuildLatestCatalogIndexes();
}
});
});
}
function isExcludedTitle(title) {
var clean = normalizeTitle(title);
if (!clean) return true;
if (clean.indexOf('#') !== -1) {
clean = normalizeTitle(clean.split('#')[0]);
}
if (extraExcludedTitles.some(function (excluded) {
return titleKey(excluded) === titleKey(clean);
})) {
return true;
}
return EXCLUDED_PREFIXES.some(function (prefix) {
return clean.indexOf(prefix) === 0;
});
}
function isValidLink(link) {
if (!link || !link.title) return false;
if (typeof link.ns === 'number' && link.ns !== 0) return false;
if (isExcludedTitle(link.title)) return false;
return true;
}
function getPageLinks(api, requestedTitle) {
var links = [];
var redirects = [];
var normalizedRequested = normalizeTitle(requestedTitle);
function request(plcontinue) {
var params = {
action: 'query',
prop: 'links',
titles: normalizedRequested,
pllimit: 'max',
redirects: 1,
formatversion: 2
};
if (plcontinue) {
params.plcontinue = plcontinue;
}
return api.get(params).then(function (data) {
if (data.query && data.query.redirects) {
data.query.redirects.forEach(function (redirect) {
redirects.push({
from: normalizeTitle(redirect.from),
to: normalizeTitle(redirect.to)
});
});
}
var pages = data.query && data.query.pages ? data.query.pages : [];
var page = pages[0];
if (!page) {
return {
requestedTitle: normalizedRequested,
finalTitle: normalizedRequested,
exists: false,
links: [],
redirects: redirects
};
}
var finalTitle = normalizeTitle(page.title || normalizedRequested);
if (page.missing !== undefined) {
return {
requestedTitle: normalizedRequested,
finalTitle: finalTitle,
exists: false,
links: [],
redirects: redirects
};
}
if (typeof page.ns === 'number' && page.ns !== 0) {
return {
requestedTitle: normalizedRequested,
finalTitle: finalTitle,
exists: true,
links: [],
redirects: redirects
};
}
if (page.links) {
page.links.forEach(function (link) {
if (isValidLink(link)) {
links.push(normalizeTitle(link.title));
}
});
}
if (data.continue && data.continue.plcontinue) {
return request(data.continue.plcontinue);
}
return {
requestedTitle: normalizedRequested,
finalTitle: finalTitle,
exists: true,
links: uniqueSorted(links),
redirects: redirects
};
});
}
return request();
}
var header = makeEl('div', 'scmc-scan-header');
var title = makeEl('div', 'scmc-scan-title', 'Сканер ссылок Marine Corps');
var subtitle = makeEl(
'div',
'scmc-scan-subtitle',
'Старт: ' + rootTitle + ' · каталог: ' + catalogTitle + ' · глубина: ' + maxDepth + ' · лимит страниц: ' + maxPages
);
header.appendChild(title);
header.appendChild(subtitle);
var controls = makeEl('div', 'scmc-scan-controls');
var scanBtn = makeButton('Сканировать ссылки');
var clearBtn = makeButton('Очистить', 'scmc-scan-btn scmc-scan-btn-secondary');
controls.appendChild(scanBtn);
controls.appendChild(clearBtn);
var statusBox = makeEl('div', 'scmc-scan-status', 'Ожидает запуска');
var statsBox = makeEl('div', 'scmc-scan-stats');
var resultWrap = makeEl('div', 'scmc-scan-result');
var treeBox = makeEl('div', 'scmc-scan-box');
var listBox = makeEl('div', 'scmc-scan-box');
var candidatesBox = makeEl('div', 'scmc-scan-box scmc-scan-box-wide');
var redirectBox = makeEl('div', 'scmc-scan-box scmc-scan-box-wide');
resultWrap.appendChild(treeBox);
resultWrap.appendChild(listBox);
resultWrap.appendChild(candidatesBox);
resultWrap.appendChild(redirectBox);
container.innerHTML = '';
container.appendChild(header);
container.appendChild(controls);
container.appendChild(statusBox);
container.appendChild(statsBox);
container.appendChild(resultWrap);
function setStatus(text, mode) {
statusBox.textContent = text;
statusBox.setAttribute('data-mode', mode || '');
}
function clearResults() {
treeBox.innerHTML = '';
listBox.innerHTML = '';
candidatesBox.innerHTML = '';
redirectBox.innerHTML = '';
statsBox.innerHTML = '';
setStatus('Ожидает запуска', '');
}
function renderTree(root, childrenMap, pageInfo, firstParent, alsoLinkedFrom) {
treeBox.innerHTML = '';
treeBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Дерево известных страниц'));
function makeNode(title, path) {
var key = titleKey(title);
var info = pageInfo[key];
var node = makeEl('div', 'scmc-scan-node');
var line = makeEl('div', 'scmc-scan-node-line');
var link = document.createElement('a');
link.href = titleToUrl(title);
link.textContent = title;
line.appendChild(link);
if (info) {
line.appendChild(makeEl('span', 'scmc-scan-depth', 'ур. ' + info.depth));
if (info.requestedTitle && titleKey(info.requestedTitle) !== titleKey(info.finalTitle)) {
line.appendChild(makeEl('span', 'scmc-scan-redirect-mini', info.requestedTitle + ' → ' + info.finalTitle));
}
if (info.candidate) {
line.appendChild(makeEl('span', 'scmc-scan-candidate', 'кандидат'));
}
if (info.catalogStop) {
line.appendChild(makeEl('span', 'scmc-scan-stopped', 'остановлено каталогом'));
}
if (info.depthStop) {
line.appendChild(makeEl('span', 'scmc-scan-stopped', 'предел глубины'));
}
if (info.exists === false) {
line.appendChild(makeEl('span', 'scmc-scan-missing', 'нет страницы'));
}
}
node.appendChild(line);
if (path[key]) {
node.appendChild(makeEl('div', 'scmc-scan-loop', '↳ уже встречалась выше'));
return node;
}
var extraLinks = alsoLinkedFrom[key] || [];
if (extraLinks.length) {
node.appendChild(makeEl('div', 'scmc-scan-also', 'Ещё ссылки из: ' + extraLinks.join(', ')));
}
var nextPath = Object.assign({}, path);
nextPath[key] = true;
var children = childrenMap[key] || [];
if (children.length) {
var childrenWrap = makeEl('div', 'scmc-scan-children');
children.forEach(function (childTitle) {
var childKey = titleKey(childTitle);
if (firstParent[childKey] && titleKey(firstParent[childKey]) !== key) {
var refNode = makeEl('div', 'scmc-scan-node');
var refLine = makeEl('div', 'scmc-scan-node-line scmc-scan-node-ref');
var refLink = document.createElement('a');
refLink.href = titleToUrl(childTitle);
refLink.textContent = childTitle;
refLine.appendChild(refLink);
refLine.appendChild(makeEl('span', 'scmc-scan-ref', 'уже найдено в: ' + firstParent[childKey]));
refNode.appendChild(refLine);
childrenWrap.appendChild(refNode);
return;
}
childrenWrap.appendChild(makeNode(childTitle, nextPath));
});
node.appendChild(childrenWrap);
}
return node;
}
treeBox.appendChild(makeNode(root, {}));
}
function renderKnownList(pageInfo) {
listBox.innerHTML = '';
listBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Известные страницы из каталога'));
var pages = Object.keys(pageInfo)
.map(function (key) {
return pageInfo[key];
})
.filter(function (info) {
return !info.candidate;
})
.sort(function (a, b) {
if (a.depth !== b.depth) return a.depth - b.depth;
return a.finalTitle.localeCompare(b.finalTitle, 'ru');
});
if (!pages.length) {
listBox.appendChild(makeEl('div', 'scmc-scan-empty', 'Пока пусто.'));
return;
}
var list = makeEl('ol', 'scmc-scan-page-list');
pages.forEach(function (info) {
var li = document.createElement('li');
var link = document.createElement('a');
link.href = titleToUrl(info.finalTitle);
link.textContent = info.finalTitle;
li.appendChild(link);
li.appendChild(makeEl('span', 'scmc-scan-depth', 'ур. ' + info.depth));
if (info.requestedTitle && titleKey(info.requestedTitle) !== titleKey(info.finalTitle)) {
li.appendChild(makeEl('span', 'scmc-scan-redirect-mini', 'найдено как: ' + info.requestedTitle));
}
if (info.catalogStop) {
li.appendChild(makeEl('span', 'scmc-scan-stopped', 'остановлено каталогом'));
}
if (info.depthStop) {
li.appendChild(makeEl('span', 'scmc-scan-stopped', 'предел глубины'));
}
list.appendChild(li);
});
listBox.appendChild(list);
}
function renderCandidates(candidates) {
candidatesBox.innerHTML = '';
candidatesBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Кандидаты: ссылки не из каталога'));
var pages = Object.keys(candidates)
.map(function (key) {
return candidates[key];
})
.sort(function (a, b) {
if (a.depth !== b.depth) return a.depth - b.depth;
return a.finalTitle.localeCompare(b.finalTitle, 'ru');
});
if (!pages.length) {
candidatesBox.appendChild(makeEl('div', 'scmc-scan-empty', 'Новых кандидатов не найдено.'));
return;
}
var list = makeEl('ol', 'scmc-scan-page-list');
pages.forEach(function (info) {
var li = document.createElement('li');
var link = document.createElement('a');
var addButton = makeButton('+ В каталог', 'scmc-scan-add-btn');
link.href = titleToUrl(info.finalTitle);
link.textContent = info.finalTitle;
addButton.addEventListener('click', function () {
openAddCandidateModal(info, addButton);
});
li.appendChild(link);
li.appendChild(makeEl('span', 'scmc-scan-depth', 'ур. ' + info.depth));
li.appendChild(makeEl('span', 'scmc-scan-candidate', 'не сканировалась'));
if (info.from) {
var fromRef = makeEl('span', 'scmc-scan-ref');
fromRef.appendChild(document.createTextNode('найдена из: '));
fromRef.appendChild(makePageLink(info.from, info.from, 'scmc-scan-ref-link'));
fromRef.appendChild(document.createTextNode(' · '));
fromRef.appendChild(makeEditLink(info.from, 'код', 'scmc-scan-ref-link scmc-scan-ref-edit'));
li.appendChild(fromRef);
}
if (info.requestedTitle && titleKey(info.requestedTitle) !== titleKey(info.finalTitle)) {
li.appendChild(makeEl('span', 'scmc-scan-redirect-mini', info.requestedTitle + ' → ' + info.finalTitle));
}
if (info.exists === false) {
li.appendChild(makeEl('span', 'scmc-scan-missing', 'нет страницы'));
}
li.appendChild(addButton);
list.appendChild(li);
});
candidatesBox.appendChild(list);
}
function renderRedirects(redirectsFound) {
redirectBox.innerHTML = '';
redirectBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Редиректы'));
var keys = Object.keys(redirectsFound).sort(function (a, b) {
return a.localeCompare(b, 'ru');
});
if (!keys.length) {
redirectBox.appendChild(makeEl('div', 'scmc-scan-empty', 'Редиректы не найдены.'));
return;
}
var list = makeEl('ol', 'scmc-scan-page-list');
keys.forEach(function (from) {
var to = redirectsFound[from];
var li = document.createElement('li');
var fromLink = document.createElement('a');
fromLink.href = titleToUrl(from);
fromLink.textContent = from;
var toLink = document.createElement('a');
toLink.href = titleToUrl(to);
toLink.textContent = to;
li.appendChild(fromLink);
li.appendChild(document.createTextNode(' → '));
li.appendChild(toLink);
list.appendChild(li);
});
redirectBox.appendChild(list);
}
function scan() {
clearResults();
var api = getApi();
scanBtn.disabled = true;
clearBtn.disabled = true;
setStatus('Читаю каталог: ' + catalogTitle, 'scanning');
loadCatalog(api, catalogTitle).then(function (catalog) {
latestCatalogData = catalog;
var knownPages = catalog.knownPages;
var stopPages = catalog.stopPages;
var redirectsFound = Object.assign({}, catalog.redirects);
var queue = [{
requestedTitle: rootTitle,
depth: 0,
parent: null
}];
var scanned = {};
var queued = {};
var pageInfo = {};
var candidates = {};
var childrenMap = {};
var firstParent = {};
var alsoLinkedFrom = {};
var stoppedByLimit = false;
queued[titleKey(rootTitle)] = true;
function addRedirects(redirects) {
redirects.forEach(function (redirect) {
if (redirect.from && redirect.to) {
redirectsFound[redirect.from] = redirect.to;
}
});
}
function addAlsoLinked(childTitle, parentTitle) {
var childKey = titleKey(childTitle);
if (!alsoLinkedFrom[childKey]) {
alsoLinkedFrom[childKey] = [];
}
if (alsoLinkedFrom[childKey].indexOf(parentTitle) === -1) {
alsoLinkedFrom[childKey].push(parentTitle);
}
}
function upsertPageInfo(finalTitle, requestedTitle, depth, exists, options) {
var finalKey = titleKey(finalTitle);
var current = pageInfo[finalKey];
if (!current) {
current = {
requestedTitle: requestedTitle || finalTitle,
finalTitle: finalTitle,
depth: depth,
exists: exists
};
pageInfo[finalKey] = current;
}
if (depth < current.depth) {
current.depth = depth;
}
if (requestedTitle && titleKey(requestedTitle) !== titleKey(finalTitle)) {
current.requestedTitle = requestedTitle;
}
if (exists === false) {
current.exists = false;
}
if (options) {
Object.keys(options).forEach(function (key) {
current[key] = options[key];
});
}
return current;
}
function step() {
if (!queue.length) {
finish();
return;
}
if (Object.keys(scanned).length >= maxPages) {
stoppedByLimit = true;
finish();
return;
}
var item = queue.shift();
var requestedTitle = normalizeTitle(item.requestedTitle);
var requestedKey = titleKey(requestedTitle);
if (scanned[requestedKey]) {
step();
return;
}
scanned[requestedKey] = true;
setStatus('Сканирую: ' + requestedTitle + ' · уровень ' + item.depth, 'scanning');
getPageLinks(api, requestedTitle).then(function (data) {
addRedirects(data.redirects);
var finalTitle = normalizeTitle(data.finalTitle || requestedTitle);
var finalKey = titleKey(finalTitle);
var isRoot = item.depth === 0;
var isKnown = !!knownPages[finalKey] || !!knownPages[requestedKey] || isRoot;
var isCatalogStop = !isRoot && (!!stopPages[finalKey] || !!stopPages[requestedKey]);
upsertPageInfo(finalTitle, requestedTitle, item.depth, data.exists, {
catalogStop: isCatalogStop
});
if (!isKnown) {
candidates[finalKey] = {
requestedTitle: requestedTitle,
finalTitle: finalTitle,
depth: item.depth,
exists: data.exists,
from: item.parent || '',
candidate: true
};
upsertPageInfo(finalTitle, requestedTitle, item.depth, data.exists, {
candidate: true
});
setTimeout(step, 80);
return;
}
if (isCatalogStop) {
childrenMap[finalKey] = [];
setTimeout(step, 80);
return;
}
if (item.depth >= maxDepth) {
pageInfo[finalKey].depthStop = true;
childrenMap[finalKey] = [];
setTimeout(step, 80);
return;
}
resolveTitles(api, data.links).then(function (resolvedLinks) {
Object.keys(resolvedLinks.redirects).forEach(function (from) {
redirectsFound[from] = resolvedLinks.redirects[from];
});
var children = [];
data.links.forEach(function (originalLinkTitle) {
var originalKey = titleKey(originalLinkTitle);
var resolved = resolvedLinks.items[originalKey];
var childRequested = resolved ? resolved.requestedTitle : originalLinkTitle;
var childFinal = resolved ? resolved.finalTitle : originalLinkTitle;
var childExists = resolved ? resolved.exists : null;
var childKey = titleKey(childFinal);
if (isExcludedTitle(childRequested) || isExcludedTitle(childFinal)) {
return;
}
var childKnown = !!knownPages[childKey] || !!knownPages[originalKey];
var childStop = !!stopPages[childKey] || !!stopPages[originalKey];
children.push(childFinal);
if (!firstParent[childKey]) {
firstParent[childKey] = finalTitle;
} else if (titleKey(firstParent[childKey]) !== finalKey) {
addAlsoLinked(childFinal, finalTitle);
}
if (childKnown) {
upsertPageInfo(childFinal, childRequested, item.depth + 1, childExists, {
catalogStop: childStop
});
if (!childStop && !queued[childKey]) {
queued[childKey] = true;
queue.push({
requestedTitle: childFinal,
depth: item.depth + 1,
parent: finalTitle
});
}
} else {
candidates[childKey] = {
requestedTitle: childRequested,
finalTitle: childFinal,
depth: item.depth + 1,
exists: childExists,
from: finalTitle,
candidate: true
};
upsertPageInfo(childFinal, childRequested, item.depth + 1, childExists, {
candidate: true
});
}
});
childrenMap[finalKey] = uniqueSorted(children);
statsBox.textContent =
'Каталог: ' + Object.keys(knownPages).length +
' · Просканировано: ' + Object.keys(scanned).length +
' · В очереди: ' + queue.length +
' · Известных найдено: ' + Object.keys(pageInfo).length +
' · Кандидатов: ' + Object.keys(candidates).length +
' · Редиректов: ' + Object.keys(redirectsFound).length;
setTimeout(step, 100);
});
}).catch(function (error) {
console.error(error);
setStatus('Ошибка при сканировании: ' + requestedTitle, 'error');
scanBtn.disabled = false;
clearBtn.disabled = false;
});
}
function finish() {
var rootFinal = rootTitle;
Object.keys(pageInfo).some(function (key) {
var info = pageInfo[key];
if (titleKey(info.requestedTitle) === titleKey(rootTitle) || titleKey(info.finalTitle) === titleKey(rootTitle)) {
rootFinal = info.finalTitle;
return true;
}
return false;
});
renderTree(rootFinal, childrenMap, pageInfo, firstParent, alsoLinkedFrom);
renderKnownList(pageInfo);
renderCandidates(candidates);
renderRedirects(redirectsFound);
var finalText =
'Готово. Каталог прочитан. Просканировано: ' + Object.keys(scanned).length +
'. Кандидатов: ' + Object.keys(candidates).length +
'. Редиректов: ' + Object.keys(redirectsFound).length + '.';
if (stoppedByLimit) {
finalText += ' Остановлено по лимиту страниц.';
}
statsBox.textContent = finalText;
setStatus('Готово', 'done');
scanBtn.disabled = false;
clearBtn.disabled = false;
}
step();
}).catch(function (error) {
console.error(error);
setStatus('Не удалось прочитать каталог: ' + catalogTitle, 'error');
scanBtn.disabled = false;
clearBtn.disabled = false;
});
}
scanBtn.addEventListener('click', scan);
clearBtn.addEventListener('click', clearResults);
}
buildScanner(scannerRoot);
})();