// ==UserScript==
// @name Search posts2 (500 posts per page edition)
// @name:ru Поиск постов (быстрый по 500 постов)
// @description Search for posts by user(-s) or(and) text in current topic (500 posts/page fast mode with standard link calculation)
// @description:ru Быстрый поиск постов по 500 на страницу с пересчетом ссылок на стандартные страницы по 20 постов
// @version 5.1.0
// @author Halibut (mod for 500-posts & link recalculation)
// @namespace https://greasyfork.org/en/users/145947-halibut
// @include http*://forum.ru-board.com/topic.cgi?forum=*&topic=*
// @noframes
// @run-at document-start
// @grant none
// ==/UserScript==
window.addEventListener("DOMContentLoaded", function f() {
'use strict';
this.removeEventListener("DOMContentLoaded", f);
const body = document.body,
style = document.head.appendChild(document.createElement("style")),
dats = [...document.getElementsByClassName("tb")].map(el => el.getElementsByClassName("dats")[0]),
listener = {
options: {
// <--- НАСТРОЙКИ СКОРОСТИ И СЕТИ --->
concurrency: 3 // Параллельные запросы (3 - быстро и без 429)
, requestDelay: 0 // Пауза между запросами (мс)
, maxRetries: 3 // Повторов при 429 или сбое связи
, retryDelay: 2500 // Пауза перед повтором при 429 (мс)
// <--- ОПЦИИ СКРИПТА --->
, showAlerts: false // Показывать алерты по окончании поиска
, scrollToSearchResults: true // Автоматически прокручивать страницу к результатам поиска (если showAlerts == true, то, при подтверждении, все-равно прокрутит к результатам)
, hideSearchButton: false // Показывать кнопку поиска только при наведении курсора на пост
, autoOpenSpoilerWithResult: false // Раскрывать спойлер с результатами поиска автоматически
, reversPostSorting: true // обратить сортировку по дате для найденных постов (от новых к старым)
, headToResult: false // Включать шапку темы (если она создана выбранным пользоватем или в ней найден текст) в результаты поиска
, srchInSigs: false // Искать ли, при по тексту, в подписях пользователей
, srchInQuotes: false // Искать ли, при по тексту, в цитатах
, imgsToLinks: true // Заменять изображения в найденных постах ссылками на них
, createCnMenu: true // Добавить в контекстное меню пункт запускающий поиск (только для FF)
, searchOnProfileLinksByRMB: false // Поиск при клике ПКМ по ссылкам ведущим на профиль пользователя (только если нет выделенного текста и не нажаты клавиши модификаторы)
, fillSearchFormWithNames: true // Заполнять в диалоговом окне поиска по тексту поле именем(-ами)
, nameToFillSrchForm: null // Впишите сюда имя (в кавычках) которым всегда будет заполняться поле имени в форма поиска по ПКМ
, namesToSearch: ['Name1', 'Name2', 'Name3', 'etc...'] // Список имен которые можно будет выбрать из выпадающего меню в форме поиска
, highlightMatches: false // Включает подсветку совпадений в тексте найденных постов.
// Влияет на производительность и может поломать верстку постов
// (если, например, часть найденной фразы находится внутри одного элемента, а часть - за его пределами).
// Подсветит результат и в цитатах / подписях, даже при их исключении из поиска (только подсветит, на результаты не влияет)
, highlightColor: 'yellow' // Цвет подсветки (html color, rgb(a) / hsl(a) / hex, etc)
}
, names: []
, xhrPool: []
, txt: ''
, dtStmp: null
, forum: null
, topic: null
, hasPinnedHead: false
, firstPostSignature: null
, ttlShow: '\u25BA Показать результаты поиска'
, ttlHide: '\u25BC Скрыть результаты поиска'
, ttlLdng: '\u23F3 Поиск... (Нажмите, чтобы прервать)'
, ttlNotFnd: '\u26A0 Сообщений не найдено!'
, get name() { return this.name = this.getName() }
, set name(str) { return this.setName(str) }
, get text() {
const text = this.txt;
delete this.txt;
return text;
}
, set text(str) { if (str) return this.txt = str; }
, get win() { delete this.win; return this.win = window.top }
, get loc() { delete this.loc; return this.loc = this.win.location.href }
, get actnBox() {
delete this.actnBox;
return this.actnBox = [...document.getElementsByTagName("table")].find(el => el.querySelector("a[href^='post.cgi?action=new']")
|| !el.getElementsByTagName('td')[0].children.length)
}
, get isFF() {
delete this.isFF;
return this.isFF = this.win.navigator && this.win.navigator.userAgent && this.win.navigator.userAgent.toLowerCase().includes("firefox")
}
, get spHd() { delete this.spHd; return this.spHd = this.getSpHd() }
, get spBd() { delete this.spBd; return this.spBd = this.getSpBd() }
, get spTTl() { delete this.spTTl; return this.spTTl = this.spHd && this.spHd.getElementsByTagName('td')[0] }
, get navBox() { delete this.navBox; return this.navBox = this.getNavBox() }
, getSel() { return this.win.getSelection && this.win.getSelection().toString() || "" }
, abortXhrs() {
if (!this.xhrPool.length) return;
for (const xhr of this.xhrPool) xhr && xhr.abort();
this.xhrPool.length = 0;
}
, delay(ms, stmp) {
return new Promise(resolve => {
const start = Date.now();
const interval = setInterval(() => {
if (this.dtStmp !== stmp || Date.now() - start >= ms) {
clearInterval(interval);
resolve();
}
}, 50);
});
}
, prepareSntc(str, cs, ww) {
const escStr = s => s.replace(/[-[\]{}()*+?.,\\^$|#]/g, '\\$&').replace(/\s+/g, '\\s+'),
rgxp = s => new RegExp(s, cs ? 'i' : ''),
wwrgxp = s => new RegExp('(?:[^а-яА-Яa-zA-Z0-9]|^)' + s + '(?:(?!(?=[а-яА-Яa-zA-Z0-9]))|$)', cs ? 'i' : '');
if (Array.isArray(str)) {
str = str.map(el => escStr(el));
return ww ? wwrgxp('(?:' + str.join('|') + ')') : rgxp(str.join('|'))
}
return ww ? wwrgxp(escStr(str)) : rgxp(escStr(str));
}
, getCrntPost(arr) {
const clst = this.isFF ? 0 : parseInt(this.win.innerHeight / 2);
return arr.reduce((prev, curr) => (Math.abs(curr - clst) < Math.abs(prev - clst) ? curr : prev))
}
, spawn(gen) {
const continuer = (verb, arg) => {
let result;
try { result = generator[verb](arg); } catch (err) { return Promise.reject(err); }
if (result.done) return result.value;
return Promise.resolve(result.value).then(onFulfilled, onRejected);
},
generator = gen(),
onFulfilled = continuer.bind(continuer, 'next'),
onRejected = continuer.bind(continuer, 'throw');
return onFulfilled();
}
, prompt(name, txt) {
return new Promise((res, rej) => {
body.appendChild(document.createElement('div')).outerHTML = html;
const mdlOverlay = document.getElementById('spu-modal'),
clsBtn = document.getElementById('spu-close'),
regChkbx = document.getElementById('spu-regexp'),
csChkbx = document.getElementById('spu-case'),
highlight = document.getElementById('spu-highlight'),
sig = document.getElementById('spu-sig'),
qt = document.getElementById('spu-qt'),
sntChkbx = document.getElementById('spu-sentence'),
wholeWord = document.getElementById('spu-whole'),
flgsFrm = document.getElementById('spu-flags'),
txtFrm = document.getElementById('spu-txtarea'),
nmsFrm = document.getElementById('spu-names'),
nmsBtn = document.getElementById('spu-names-list'),
srchBtn = document.getElementById('spu-srch-strt'),
fromOld= document.getElementById('spu-fromold'),
fromNew= document.getElementById('spu-fromnew'),
nmsMenu = document.getElementById('spu-nms-menu'),
hdToResult = document.getElementById('spu-head'),
imgs = document.getElementById('spu-imgs');
name && (nmsFrm.value = name); txt && (txtFrm.value = txt); txtFrm.focus();
for (const nm of this.options.namesToSearch) {
if (nm) {
const menuitem = nmsMenu.appendChild(document.createElement('li'));
menuitem.textContent = nm;
menuitem.onclick = () => nmsFrm.value += (nmsFrm.value ? ',' + nm : nm);
}
};
csChkbx.checked = true;
hdToResult.checked = this.options.headToResult;
hdToResult.onclick = () => { this.options.headToResult = hdToResult.checked };
imgs.checked = this.options.imgsToLinks;
imgs.onclick = () => { this.options.imgsToLinks = imgs.checked };
highlight.checked = this.options.highlightMatches;
highlight.onclick = () => { this.options.highlightMatches = highlight.checked };
sig.checked = this.options.srchInSigs;
sig.onclick = () => { this.options.srchInSigs = sig.checked };
qt.checked = this.options.srchInQuotes;
qt.onclick = () => { this.options.srchInQuotes = qt.checked };
txt && txt.startsWith('"') && txt.endsWith('"') && (sntChkbx.checked = true);
for (const el of [...mdlOverlay.getElementsByClassName('clear-form')])
el && (el.onclick = () => el.nextElementSibling.value = '');
clsBtn.onclick = () => {
mdlOverlay.remove(); body.onwheel = null; this.names.length = 0; res(null);
};
mdlOverlay.onclick = mdlOverlay.oncontextmenu = mdlOverlay.onwheel = e => {
if (e.target != mdlOverlay) return;
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
};
body.onwheel = e => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); };
nmsBtn.onclick = () => { nmsMenu.hidden = !nmsMenu.hidden };
sntChkbx.onclick = () => {
let quoted, val = txtFrm.value.trim(), indx = val.indexOf('NOT'), excl = indx != -1 ? ' ' + val.substring(indx) : '';
excl && (val = val.substring(0, indx).trimRight());
quoted = val && val.startsWith('"') && val.endsWith('"');
sntChkbx.checked
? !quoted && (val = '"' + val + '"')
: quoted && (val = val.slice(val.indexOf('"') + 1, val.lastIndexOf('"')));
txtFrm.value = val + excl;
};
this.options.reversPostSorting ? (fromNew.checked = true) : (fromOld.checked = true);
fromNew.onclick = fromOld.onclick = () => { this.options.reversPostSorting = fromNew.checked; };
regChkbx.onclick = e => {
flgsFrm.disabled = flgsFrm.hidden = !regChkbx.checked;
sntChkbx.disabled = csChkbx.disabled = wholeWord.disabled = regChkbx.checked;
!regChkbx.checked && (flgsFrm.value = '');
};
srchBtn.onclick = () => {
const val = {n: nmsFrm.value, t: regChkbx.checked ? txtFrm.value : txtFrm.value.trim(), rgxp: regChkbx.checked, flags: flgsFrm.value, cs: csChkbx.checked, ww: wholeWord.checked};
body.onwheel = null; mdlOverlay.remove();
res(val);
};
})
}
// Вычисление ID форума и темы
, getForumAndTopic() {
const matchF = this.win.location.href.match(/forum=(\d+)/),
matchT = this.win.location.href.match(/topic=(\d+)/);
return {
forum: matchF ? matchF[1] : null,
topic: matchT ? matchT[1] : null
};
}
// Формирование списка страниц по 500 постов
, get500Pages() {
const { forum, topic } = this.getForumAndTopic();
this.forum = forum;
this.topic = topic;
this.hasPinnedHead = !!document.querySelector('img[src*="pin.gif"], img[src*="sticky.gif"], img[src*="pinned"]');
if (!forum || !topic) return [[this.loc, 1, 0]];
const paginator = [...document.getElementsByTagName("p")].find(
el => el.textContent && el.textContent.trim().startsWith("Страницы:")
);
let maxStart = 0;
let isAlreadyAll = window.location.href.includes("&all");
if (paginator) {
const links = paginator.getElementsByTagName("a");
for (const a of links) {
if (a.href.includes("&all")) isAlreadyAll = true;
const match = a.href.match(/start=(\d+)/);
if (match) {
const st = parseInt(match[1], 10);
if (st > maxStart) maxStart = st;
}
}
const bolds = paginator.querySelectorAll("b, font");
for (const b of bolds) {
const num = parseInt(b.textContent.trim(), 10);
if (!isNaN(num) && num > 1) {
const st = isAlreadyAll ? (num - 1) * 500 : (num - 1) * 20;
if (st > maxStart) maxStart = st;
}
}
}
const pages = [];
let pageNum = 1;
for (let s = 0; s <= maxStart; s += 500) {
const pageUrl = `https://forum.ru-board.com/topic.cgi?forum=${forum}&topic=${topic}&start=${s}&all`;
pages.push([pageUrl, pageNum++, s]);
}
return pages.length ? pages : [[`https://forum.ru-board.com/topic.cgi?forum=${forum}&topic=${topic}&start=0&all`, 1, 0]];
}
// Загрузка страницы 500 постов
, getPosts([url, pageNum, pageStart], name, txt, stmp) {
return new Promise((res, rej) => {
if (this.dtStmp === null) return res(null);
if (this.dtStmp !== stmp) return rej({ aborted: true });
const rmv = itm => {
const idx = this.xhrPool.indexOf(itm);
if (idx !== -1) this.xhrPool.splice(idx, 1);
};
const isCurrentAll = this.win.location.href.includes("&all") &&
(this.win.location.href.includes(`start=${pageStart}`) || (pageStart === 0 && !this.win.location.href.includes("start=")));
if (isCurrentAll) {
const postsRaw = document.getElementsByClassName('tb');
if (postsRaw && postsRaw.length) {
const posts = this.processPagePosts([...postsRaw].map(el => el && el.cloneNode(true)), name, txt, pageNum, pageStart);
return res(posts);
}
return res([]);
}
const xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.responseType = "document";
xhr.timeout = 35000;
xhr.onload = () => {
rmv(xhr);
if (this.dtStmp !== null && this.dtStmp !== stmp) return rej({ aborted: true });
if (xhr.status === 200) {
const postsRaw = xhr.responseXML && xhr.responseXML.getElementsByClassName('tb');
if (postsRaw && postsRaw.length) {
const posts = this.processPagePosts([...postsRaw], name, txt, pageNum, pageStart);
res(posts);
} else {
res([]);
}
} else {
rej({ status: xhr.status, url: url });
}
};
xhr.onerror = () => { rmv(xhr); rej({ status: 0, url: url }); };
xhr.ontimeout = () => { rmv(xhr); rej({ status: 408, url: url }); };
xhr.onabort = () => { rmv(xhr); rej({ aborted: true }); };
this.xhrPool.push(xhr);
xhr.send();
});
}
// Нумерация и фильтрация
, processPagePosts(postsRaw, name, txt, pageNum, pageStart) {
if (pageStart === 0 && postsRaw.length > 0) {
const firstDats = postsRaw[0].querySelector("td.dats");
if (firstDats) this.firstPostSignature = firstDats.textContent.trim();
}
let hasHeaderOnThisPage = false;
if (pageStart > 0 && postsRaw.length > 0) {
const p0 = postsRaw[0];
const isHdr = !!p0.querySelector('a.tpc[href*="postno=1"], a[href*="postno=1"]')
|| (this.firstPostSignature && p0.querySelector("td.dats")?.textContent.trim() === this.firstPostSignature);
if (isHdr) {
hasHeaderOnThisPage = true;
this.hasPinnedHead = true;
}
}
let replyIndex = 0;
const numberedPosts = [];
for (let i = 0; i < postsRaw.length; i++) {
const post = postsRaw[i];
let seq;
let isHead = false;
if (pageStart === 0) {
seq = i + 1;
if (i === 0) isHead = true;
} else {
if (i === 0 && hasHeaderOnThisPage) {
seq = 1;
isHead = true;
} else {
seq = pageStart + replyIndex + 1;
replyIndex++;
}
}
if (isHead && pageStart > 0) continue;
if (seq === 1 && !this.options.headToResult) continue;
post.__postSeqNum = seq;
numberedPosts.push(post);
}
return this.filterPosts(numberedPosts, name, txt);
}
// Пересчет ссылок на стандартные 20 постов
, updatePostLinks(post, seq) {
const stdStart = Math.floor((seq - 1) / 20) * 20;
let stdAnchor = ((seq - 1) % 20) + 1;
if (this.hasPinnedHead && stdStart > 0) {
stdAnchor += 1;
}
const stdUrl = `https://forum.ru-board.com/topic.cgi?forum=${this.forum}&topic=${this.topic}&start=${stdStart}#${stdAnchor}`;
const pageNumber = (stdStart / 20) + 1;
// 1. Обычные ссылки на пост ("Отправлено:", заголовок и т.п.)
const links = post.querySelectorAll('a[href*="topic.cgi"]');
for (const a of links) {
const href = a.getAttribute('href') || '';
if (href.includes('#') || a.classList.contains('tpc') || a.closest('.dats')) {
if (href.includes(`topic=${this.topic}`) || !href.includes('topic=')) {
a.href = stdUrl;
a.title = `Перейти к сообщению: Стр. ${pageNumber}, Пост #${stdAnchor}`;
}
}
}
// 2. Ссылки на дату/часы для копирования: javascript:void(link('#...'))
const allPostLinks = post.getElementsByTagName('a');
for (const a of allPostLinks) {
const href = a.getAttribute('href') || '';
if (href.includes('link(') || href.includes('link%28')) {
a.href = `javascript:void(link('#${stdAnchor}'))`;
a.title = `Скопировать ссылку: Стр. ${pageNumber}, Пост #${stdAnchor}`;
a.onclick = e => {
e.preventDefault();
e.stopPropagation();
window.prompt('Ссылка на это сообщение:', stdUrl);
};
}
}
// 3. Внутренний якорь поста
const anchors = post.querySelectorAll('a[name]');
for (const a of anchors) {
a.name = String(stdAnchor);
}
}
, async fetchAllPages(pageLinks, nmsToSearch, txtQuery, stmp, onProgress, onStatus) {
const total = pageLinks.length;
const results = new Array(total);
let completed = 0;
let cursor = 0;
const worker = async () => {
while (cursor < total) {
if (this.dtStmp === null || this.dtStmp !== stmp) return;
const idx = cursor++;
const link = pageLinks[idx];
const pageNum = link[1];
let posts = null;
let retries = this.options.maxRetries;
while (retries >= 0) {
if (this.dtStmp === null || this.dtStmp !== stmp) return;
try {
posts = await this.getPosts(link, nmsToSearch, txtQuery, stmp);
break;
} catch (err) {
if (err && err.aborted) return;
if (retries > 0) {
retries--;
const isRateLimit = err && (err.status === 429 || err.status === 503);
const waitTime = isRateLimit ? this.options.retryDelay : 1000;
onStatus && onStatus(`Блок ${pageNum}: ${isRateLimit ? 'Лимит 429' : 'Сбой'}. Ожидание...`);
await this.delay(waitTime, stmp);
} else {
posts = null;
break;
}
}
}
if (this.dtStmp !== null && this.dtStmp !== stmp) return;
results[idx] = posts;
completed++;
if (onProgress) onProgress(posts, completed, total);
if (this.dtStmp === null) return;
if (this.options.requestDelay > 0) {
await this.delay(this.options.requestDelay, stmp);
}
}
};
const workers = [];
const conc = Math.min(this.options.concurrency, total);
for (let i = 0; i < conc; i++) {
workers.push(worker());
}
await Promise.all(workers);
if (this.dtStmp !== null && this.dtStmp !== stmp) return null;
return results;
}
, procesPosts(posts, stmp) {
return new Promise((res, rej) => {
if (this.dtStmp !== null && this.dtStmp !== stmp) {
posts && (posts.length = 0);
return rej([null, stmp]);
}
posts = this.flatPosts(posts).filter(Boolean);
if (posts && !!posts.length) {
for (const post of posts) {
if (post.__postSeqNum) {
this.updatePostLinks(post, post.__postSeqNum);
}
}
if (this.options.imgsToLinks) posts = this.imgsToLinks(posts);
for (let post = 0; posts.length; post++)
this.spBd.appendChild(this.options.reversPostSorting ? posts.pop() : posts.shift());
this.spBd.appendChild(this.navBox);
res([null, stmp]);
} else {
rej(['not found', stmp]);
}
});
}
, filterPosts(posts, name, txt) {
return posts.filter(post => {
const datsElem = post.querySelector("td.dats b");
if (!datsElem) return false;
const postUser = datsElem.textContent.toLowerCase().replace(/\s/g, '_');
const isUser = name && name.includes(postUser);
const isText = () => {
const pstEl = post.getElementsByClassName('post')[0];
if (!pstEl) return false;
const highligthMatches = () => {
if (this.options.highlightMatches) {
const rgxp = str => {
let source = str.source,
flags = str.toString().match(/[gimuy]*$/)[0];
source = source.replace(/\s+|\\s\+?/g, '(?:[<>&;\\w\\s]+)');
flags = flags.includes('g') ? flags : flags + 'g';
return new RegExp(source, flags);
};
if (Array.isArray(txt.val))
for (const sntnc of txt.val)
pstEl.innerHTML = pstEl.innerHTML.replace(rgxp(sntnc), '
| Найдено: Ошибок: |