Загрузка данных
// ==UserScript==
// @name Intim Mirrors - Block Images
// @namespace https://a.intimcity.promo/
// @version 3.7.0
// @description Fast image/contact blocking and risky-word censoring for intim mirrors
// @author You
// @match *://*/*
// @run-at document-start
// @grant none
// ==/UserScript==
(function () {
"use strict";
const STORAGE_KEY = "tm_intim_block_images_enabled";
const WORST_SORT_STORAGE_KEY = "tm_intim_sort_worst_reviews_enabled";
const TOGGLE_ID = "tm-image-toggle-btn";
const WORST_SORT_TOGGLE_ID = "tm-worst-review-sort-btn";
const STYLE_ID = "tm-block-images-style";
const CONTACT_STYLE_ID = "tm-hide-contacts-style";
const SEARCH_STYLE_ID = "tm-search-visibility-style";
const VISITED_STYLE_ID = "tm-visited-profile-style";
const CONTACT_KEEP_SELECTOR = "#headerBg, #headerBg *, .main_link1, .main_link1 *, #search, #search *";
const USERINFO_KEEP_SELECTOR = ".userinfo-link, .userinfo, [itemprop='author']";
const SANITIZED_TEXT_FIELD_SELECTOR =
"textarea, input[readonly][type='text'], input[readonly][type='search'], input[readonly]:not([type])";
const IMAGE_KEEP_ROOT_SELECTOR = "#headerBg, .main_link1, .up-ocenki-basic, .up-ocenki-dop";
const COMPARE_TRIGGER_SELECTOR =
'[class*="sravn"], [id*="sravn"], [class*="compare"], [id*="compare"], [onclick*="sravn"], [onclick*="compare"]';
const HIDDEN_CONTACT_ATTR = "data-tm-contact-hidden";
const HOST_RE = /intim/i; // Works for changing mirrors with "intim" in host.
const CONTACT_LINK_SELECTOR = [
'a[href^="tel:"]',
'a[href^="callto:"]',
'a[href^="sms:"]',
'a[href*="wa.me/"]',
'a[href*="api.whatsapp.com"]',
'a[href*="whatsapp"]',
'a[href*="viber"]',
'a[href*="tg://"]',
'a[href*="t.me/"]',
'[class*="phone"]',
'[class*="whatsapp"]',
'[class*="viber"]',
'[class*="telegram"]',
'[id*="phone"]',
'[id*="whatsapp"]',
'[id*="viber"]',
'[id*="telegram"]',
"[data-phone]",
"[data-tel]",
"[data-whatsapp]",
"[data-viber]",
"[data-telegram]",
].join(", ");
const CONTACT_ATTR_SCAN_SELECTOR =
"[href], [onclick], [title], [aria-label], [data-phone], [data-tel], [data-whatsapp], [data-viber], [data-telegram]";
const CONTACT_ATTR_NAME_RE = /(?:^|[-_:.])(phone|tel|whatsapp|viber|telegram|tg)(?:[-_:.]|$)/i;
const CONTACT_VALUE_RE =
/(tel:|callto:|sms:|wa\.me|api\.whatsapp\.com|whatsapp|viber|tg:\/\/|t\.me\/|telegram|\bтелефон\b|\bномер\b|\bphone\b|\btel\b)/i;
const IMAGE_ATTR_FILTER = ["src", "srcset", "poster", "style", "data-src", "data-srcset"];
const CONTACT_ATTR_FILTER = [
"href",
"onclick",
"title",
"aria-label",
"data-phone",
"data-tel",
"data-whatsapp",
"data-viber",
"data-telegram",
];
const PROFILE_CARD_SELECTOR =
"[class*='anket'], [class*='profile'], [class*='girl'], [class*='card'], [class*='item'], article, li, tr";
const PROFILE_LINK_CUE_RE = /(anket|anketa|profile|girl|model|lady|escort|user|person|id=|\/id\/|\/\d{3,}(?:[/?#]|$))/i;
const PROFILE_LINK_EXCLUDE_RE =
/(?:\/|[?&])(reviews?|comments?|forum|news|blog|search|login|register|signup|photo|photos|video|videos|contact|contacts|faq|rules?|terms|policy)(?:[/?&#=]|$)/i;
const REVIEW_CUE_RE = /(отзыв|отзыва|отзывов|reviews?|коммент|рейтинг|оценк|звезд|звёзд|★|☆|\d\s*\/\s*(?:5|10))/i;
const NO_REVIEW_CUE_RE = /(нет\s+отзыв|без\s+отзыв|отзывов?\s*0|0\s*отзыв)/i;
const NEGATIVE_REVIEW_WORD_RE =
/(?<![\p{L}\p{N}_])(?:обман\p{L}*|развод\p{L}*|кидал\p{L}*|фейк\p{L}*|ужас\p{L}*|кошмар\p{L}*|плох\p{L}*|хам\p{L}*|груб\p{L}*|гряз\p{L}*|вон\p{L}*|болез\p{L}*|некомфорт\p{L}*|неприятн\p{L}*|опозд\p{L}*|отмен\p{L}*|дорож\p{L}*|вымаг\p{L}*|не\s+рекоменд\p{L}*|не\s+совет\p{L}*)(?![\p{L}\p{N}_])/giu;
const POSITIVE_REVIEW_WORD_RE =
/(?<![\p{L}\p{N}_])(?:отлич\p{L}*|хорош\p{L}*|супер\p{L}*|класс\p{L}*|идеал\p{L}*|чист\p{L}*|вежлив\p{L}*|приятн\p{L}*|рекоменд\p{L}*|совет\p{L}*)(?![\p{L}\p{N}_])/giu;
const PROFILE_FETCH_TIMEOUT_MS = 15000;
const PROFILE_FETCH_CONCURRENCY = 4;
const PROFILE_REVIEW_PAGE_LIMIT = 80;
const REVIEW_SECTION_SELECTOR =
"[class*='review'], [id*='review'], [class*='otziv'], [id*='otziv'], [class*='comment'], [id*='comment'], [itemprop*='review'], [itemprop*='comment']";
const REVIEW_PAGINATION_SELECTOR = "[class*='pag'], [id*='pag'], nav, .pager, .pagination";
const REVIEW_PAGE_LINK_CUE_RE = /(review|reviews|comment|comments|отзыв|коммент|otziv|feedback)/i;
const REVIEW_PAGE_NAV_TEXT_RE = /^(?:\d{1,3}|next|prev|далее|назад|след|пред)$/i;
const COMMENT_AREA_SORT_EXCLUDE_RE = /(comment|comments|review|reviews|отзыв|коммент|otziv)/i;
const REVIEW_SCORE_ATTR = "data-tm-review-score";
const VISITED_PROFILE_ATTR = "data-tm-visited-profile";
const VISITED_DB_NAME = "tm_intim_profile_history_db";
const VISITED_DB_STORE = "visited_profile_routes";
const VISITED_DB_VERSION = 1;
// Max strict mode for short-video platforms.
// Edit lists only; censor regex is generated automatically.
// Use "*" at the end to match word forms by stem, e.g. "проститут*" => "проститутка/проститутки".
const FORBIDDEN_WORDS = {
adult: [
"18+",
"xxx",
"порн*",
"porn*",
"nsfw",
"эрот*",
"интим*",
"адалт*",
"adult",
"секс*",
"cекс*",
"cек*",
"sex*",
"sexy",
"nude*",
"nudity",
"голый",
"голая",
"голые",
"голыш*",
"обнажен*",
"обнаж*",
"фетиш*",
"bdsm",
"бдсм",
"минет*",
"куни*",
"аналь*",
"оральн*",
"oral*",
"оргия*",
"дроч*",
"мастурб*",
"онлифанс",
"onlyfans",
"only fans",
"вебкам*",
"webcam*",
"camgirl*",
"cam model*",
"секс-услуг*",
"sexwork*",
"sex work*",
],
escort: [
"эскорт*",
"эск##т",
"escort*",
"проститут*",
"проституц*",
"шлюх*",
"шалав*",
"девушка по вызову",
"девочки по вызову",
"по вызову",
"интим услуги",
],
intimacyServices: [
"классич* секс*",
"секс класс*",
"группов* секс*",
"аналь*",
"ана##*",
"минет*",
"ми##т*",
"горлов* минет*",
"кунилингус*",
"поцелу*",
"окончание в рот",
"окончание на лицо",
"окончание на грудь",
"член*",
"глот*",
"конч*",
"сквирт*",
"виртуальн* секс*",
"секс по телефону",
"телефонн* секс*",
"эротическ* массаж*",
"эро#######*",
"урологическ* массаж*",
"лингам*",
"карсай*",
"страпон*",
"анилингус*",
"золот* дожд*",
"копро*",
"фистинг*",
"фингеринг*",
"садо-мазо",
"садо мазо",
"бандаж*",
"госпожа*",
"игры*",
"легк* доминац*",
"порк*",
"рабын*",
"фетиш*",
"фе##ш*",
"футфетиш*",
"трамплинг*",
"клизм*",
"боллбастинг*",
"ballbusting*",
"феминизац*",
"размер имеет значение",
"услуги семейной паре",
],
violenceSelfHarm: [
"насили*",
"изнасил*",
"убий*",
"убить",
"расстрел*",
"зарез*",
"задуш*",
"пытк*",
"избиени*",
"жесток*",
"жесть",
"мясоруб*",
"кровав*",
"труп*",
"расчлен*",
"суицид*",
"самоубий*",
"самоповрежд*",
"селфхарм*",
"selfharm*",
"self-harm*",
"suicide*",
"kill*",
"murder*",
"dead body",
"corpse",
"повес*",
"вскрыть вены",
"резать вены",
"вены вскры*",
],
drugsAlcoholTobacco: [
"наркот*",
"закладк*",
"закладчик*",
"драг*",
"drug*",
"cocaine",
"кокаин*",
"heroin",
"героин*",
"meth",
"метамфет*",
"амфет*",
"мефедрон*",
"mdma",
"мдма",
"экстази",
"ecstasy",
"спайс*",
"соль*",
"марихуан*",
"конопл*",
"каннабис*",
"гашиш*",
"weed",
"420",
"алкогол*",
"бухл*",
"бухат*",
"пьян*",
"водк*",
"виски",
"whiskey",
"пиво",
"вино",
"самогон*",
"табак*",
"сигарет*",
"сигар*",
"вейп*",
"vape*",
"никотин*",
"smok*",
"курен*",
],
hateDiscrimination: [
"расист*",
"расизм*",
"нацист*",
"нацизм*",
"фаш*",
"ксенофоб*",
"дискриминац*",
"унижен*",
"оскорбл*",
"hatespeech",
"hate speech",
"slur*",
"негр*",
"ниггер*",
"nigg*",
"chink*",
"spic*",
"kike*",
"trann*",
"транн*",
"fagg*",
"пидор*",
"пидр*",
"гомик*",
"жидов*",
"жиден*",
"хач*",
"чурк*",
"blackface",
"white power",
"supremac*",
],
crimeScam: [
"мошенн*",
"скам*",
"scam*",
"обман*",
"развод*",
"краж*",
"грабеж*",
"угон*",
"взлом*",
"hack*",
"хак*",
"phishing*",
"фишинг*",
"кардинг*",
"carding*",
"даркнет*",
"darknet*",
"отмыв*",
"подделк*",
"фейк док*",
"подделка документов",
"вымогат*",
"шантаж*",
"террор*",
"экстрем*",
"пропаганд* террор",
"вербовк*",
"crypto pump",
"памп*",
"дамп*",
"фин пирами*",
"пирамид*",
"дропп*",
"обнал*",
],
weaponsExplosives: [
"оружи*",
"огнестрел*",
"пистолет*",
"револьвер*",
"автомат*",
"штурмовая винтовк*",
"shotgun*",
"rifle*",
"gun*",
"ammo*",
"патрон*",
"нож*",
"кастет*",
"гранат*",
"бомб*",
"взрыв*",
"взрывчат*",
"терракт*",
"теракт*",
"стрельб*",
"поджог*",
],
gamblingBetting: [
"казино*",
"casino*",
"ставк*",
"бетт*",
"betting*",
"букмекер*",
"лудоман*",
"gambl*",
"джекпот*",
"слот*",
"рулетк*",
"тотализатор*",
"покер*",
],
medicalMisinformation: [
"чудо-лекарств*",
"чудо средство",
"гарантированно лечит",
"вылечит рак",
"лечит рак",
"без врачей",
"без операций",
"анорекс*",
"булими*",
"быстрое похудение",
"похудеть за 3 дня",
"похудеть за неделю на 10",
"таблетки для похудения",
"fat burner",
],
minorsAndAbuse: [
"педоф*",
"несовершеннолетн*",
"малолетк*",
"child porn*",
"lolicon*",
"лоликон*",
"incest*",
"инцест*",
"grooming*",
"груминг*",
],
bloggerEuphemisms: [
"запрещенк*",
"деликатная тема",
"контент 18",
"клубничк*",
"клубника",
"темки 18",
"серый контент",
"gray content",
"грязные деньги",
"быстрый заработок",
"легкие деньги",
"гарантированный доход",
"100% прибыль",
],
profanityRu: [
"бля*",
"бляд*",
"еб*",
"ёб*",
"пизд*",
"хуй*",
"хер*",
"наху*",
"нахер*",
"сук*",
"мраз*",
"твар*",
"трах*",
"гандон*",
"мудак*",
"долбоеб*",
"долбаеб*",
"ублюд*",
"уеб*",
"шмар*",
"сран*",
"срать*",
"сру*",
"засра*",
"говн*",
"петух*",
"чмо*",
],
profanityEn: [
"fuck*",
"shit*",
"bitch*",
"asshole*",
"motherf*",
"cunt*",
"whore*",
"slut*",
"dick*",
"cock*",
"pussy*",
"bastard*",
"retard*",
"wtf",
"stfu",
],
};
const FORBIDDEN_WORD_TOKENS = Object.values(FORBIDDEN_WORDS).flat();
const RISKY_WORD_REGEX = compileForbiddenWordRegex(FORBIDDEN_WORD_TOKENS);
const TEXT_SANITIZE_HINT_RE = compileSanitizeHintRegex(FORBIDDEN_WORD_TOKENS);
const OBSERVER_ATTR_FILTER = Array.from(new Set([...IMAGE_ATTR_FILTER, ...CONTACT_ATTR_FILTER]));
if (!HOST_RE.test(location.hostname)) return;
const blockingEnabled = localStorage.getItem(STORAGE_KEY) !== "0";
const worstSortEnabled = localStorage.getItem(WORST_SORT_STORAGE_KEY) !== "0";
let worstSortScheduled = false;
let worstSortInFlight = false;
let worstSortRerunRequested = false;
let visitedMarkScheduled = false;
let visitedMarkInFlight = false;
let visitedMarkRerunRequested = false;
const profileBadnessScoreCache = new Map();
const profileBadnessRequestCache = new Map();
const visitedRouteStateCache = new Map();
let visitedDbPromise = null;
const CSS_BLOCK = `
img,
picture,
source,
svg image,
video[poster],
[style*="background-image"]:not(#headerBg):not(#headerBg *):not(.main_link1):not(.main_link1 *):not(.up-ocenki-basic):not(.up-ocenki-basic *):not(.up-ocenki-dop):not(.up-ocenki-dop *) {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
}
*:not(#headerBg):not(#headerBg *):not(.main_link1):not(.main_link1 *):not(.up-ocenki-basic):not(.up-ocenki-basic *):not(.up-ocenki-dop):not(.up-ocenki-dop *) {
background-image: none !important;
}
.up-ocenki-basic img,
.up-ocenki-dop img {
display: inline-block !important;
visibility: visible !important;
opacity: 1 !important;
}
`;
const CSS_HIDE_CONTACTS = `
a[href^="tel:"],
a[href^="callto:"],
a[href^="sms:"],
a[href*="wa.me/"],
a[href*="api.whatsapp.com"],
a[href*="whatsapp"],
a[href*="viber"],
a[href*="tg://"],
a[href*="t.me/"],
[data-phone],
[data-tel],
[data-whatsapp],
[data-viber],
[data-telegram] {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
}
`;
const CSS_SEARCH_VISIBILITY = `
#search,
#search .searchphone,
#search form {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
#search #searchStr {
background: #ffffff !important;
color: #111111 !important;
border: 1px solid #777777 !important;
border-radius: 4px !important;
padding: 4px 8px !important;
}
#search #searchPS {
display: inline-block !important;
visibility: visible !important;
opacity: 1 !important;
min-width: 74px !important;
min-height: 28px !important;
padding: 4px 10px !important;
border: 1px solid #666666 !important;
border-radius: 4px !important;
background: #f2f2f2 !important;
color: #111111 !important;
cursor: pointer !important;
text-indent: 0 !important;
line-height: 1.2 !important;
}
#search #menuRight2 {
color: #f2f2f2 !important;
}
#search #menuRight2 a.menu {
color: #f2f2f2 !important;
text-decoration: underline !important;
}
#search #menuRight2 a.menu[style*="#CC0000"],
#search #menuRight2 a.menu[style*="#cc0000"] {
color: #ff5959 !important;
}
`;
const CSS_VISITED_PROFILES = `
[${VISITED_PROFILE_ATTR}="1"] {
position: relative !important;
outline: 2px solid rgba(42, 130, 67, 0.55) !important;
outline-offset: -2px !important;
}
[${VISITED_PROFILE_ATTR}="1"]::before {
content: "Посещено!" !important;
position: absolute !important;
top: 6px !important;
left: 6px !important;
z-index: 2147483000 !important;
padding: 2px 8px !important;
border-radius: 999px !important;
font: 700 11px/1.1 Arial, sans-serif !important;
color: #ffffff !important;
background: #21813f !important;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.35) !important;
pointer-events: none !important;
}
`;
function appendNode(node) {
const root = document.documentElement || document.head || document.body;
if (root) {
root.appendChild(node);
return;
}
document.addEventListener(
"readystatechange",
() => {
const nextRoot = document.documentElement || document.head || document.body;
if (nextRoot && !node.isConnected) nextRoot.appendChild(node);
},
{ once: true }
);
}
function setBlockingState(enabled) {
localStorage.setItem(STORAGE_KEY, enabled ? "1" : "0");
}
function setWorstSortState(enabled) {
localStorage.setItem(WORST_SORT_STORAGE_KEY, enabled ? "1" : "0");
}
function injectToggleButton() {
if (document.getElementById(TOGGLE_ID)) return;
const style = document.createElement("style");
style.textContent = `
#${TOGGLE_ID} {
position: fixed !important;
right: 12px !important;
bottom: 12px !important;
z-index: 2147483647 !important;
padding: 8px 12px !important;
border: 1px solid #333 !important;
border-radius: 8px !important;
font: 12px/1.2 Arial, sans-serif !important;
cursor: pointer !important;
color: #fff !important;
background: ${blockingEnabled ? "#b00020" : "#0a7a2f"} !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35) !important;
}
#${TOGGLE_ID}:hover {
filter: brightness(1.08) !important;
}
`;
appendNode(style);
const btn = document.createElement("button");
btn.id = TOGGLE_ID;
btn.type = "button";
btn.textContent = blockingEnabled ? "Изображения: OFF" : "Изображения: ON";
btn.title = "Переключить и перезагрузить";
btn.addEventListener("click", () => {
setBlockingState(!blockingEnabled);
location.reload();
});
const mount = () => {
if (!document.body) return;
document.body.appendChild(btn);
};
if (document.body) {
mount();
} else {
document.addEventListener("DOMContentLoaded", mount, { once: true });
}
}
function injectWorstSortButton() {
if (document.getElementById(WORST_SORT_TOGGLE_ID)) return;
const style = document.createElement("style");
style.textContent = `
#${WORST_SORT_TOGGLE_ID} {
position: fixed !important;
right: 12px !important;
bottom: 52px !important;
z-index: 2147483647 !important;
padding: 8px 12px !important;
border: 1px solid #333 !important;
border-radius: 8px !important;
font: 12px/1.2 Arial, sans-serif !important;
cursor: pointer !important;
color: #fff !important;
background: ${worstSortEnabled ? "#8d1f2a" : "#2f4f6b"} !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35) !important;
}
#${WORST_SORT_TOGGLE_ID}:hover {
filter: brightness(1.08) !important;
}
`;
appendNode(style);
const btn = document.createElement("button");
btn.id = WORST_SORT_TOGGLE_ID;
btn.type = "button";
btn.textContent = worstSortEnabled ? "Сортировка по отзывам: ON" : "Сортировка по отзывам: OFF";
btn.title = "Переключить сортировку и перезагрузить";
btn.addEventListener("click", () => {
setWorstSortState(!worstSortEnabled);
location.reload();
});
const mount = () => {
if (!document.body) return;
document.body.appendChild(btn);
};
if (document.body) {
mount();
} else {
document.addEventListener("DOMContentLoaded", mount, { once: true });
}
}
function injectBlockCss() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS_BLOCK;
appendNode(style);
}
function injectContactCss() {
if (document.getElementById(CONTACT_STYLE_ID)) return;
const style = document.createElement("style");
style.id = CONTACT_STYLE_ID;
style.textContent = CSS_HIDE_CONTACTS;
appendNode(style);
}
function injectSearchCss() {
if (document.getElementById(SEARCH_STYLE_ID)) return;
const style = document.createElement("style");
style.id = SEARCH_STYLE_ID;
style.textContent = CSS_SEARCH_VISIBILITY;
appendNode(style);
}
function injectVisitedCss() {
if (document.getElementById(VISITED_STYLE_ID)) return;
const style = document.createElement("style");
style.id = VISITED_STYLE_ID;
style.textContent = CSS_VISITED_PROFILES;
appendNode(style);
}
function repairSearchUi(root) {
if (!(root instanceof Document || root instanceof Element)) return;
const btn = root.querySelector("#searchPS");
if (btn instanceof HTMLInputElement && btn.type === "submit") {
if (!btn.value || !btn.value.trim()) {
btn.value = "Найти";
}
}
}
function normalizeSpaces(value) {
return (value || "").replace(/\s+/g, " ").trim();
}
function parseLocaleFloat(value) {
if (value === null || value === undefined) return null;
const normalized = String(value).replace(",", ".").trim();
const num = Number.parseFloat(normalized);
return Number.isFinite(num) ? num : null;
}
function normalizeRatingToFiveScale(value, scale) {
if (value === null) return null;
if (scale === 5) return Math.max(0, Math.min(5, value));
if (scale === 10) return Math.max(0, Math.min(5, value / 2));
if (scale === 100) return Math.max(0, Math.min(5, value / 20));
if (value <= 5) return Math.max(0, value);
if (value <= 10) return Math.max(0, value / 2);
if (value <= 100) return Math.max(0, value / 20);
return null;
}
function countRegexMatches(value, regex) {
if (!value || !regex) return 0;
regex.lastIndex = 0;
let count = 0;
while (regex.exec(value)) {
count += 1;
if (count >= 100) break;
}
regex.lastIndex = 0;
return count;
}
function extractRatingsFromText(value) {
const text = value || "";
const ratings = [];
const pushRating = (raw, scale) => {
const parsed = parseLocaleFloat(raw);
const normalized = normalizeRatingToFiveScale(parsed, scale);
if (normalized === null) return;
ratings.push(normalized);
};
for (const m of text.matchAll(/(\d{1,2}(?:[.,]\d+)?)\s*\/\s*5\b/giu)) {
pushRating(m[1], 5);
}
for (const m of text.matchAll(/(\d{1,2}(?:[.,]\d+)?)\s*\/\s*10\b/giu)) {
pushRating(m[1], 10);
}
for (const m of text.matchAll(/(\d{1,2}(?:[.,]\d+)?)\s*(?:из|of)\s*5\b/giu)) {
pushRating(m[1], 5);
}
for (const m of text.matchAll(/(\d{1,2}(?:[.,]\d+)?)\s*(?:из|of)\s*10\b/giu)) {
pushRating(m[1], 10);
}
for (const m of text.matchAll(/\b(\d{1,3})\s*%\b/giu)) {
pushRating(m[1], 100);
}
for (const m of text.matchAll(/(?:рейтинг|оценк[аи]?|rating|rate)\s*[:\-]?\s*(\d{1,2}(?:[.,]\d+)?)/giu)) {
pushRating(m[1], null);
}
for (const stars of text.matchAll(/[★☆]{3,5}/g)) {
const chunk = stars[0] || "";
const total = chunk.length;
if (!total) continue;
const filled = Array.from(chunk).filter((ch) => ch === "★").length;
ratings.push((filled / total) * 5);
}
return ratings;
}
function collectCardSignalText(card) {
if (!(card instanceof Element)) return "";
const chunks = [];
const pushChunk = (value) => {
const next = normalizeSpaces(value || "");
if (!next) return;
chunks.push(next);
};
const collectAttrs = (el) => {
if (!(el instanceof Element)) return;
pushChunk(el.getAttribute("title"));
pushChunk(el.getAttribute("aria-label"));
pushChunk(el.getAttribute("alt"));
pushChunk(el.getAttribute("data-rating"));
pushChunk(el.getAttribute("data-rate"));
pushChunk(el.getAttribute("data-stars"));
pushChunk(el.getAttribute("data-score"));
pushChunk(el.getAttribute("data-review"));
pushChunk(el.getAttribute("data-reviews"));
pushChunk(el.getAttribute("data-otziv"));
pushChunk(el.getAttribute("data-comments"));
pushChunk(el.getAttribute("data-comment-count"));
};
pushChunk(card.textContent || "");
collectAttrs(card);
const attrNodes = card.querySelectorAll(
"[title], [aria-label], [alt], [data-rating], [data-rate], [data-stars], [data-score], [data-review], [data-reviews], [data-otziv], [data-comments], [data-comment-count]"
);
let seen = 0;
for (const node of attrNodes) {
collectAttrs(node);
seen += 1;
if (seen >= 180) break;
}
return normalizeSpaces(chunks.join(" ")).toLowerCase();
}
function extractReviewCount(value) {
const text = value || "";
let maxCount = null;
const patterns = [
/(\d{1,4})\s*(?:отзыв(?:а|ов)?|reviews?|коммент(?:а|ов|ариев)?)/giu,
/(?:отзыв(?:а|ов)?|reviews?|коммент(?:а|ов|ариев)?)\s*[:\-]?\s*(\d{1,4})/giu,
];
for (const re of patterns) {
for (const m of text.matchAll(re)) {
const raw = Number.parseInt(m[1], 10);
if (!Number.isFinite(raw)) continue;
if (raw < 0 || raw > 5000) continue;
maxCount = maxCount === null ? raw : Math.max(maxCount, raw);
}
}
return maxCount;
}
function getReviewBadnessScore(card) {
const text = collectCardSignalText(card);
if (!text) return Number.NEGATIVE_INFINITY;
const ratings = extractRatingsFromText(text);
const reviewCount = extractReviewCount(text);
const negativeHits = countRegexMatches(text, NEGATIVE_REVIEW_WORD_RE);
const positiveHits = countRegexMatches(text, POSITIVE_REVIEW_WORD_RE);
const hasReviewCue = REVIEW_CUE_RE.test(text);
let score = 0;
if (ratings.length) {
const minRating = Math.min(...ratings);
const avgRating = ratings.reduce((sum, value) => sum + value, 0) / ratings.length;
score += (5 - minRating) * 14;
score += (5 - avgRating) * 6;
} else if (hasReviewCue) {
score += 2;
} else {
score -= 10;
}
if (reviewCount !== null) {
score += Math.min(reviewCount, 200) * 0.05;
}
score += negativeHits * 3.5;
score -= positiveHits * 1.6;
if (NO_REVIEW_CUE_RE.test(text)) {
score -= 8;
}
if (/не\s+рекоменд|не\s+совет/iu.test(text)) {
score += 5;
}
return score;
}
function isLikelyProfileHref(rawHref) {
const href = String(rawHref || "").trim();
if (!href || href === "#") return false;
if (/^(?:javascript:|tel:|mailto:|sms:)/i.test(href)) return false;
const normalized = href.toLowerCase();
if (PROFILE_LINK_EXCLUDE_RE.test(normalized)) return false;
return PROFILE_LINK_CUE_RE.test(normalized);
}
function isLikelyProfileLink(link) {
if (!(link instanceof HTMLAnchorElement)) return false;
if (!isLikelyProfileHref(link.href || link.getAttribute("href") || "")) return false;
if (link.closest("#headerBg, #search, footer, nav")) return false;
return true;
}
function isValidProfileCardCandidate(card) {
if (!(card instanceof Element)) return false;
if (!card.parentElement) return false;
if (card.closest("#headerBg, #search, .main_link1, footer, nav")) return false;
if (isInsideContactKeepArea(card) || isUserInfoElement(card)) return false;
const text = normalizeSpaces(card.textContent || "");
if (text.length < 20 || text.length > 7000) return false;
return true;
}
function findCardBySiblingPattern(link) {
if (!(link instanceof Element)) return null;
let current = link.parentElement;
let depth = 0;
while (current && depth < 8) {
const parent = current.parentElement;
if (!(parent instanceof Element)) break;
if (parent.closest("#headerBg, #search, .main_link1, footer, nav")) break;
const siblings = Array.from(parent.children);
if (siblings.length >= 3 && siblings.length <= 220) {
let profileSiblings = 0;
for (const sibling of siblings) {
if (!(sibling instanceof Element)) continue;
let linksChecked = 0;
let hasProfileLink = false;
for (const nestedLink of sibling.querySelectorAll("a[href]")) {
if (isLikelyProfileLink(nestedLink)) {
hasProfileLink = true;
break;
}
linksChecked += 1;
if (linksChecked >= 6) break;
}
if (!hasProfileLink) continue;
profileSiblings += 1;
if (profileSiblings >= 3) break;
}
if (profileSiblings >= 3 && isValidProfileCardCandidate(current)) {
return current;
}
}
current = parent;
depth += 1;
}
return null;
}
function findProfileCardForLink(link) {
const selectorCard = link.closest(PROFILE_CARD_SELECTOR);
if (isValidProfileCardCandidate(selectorCard)) return selectorCard;
return findCardBySiblingPattern(link);
}
function normalizeProfileUrl(rawUrl) {
if (!rawUrl) return "";
try {
const normalized = new URL(rawUrl, location.href);
normalized.hash = "";
return normalized.href;
} catch {
return "";
}
}
function buildNormalizedQueryString(searchParams) {
const filtered = [];
for (const [name, value] of searchParams.entries()) {
if (/^(?:utm_[a-z0-9_]*|fbclid|gclid|yclid|ref|from)$/i.test(name)) continue;
filtered.push([name, value]);
}
filtered.sort((a, b) => {
if (a[0] === b[0]) return a[1].localeCompare(b[1]);
return a[0].localeCompare(b[0]);
});
return filtered
.map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`)
.join("&");
}
function extractStrongProfileId(urlObj) {
if (!(urlObj instanceof URL)) return "";
const pathMatch = urlObj.pathname.match(/(?:^|\/)(?:id\/)?(\d{3,})(?:\/|$)/i);
if (pathMatch && pathMatch[1]) return pathMatch[1];
for (const key of ["id", "anketa", "anket", "profile", "girl", "model", "user"]) {
const value = (urlObj.searchParams.get(key) || "").trim();
if (/^\d{3,}$/.test(value)) return value;
}
return "";
}
function parseProfileRoute(rawUrl) {
const normalizedUrl = normalizeProfileUrl(rawUrl);
if (!normalizedUrl) return null;
try {
const urlObj = new URL(normalizedUrl);
const full = `${urlObj.pathname}${urlObj.search}`.toLowerCase();
if (!isLikelyProfileHref(full)) return null;
const strongId = extractStrongProfileId(urlObj);
if (strongId) {
return {
key: `id:${strongId}`,
normalizedUrl: urlObj.href,
};
}
const path = (urlObj.pathname || "/").replace(/\/+$/g, "").toLowerCase() || "/";
const normalizedQuery = buildNormalizedQueryString(urlObj.searchParams);
const fallbackRoute = normalizedQuery ? `${path}?${normalizedQuery}` : path;
return {
key: `route:${fallbackRoute}`,
normalizedUrl: urlObj.href,
};
} catch {
return null;
}
}
function requestToPromise(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function openVisitedDb() {
if (visitedDbPromise) return visitedDbPromise;
if (!("indexedDB" in window)) {
visitedDbPromise = Promise.resolve(null);
return visitedDbPromise;
}
visitedDbPromise = new Promise((resolve) => {
try {
const openRequest = indexedDB.open(VISITED_DB_NAME, VISITED_DB_VERSION);
openRequest.onupgradeneeded = () => {
const db = openRequest.result;
if (!db.objectStoreNames.contains(VISITED_DB_STORE)) {
db.createObjectStore(VISITED_DB_STORE, { keyPath: "routeKey" });
}
};
openRequest.onsuccess = () => resolve(openRequest.result);
openRequest.onerror = () => {
visitedDbPromise = null;
resolve(null);
};
openRequest.onblocked = () => {
visitedDbPromise = null;
resolve(null);
};
} catch {
visitedDbPromise = null;
resolve(null);
}
});
return visitedDbPromise;
}
async function rememberVisitedProfile(rawUrl) {
const route = parseProfileRoute(rawUrl);
if (!route) return;
visitedRouteStateCache.set(route.key, true);
const db = await openVisitedDb();
if (!db) return;
try {
const tx = db.transaction(VISITED_DB_STORE, "readwrite");
const store = tx.objectStore(VISITED_DB_STORE);
store.put({
routeKey: route.key,
profileUrl: route.normalizedUrl,
visitedAt: Date.now(),
});
} catch {
// no-op
}
}
async function resolveVisitedRouteMap(routeKeys) {
const result = new Map();
const uniqueKeys = Array.from(new Set(routeKeys.filter(Boolean)));
if (!uniqueKeys.length) return result;
const missingKeys = [];
for (const key of uniqueKeys) {
if (visitedRouteStateCache.has(key)) {
result.set(key, visitedRouteStateCache.get(key) === true);
} else {
missingKeys.push(key);
}
}
if (!missingKeys.length) return result;
const db = await openVisitedDb();
if (!db) {
missingKeys.forEach((key) => {
visitedRouteStateCache.set(key, false);
result.set(key, false);
});
return result;
}
try {
const tx = db.transaction(VISITED_DB_STORE, "readonly");
const store = tx.objectStore(VISITED_DB_STORE);
await Promise.all(
missingKeys.map(async (key) => {
try {
const record = await requestToPromise(store.get(key));
const isVisited = Boolean(record);
visitedRouteStateCache.set(key, isVisited);
result.set(key, isVisited);
} catch {
visitedRouteStateCache.set(key, false);
result.set(key, false);
}
})
);
} catch {
missingKeys.forEach((key) => {
visitedRouteStateCache.set(key, false);
result.set(key, false);
});
}
return result;
}
function setCardVisitedState(card, visited) {
if (!(card instanceof Element)) return;
if (visited) {
card.setAttribute(VISITED_PROFILE_ATTR, "1");
} else {
card.removeAttribute(VISITED_PROFILE_ATTR);
}
}
function collectProfileCardEntries(root) {
if (!(root instanceof Document || root instanceof Element)) return [];
const entriesByCard = new Map();
root.querySelectorAll("a[href]").forEach((link) => {
if (!isLikelyProfileLink(link)) return;
const card = findProfileCardForLink(link);
if (!card) return;
const route = parseProfileRoute(link.href || link.getAttribute("href") || "");
if (!route) return;
if (!entriesByCard.has(card)) {
entriesByCard.set(card, {
card,
profileUrl: route.normalizedUrl,
routeKey: route.key,
});
}
});
return Array.from(entriesByCard.values());
}
async function markVisitedProfiles(root) {
if (visitedMarkInFlight) {
visitedMarkRerunRequested = true;
return;
}
visitedMarkInFlight = true;
visitedMarkRerunRequested = false;
try {
const entries = collectProfileCardEntries(root);
if (!entries.length) return;
const routeMap = await resolveVisitedRouteMap(entries.map((entry) => entry.routeKey));
entries.forEach((entry) => {
setCardVisitedState(entry.card, routeMap.get(entry.routeKey) === true);
});
} finally {
visitedMarkInFlight = false;
if (visitedMarkRerunRequested) {
visitedMarkRerunRequested = false;
scheduleVisitedMarking();
}
}
}
function scheduleVisitedMarking() {
if (visitedMarkScheduled) return;
visitedMarkScheduled = true;
setTimeout(() => {
visitedMarkScheduled = false;
void markVisitedProfiles(document);
}, 160);
}
function getElementCueText(el) {
if (!(el instanceof Element)) return "";
const className = typeof el.className === "string" ? el.className : "";
return `${el.id || ""} ${className} ${el.getAttribute("data-type") || ""}`.toLowerCase();
}
function isCommentOrReviewArea(el) {
if (!(el instanceof Element)) return false;
let current = el;
let depth = 0;
while (current && depth < 4) {
if (COMMENT_AREA_SORT_EXCLUDE_RE.test(getElementCueText(current))) return true;
current = current.parentElement;
depth += 1;
}
return false;
}
function collectProfileCardGroups(root) {
if (!(root instanceof Document || root instanceof Element)) return [];
const groups = new Map();
collectProfileCardEntries(root).forEach((entry) => {
const card = entry.card;
const parent = card.parentElement;
if (!(parent instanceof Element)) return;
let cardMap = groups.get(parent);
if (!cardMap) {
cardMap = new Map();
groups.set(parent, cardMap);
}
if (!cardMap.has(card)) {
cardMap.set(card, entry.profileUrl);
}
});
const result = [];
for (const [parent, cardMap] of groups.entries()) {
if (isCommentOrReviewArea(parent)) continue;
const entries = Array.from(cardMap.entries())
.filter(([card]) => card.parentElement === parent)
.map(([card, profileUrl]) => ({ card, profileUrl }));
if (entries.length < 3) continue;
result.push({ parent, entries });
}
return result;
}
function reorderCardsPreservingSlots(parent, sortedCards) {
const sortedSet = new Set(sortedCards);
const slotMarkers = [];
Array.from(parent.children).forEach((child) => {
if (!sortedSet.has(child)) return;
const marker = document.createComment("tm-worst-review-slot");
parent.insertBefore(marker, child);
slotMarkers.push(marker);
});
if (slotMarkers.length !== sortedCards.length) {
slotMarkers.forEach((marker) => marker.remove());
return;
}
for (let i = 0; i < sortedCards.length; i += 1) {
parent.insertBefore(sortedCards[i], slotMarkers[i]);
}
slotMarkers.forEach((marker) => marker.remove());
}
function normalizeFetchUrl(rawUrl) {
if (!rawUrl) return "";
try {
const urlObj = new URL(rawUrl, location.href);
urlObj.hash = "";
const normalizedQuery = buildNormalizedQueryString(urlObj.searchParams);
urlObj.search = normalizedQuery ? `?${normalizedQuery}` : "";
return urlObj.href;
} catch {
return "";
}
}
async function fetchPageHtml(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PROFILE_FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
method: "GET",
credentials: "include",
signal: controller.signal,
});
if (!response.ok) return "";
return await response.text();
} catch {
return "";
} finally {
clearTimeout(timer);
}
}
function scoreReviewSignalText(value) {
const text = normalizeSpaces(value || "").toLowerCase();
if (!text) return 0;
const ratings = extractRatingsFromText(text);
const reviewCount = extractReviewCount(text);
const negativeHits = countRegexMatches(text, NEGATIVE_REVIEW_WORD_RE);
const positiveHits = countRegexMatches(text, POSITIVE_REVIEW_WORD_RE);
const hasReviewCue = REVIEW_CUE_RE.test(text);
let score = 0;
if (ratings.length) {
const minRating = Math.min(...ratings);
const avgRating = ratings.reduce((sum, item) => sum + item, 0) / ratings.length;
score += (5 - minRating) * 15;
score += (5 - avgRating) * 6;
} else if (hasReviewCue) {
score += 2;
}
if (reviewCount !== null) {
score += Math.min(reviewCount, 600) * 0.07;
}
score += negativeHits * 3.8;
score -= positiveHits * 1.7;
if (NO_REVIEW_CUE_RE.test(text)) {
score -= 10;
}
if (/не\s+рекоменд|не\s+совет/iu.test(text)) {
score += 6;
}
return score;
}
function collectReviewTextChunks(doc) {
if (!(doc instanceof Document)) return [];
const chunks = [];
const seen = new Set();
const pushChunk = (value) => {
const normalized = normalizeSpaces(value || "").toLowerCase();
if (normalized.length < 24) return;
const hasSignal =
REVIEW_CUE_RE.test(normalized) ||
extractRatingsFromText(normalized).length > 0 ||
countRegexMatches(normalized, NEGATIVE_REVIEW_WORD_RE) > 0 ||
countRegexMatches(normalized, POSITIVE_REVIEW_WORD_RE) > 0;
if (!hasSignal) return;
const key = `${normalized.slice(0, 180)}|${normalized.length}`;
if (seen.has(key)) return;
seen.add(key);
chunks.push(normalized);
};
let seenNodes = 0;
for (const node of doc.querySelectorAll(REVIEW_SECTION_SELECTOR)) {
if (!(node instanceof Element)) continue;
const text = node.textContent || "";
if (text.length < 24) continue;
pushChunk(text);
seenNodes += 1;
if (seenNodes >= 220) break;
}
if (!chunks.length && doc.body) {
pushChunk((doc.body.textContent || "").slice(0, 120000));
}
return chunks;
}
function isSameProfileScope(urlA, urlB) {
if (!(urlA instanceof URL) || !(urlB instanceof URL)) return true;
const idA = extractStrongProfileId(urlA);
const idB = extractStrongProfileId(urlB);
if (!idA || !idB) return true;
return idA === idB;
}
function collectReviewPageLinks(doc, sourceUrl) {
if (!(doc instanceof Document)) return [];
let source;
try {
source = new URL(sourceUrl);
} catch {
return [];
}
const sourceNormalized = normalizeFetchUrl(source.href);
const links = new Set();
const hasReviewSection = Boolean(doc.querySelector(REVIEW_SECTION_SELECTOR));
doc.querySelectorAll("a[href]").forEach((link) => {
if (!(link instanceof HTMLAnchorElement)) return;
const rawHref = (link.getAttribute("href") || "").trim();
if (!rawHref || rawHref.startsWith("#") || /^javascript:/i.test(rawHref)) return;
let resolved;
try {
resolved = new URL(rawHref, source.href);
} catch {
return;
}
if (resolved.origin !== source.origin) return;
if (!isSameProfileScope(source, resolved)) return;
const hrefPart = `${resolved.pathname}${resolved.search}`.toLowerCase();
const cueText = `${getElementCueText(link)} ${link.getAttribute("title") || ""} ${link.getAttribute("aria-label") || ""}`.toLowerCase();
const label = normalizeSpaces(link.textContent || "").toLowerCase();
const insidePagination = Boolean(link.closest(REVIEW_PAGINATION_SELECTOR));
const insideReviewSection = Boolean(link.closest(REVIEW_SECTION_SELECTOR));
const looksLikeReviewLink = REVIEW_PAGE_LINK_CUE_RE.test(hrefPart) || REVIEW_PAGE_LINK_CUE_RE.test(cueText);
const looksLikeReviewPagination =
(insideReviewSection || (hasReviewSection && insidePagination)) && REVIEW_PAGE_NAV_TEXT_RE.test(label);
if (!looksLikeReviewLink && !looksLikeReviewPagination) return;
const normalized = normalizeFetchUrl(resolved.href);
if (!normalized || normalized === sourceNormalized) return;
links.add(normalized);
});
return Array.from(links);
}
function clampValue(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function scoreReviewDocument(doc) {
const chunks = collectReviewTextChunks(doc);
if (!chunks.length) return 0;
let score = 0;
let bestChunk = Number.NEGATIVE_INFINITY;
for (let i = 0; i < chunks.length && i < 120; i += 1) {
const chunkScore = scoreReviewSignalText(chunks[i]);
score += clampValue(chunkScore, -10, 34);
bestChunk = Math.max(bestChunk, chunkScore);
}
if (bestChunk > 0) {
score += bestChunk * 0.4;
}
return clampValue(score, -30, 260);
}
async function fetchBadnessScoreForProfile(url) {
const normalizedRoot = normalizeFetchUrl(url);
if (!normalizedRoot) return 0;
if (profileBadnessScoreCache.has(normalizedRoot)) {
return profileBadnessScoreCache.get(normalizedRoot) || 0;
}
const pending = profileBadnessRequestCache.get(normalizedRoot);
if (pending) return pending;
const request = (async () => {
const queue = [normalizedRoot];
const seen = new Set();
let crawled = 0;
let totalScore = 0;
try {
while (queue.length && crawled < PROFILE_REVIEW_PAGE_LIMIT) {
const nextUrl = queue.shift();
if (!nextUrl || seen.has(nextUrl)) continue;
seen.add(nextUrl);
const html = await fetchPageHtml(nextUrl);
if (!html) continue;
crawled += 1;
const doc = new DOMParser().parseFromString(html, "text/html");
totalScore += scoreReviewDocument(doc);
const extraLinks = collectReviewPageLinks(doc, nextUrl);
for (const link of extraLinks) {
if (seen.has(link) || queue.includes(link)) continue;
queue.push(link);
}
}
} catch {
// no-op
} finally {
profileBadnessRequestCache.delete(normalizedRoot);
}
const finalScore = clampValue(totalScore, -40, 1600);
profileBadnessScoreCache.set(normalizedRoot, finalScore);
return finalScore;
})();
profileBadnessRequestCache.set(normalizedRoot, request);
return request;
}
async function warmProfileBadnessScores(profileUrls) {
const urls = Array.from(new Set(profileUrls.filter(Boolean)));
if (!urls.length) return;
let cursor = 0;
const workerCount = Math.min(PROFILE_FETCH_CONCURRENCY, urls.length);
const workers = [];
for (let i = 0; i < workerCount; i += 1) {
workers.push(
(async () => {
while (cursor < urls.length) {
const index = cursor;
cursor += 1;
const targetUrl = urls[index];
await fetchBadnessScoreForProfile(targetUrl);
}
})()
);
}
await Promise.all(workers);
}
function sortCardsByWorstReviewScore(entries) {
const ranked = entries.map((entry, index) => ({
card: entry.card,
index,
remoteScore: profileBadnessScoreCache.get(normalizeFetchUrl(entry.profileUrl)) || 0,
localScore: getReviewBadnessScore(entry.card),
}));
ranked.forEach((entry) => {
const score = entry.remoteScore + entry.localScore * 0.35;
entry.score = score;
entry.card.setAttribute(REVIEW_SCORE_ATTR, score.toFixed(2));
});
ranked.sort(
(a, b) => b.score - a.score || b.remoteScore - a.remoteScore || b.localScore - a.localScore || a.index - b.index
);
if (!ranked.length) return null;
const orderChanged = ranked.some((entry, index) => entry.card !== entries[index].card);
if (!orderChanged) return null;
return ranked.map((entry) => entry.card);
}
async function sortProfilesByWorstReviews(root) {
if (!worstSortEnabled) return;
if (worstSortInFlight) {
worstSortRerunRequested = true;
return;
}
worstSortInFlight = true;
worstSortRerunRequested = false;
const groups = collectProfileCardGroups(root);
try {
const allProfileUrls = groups.flatMap((group) => group.entries.map((entry) => entry.profileUrl));
await warmProfileBadnessScores(allProfileUrls);
for (const group of groups) {
const sortedCards = sortCardsByWorstReviewScore(group.entries);
if (!sortedCards) continue;
reorderCardsPreservingSlots(group.parent, sortedCards);
}
} finally {
worstSortInFlight = false;
if (worstSortRerunRequested) {
worstSortRerunRequested = false;
scheduleWorstReviewSort();
}
}
}
function scheduleWorstReviewSort() {
if (!worstSortEnabled) return;
if (worstSortScheduled) return;
worstSortScheduled = true;
setTimeout(() => {
worstSortScheduled = false;
void sortProfilesByWorstReviews(document);
}, 140);
}
function primeWorstReviewSort() {
if (!worstSortEnabled) return;
scheduleWorstReviewSort();
setTimeout(() => void sortProfilesByWorstReviews(document), 700);
setTimeout(() => void sortProfilesByWorstReviews(document), 1800);
}
function escapeForRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function compileForbiddenWordRegex(tokens) {
const uniq = Array.from(new Set(tokens.map((v) => (v || "").trim().toLowerCase()).filter(Boolean)));
const variants = [];
for (const token of uniq) {
const isStem = token.endsWith("*");
const base = isStem ? token.slice(0, -1) : token;
if (!base) continue;
const escaped = escapeForRegex(base);
const variant = isStem ? `${escaped}[\\p{L}\\p{N}_-]*` : escaped;
variants.push(variant);
}
if (!variants.length) return null;
return new RegExp(`(?<![\\p{L}\\p{N}_])(?:${variants.join("|")})(?![\\p{L}\\p{N}_])`, "giu");
}
function compileSanitizeHintRegex(tokens) {
const chunks = Array.from(
new Set(
tokens
.map((v) => (v || "").trim().toLowerCase().replace(/\*+$/g, ""))
.filter(Boolean)
.map(escapeForRegex)
)
);
if (!chunks.length) return /\d/;
return new RegExp(`\\d|${chunks.join("|")}`, "i");
}
function hasPhoneLikePattern(value) {
if (!value || !/\d/.test(value)) return false;
const hasPhoneCue = /(тел|телефон|phone|tel|номер|call|wa\.me|whatsapp|viber|telegram|tg)/i.test(value);
const matches = value.match(/(?:\+?\d[\d\s().-]{8,}\d)/g);
if (!matches) return false;
return matches.some((chunk) => {
const digits = chunk.replace(/\D/g, "");
if (digits.length < 10 || digits.length > 15) return false;
const hasDelimiter = /[\s().-]/.test(chunk) || chunk.startsWith("+");
return hasDelimiter || hasPhoneCue;
});
}
function maskPhoneText(value) {
return value.replace(/(?:\+?\d[\d\s().-]{8,}\d)/g, (chunk) => {
const digits = chunk.replace(/\D/g, "");
if (digits.length < 10 || digits.length > 15) return chunk;
return "номер скрыт";
});
}
function maskTokenWithHashes(token) {
const chars = Array.from(token);
const letterLikeIndexes = [];
for (let i = 0; i < chars.length; i += 1) {
if (/[\p{L}\p{N}]/u.test(chars[i])) {
letterLikeIndexes.push(i);
}
}
if (letterLikeIndexes.length <= 2) {
if (letterLikeIndexes.length === 0) return token;
if (letterLikeIndexes.length === 1) return "#";
chars[letterLikeIndexes[1]] = "#";
return chars.join("");
}
// Keep readability for "конч*" forms (e.g. "кончил" -> "к#нчил").
if (/^конч/iu.test(token) && letterLikeIndexes.length >= 2) {
const secondLetterIndex = letterLikeIndexes[1];
chars[secondLetterIndex] = "#";
return chars.join("");
}
// Keep readability for "член*" forms (e.g. "член" -> "чл#н").
if (/^член/iu.test(token) && letterLikeIndexes.length >= 3) {
const thirdLetterIndex = letterLikeIndexes[2];
chars[thirdLetterIndex] = "#";
return chars.join("");
}
// Soft mode: keep words understandable by masking only 1 char (2 for very long tokens).
const logicalMaskIndexes = [1];
if (letterLikeIndexes.length >= 10) {
logicalMaskIndexes.push(Math.floor(letterLikeIndexes.length / 2));
}
for (const logicalIndex of logicalMaskIndexes) {
const sourceIndex = letterLikeIndexes[logicalIndex];
if (sourceIndex === undefined) continue;
chars[sourceIndex] = "#";
}
return chars.join("");
}
function maskWordWithHashes(value) {
const tokenMatches = Array.from(value.matchAll(/[\p{L}\p{N}]+/gu));
if (!tokenMatches.length) return value;
if (tokenMatches.length === 1) {
return value.replace(/[\p{L}\p{N}]+/u, (token) => maskTokenWithHashes(token));
}
// For multi-word phrases, mask only the first word to keep text understandable.
const first = tokenMatches[0];
const start = first.index || 0;
const token = first[0];
const masked = maskTokenWithHashes(token);
return `${value.slice(0, start)}${masked}${value.slice(start + token.length)}`;
}
function maskRiskyWords(value) {
if (!RISKY_WORD_REGEX) return value;
RISKY_WORD_REGEX.lastIndex = 0;
return value.replace(RISKY_WORD_REGEX, (word) => maskWordWithHashes(word));
}
function sanitizeText(value) {
if (!value || !TEXT_SANITIZE_HINT_RE.test(value)) return value;
let next = maskPhoneText(value);
next = maskRiskyWords(next);
return next;
}
function isInsideImageKeepArea(el) {
if (!(el instanceof Element)) return false;
if (el.matches(IMAGE_KEEP_ROOT_SELECTOR)) return true;
return Boolean(el.closest(IMAGE_KEEP_ROOT_SELECTOR));
}
function isInsideContactKeepArea(el) {
if (!(el instanceof Element)) return false;
if (el.matches(CONTACT_KEEP_SELECTOR)) return true;
return Boolean(el.closest("#headerBg, .main_link1, #search"));
}
function isUserInfoElement(el) {
if (!(el instanceof Element)) return false;
if (el.matches(USERINFO_KEEP_SELECTOR)) return true;
return Boolean(el.closest(USERINFO_KEEP_SELECTOR));
}
function hideContactElement(el) {
if (!(el instanceof Element)) return;
if (isInsideContactKeepArea(el) || isUserInfoElement(el)) {
if (el.hasAttribute(HIDDEN_CONTACT_ATTR)) {
el.removeAttribute(HIDDEN_CONTACT_ATTR);
el.style.removeProperty("display");
el.style.removeProperty("visibility");
el.style.removeProperty("opacity");
}
return;
}
if (el.hasAttribute(HIDDEN_CONTACT_ATTR)) return;
el.setAttribute(HIDDEN_CONTACT_ATTR, "1");
el.style.setProperty("display", "none", "important");
el.style.setProperty("visibility", "hidden", "important");
el.style.setProperty("opacity", "0", "important");
if (el.tagName === "A") {
el.removeAttribute("href");
}
el.removeAttribute("onclick");
}
function shouldHideByAttributes(el) {
if (!(el instanceof Element)) return false;
if (isUserInfoElement(el)) return false;
const attrNames = el.getAttributeNames();
for (const attrName of attrNames) {
const value = (el.getAttribute(attrName) || "").trim();
const name = attrName.toLowerCase();
const allowPhonePattern =
name === "href" ||
name === "title" ||
name === "aria-label" ||
name === "value" ||
name.startsWith("data-");
if (CONTACT_ATTR_NAME_RE.test(name) && value) return true;
if (value && CONTACT_VALUE_RE.test(value)) return true;
if (value && allowPhonePattern && hasPhoneLikePattern(value)) return true;
}
return false;
}
function sanitizeTextNode(node) {
if (!(node instanceof Text)) return;
if (!node.nodeValue) return;
const parent = node.parentElement;
if (!parent) return;
const tag = parent.tagName;
if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT" || tag === "TEXTAREA") return;
const next = sanitizeText(node.nodeValue);
if (next !== node.nodeValue) {
node.nodeValue = next;
}
}
function scanSanitizedText(root) {
if (!(root instanceof Document || root instanceof Element)) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {
sanitizeTextNode(node);
node = walker.nextNode();
}
}
function sanitizeFormTextField(el) {
if (!(el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement)) return;
if (isInsideContactKeepArea(el)) return;
const source = el.value || (el instanceof HTMLTextAreaElement ? el.textContent || "" : "");
if (!source) return;
const next = sanitizeText(source);
if (next === source) return;
el.value = next;
if (el instanceof HTMLTextAreaElement) {
el.textContent = next;
return;
}
if (el.hasAttribute("value")) {
el.setAttribute("value", next);
}
}
function scanSanitizedFormFields(root) {
if (!(root instanceof Document || root instanceof Element)) return;
if (
(root instanceof HTMLTextAreaElement || root instanceof HTMLInputElement) &&
root.matches(SANITIZED_TEXT_FIELD_SELECTOR)
) {
sanitizeFormTextField(root);
}
root.querySelectorAll(SANITIZED_TEXT_FIELD_SELECTOR).forEach((el) => {
sanitizeFormTextField(el);
});
}
function forEachMatch(root, selector, cb) {
if (!(root instanceof Document || root instanceof Element)) return;
if (root instanceof Element && root.matches(selector)) cb(root);
root.querySelectorAll(selector).forEach(cb);
}
function scanContacts(root) {
if (!(root instanceof Document || root instanceof Element)) return;
forEachMatch(root, CONTACT_LINK_SELECTOR, hideContactElement);
forEachMatch(root, CONTACT_ATTR_SCAN_SELECTOR, (el) => {
if (shouldHideByAttributes(el)) hideContactElement(el);
});
scanSanitizedText(root);
scanSanitizedFormFields(root);
}
function neutralize(el) {
if (!(el instanceof Element)) return;
if (!isInsideContactKeepArea(el)) {
if (el.matches(CONTACT_LINK_SELECTOR) || shouldHideByAttributes(el)) {
hideContactElement(el);
}
}
if (!blockingEnabled) return;
if (isInsideImageKeepArea(el)) return;
if (el.matches("img")) {
el.removeAttribute("src");
el.removeAttribute("srcset");
el.removeAttribute("data-src");
el.removeAttribute("data-srcset");
el.removeAttribute("data-original");
el.removeAttribute("data-lazy");
el.removeAttribute("data-lazy-src");
return;
}
if (el.matches("source")) {
el.removeAttribute("src");
el.removeAttribute("srcset");
return;
}
if (el.matches("video[poster]")) {
el.removeAttribute("poster");
}
if (el.hasAttribute("style")) {
const style = el.getAttribute("style") || "";
if (/background-image\s*:\s*url\(/i.test(style)) {
el.setAttribute(
"style",
style.replace(/background-image\s*:[^;]+;?/gi, "background-image:none !important;")
);
}
}
}
function scanImages(root) {
if (!(root instanceof Document || root instanceof Element)) return;
const list = root.querySelectorAll(
"img, source, picture, svg image, video[poster], [style*='background-image']:not(#headerBg):not(#headerBg *):not(.main_link1):not(.main_link1 *):not(.up-ocenki-basic):not(.up-ocenki-basic *):not(.up-ocenki-dop):not(.up-ocenki-dop *)"
);
list.forEach(neutralize);
}
function scan(root) {
if (!(root instanceof Document || root instanceof Element)) return;
scanContacts(root);
repairSearchUi(root);
if (blockingEnabled) scanImages(root);
if (worstSortEnabled) scheduleWorstReviewSort();
scheduleVisitedMarking();
}
function triggerCompareRescan() {
setTimeout(() => scan(document), 0);
setTimeout(() => scan(document), 150);
setTimeout(() => scan(document), 450);
setTimeout(() => void sortProfilesByWorstReviews(document), 900);
}
function observeCompareTriggers() {
document.addEventListener(
"click",
(event) => {
if (!(event.target instanceof Element)) return;
if (!event.target.closest(COMPARE_TRIGGER_SELECTOR)) return;
triggerCompareRescan();
},
true
);
}
function markCurrentProfileRouteVisited() {
const route = parseProfileRoute(location.href);
if (!route) return;
void rememberVisitedProfile(route.normalizedUrl);
scheduleVisitedMarking();
}
function trackVisitedProfileLink(event) {
if (!(event.target instanceof Element)) return;
const link = event.target.closest("a[href]");
if (!(link instanceof HTMLAnchorElement)) return;
if (!isLikelyProfileLink(link)) return;
const route = parseProfileRoute(link.href || link.getAttribute("href") || "");
if (!route) return;
void rememberVisitedProfile(route.normalizedUrl);
const card = findProfileCardForLink(link);
if (card) setCardVisitedState(card, true);
scheduleVisitedMarking();
}
function observeVisitedProfiles() {
document.addEventListener("click", trackVisitedProfileLink, true);
document.addEventListener("auxclick", trackVisitedProfileLink, true);
}
function observe() {
const root = document.documentElement;
if (!root) {
document.addEventListener("DOMContentLoaded", observe, { once: true });
return;
}
const obs = new MutationObserver((mutations) => {
let hasReviewRelevantMutation = false;
for (const m of mutations) {
if (m.type === "characterData") {
sanitizeTextNode(m.target);
hasReviewRelevantMutation = true;
continue;
}
if (m.type === "attributes" && m.target instanceof Element) {
neutralize(m.target);
continue;
}
if (m.type === "childList") {
hasReviewRelevantMutation = true;
m.addedNodes.forEach((n) => {
if (n instanceof Element) {
neutralize(n);
scan(n);
return;
}
if (n instanceof Text) {
sanitizeTextNode(n);
}
});
}
}
if (hasReviewRelevantMutation) {
scheduleWorstReviewSort();
scheduleVisitedMarking();
}
});
obs.observe(root, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: OBSERVER_ATTR_FILTER,
});
}
injectToggleButton();
injectWorstSortButton();
injectContactCss();
injectSearchCss();
injectVisitedCss();
scanContacts(document);
repairSearchUi(document);
if (worstSortEnabled) primeWorstReviewSort();
observeCompareTriggers();
observeVisitedProfiles();
observe();
markCurrentProfileRouteVisited();
scheduleVisitedMarking();
if (blockingEnabled) {
injectBlockCss();
scanImages(document);
}
document.addEventListener("DOMContentLoaded", () => {
scan(document);
markCurrentProfileRouteVisited();
});
})();