// ==UserScript==
// @name ejchan deleted marker + fixed unread counter
// @namespace local.ejchan.deleted-marker
// @version 0.4.0
// @description Custom updater, deleted post marker, and fixed unread title counter for ejchan
// @match https://ejchan.net/*/res/*.html*
// @match https://ejchan.site/*/res/*.html*
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const CFG = {
minIntervalSec: 5,
maxIntervalSec: 60,
defaultIntervalSec: 10,
nativeUpdateWaitMs: 1800,
debug: false
};
const UI_COLOR = '#ff9100';
const log = (...args) => {
if (CFG.debug) console.log('[ejchan-helper]', ...args);
};
if (window.__ejDeletedMarkerLoaded) return;
window.__ejDeletedMarkerLoaded = true;
const threadEl = document.querySelector('.thread[id^="thread_"], .thread[data-threadid]');
if (!threadEl) return;
const board =
window.board_name ||
threadEl.dataset.board ||
location.pathname.split('/')[1];
const threadId =
window.thread_id ||
threadEl.dataset.threadid ||
(location.pathname.match(/\/res\/(\d+)\.html/) || [])[1];
if (!board || !threadId) {
console.warn('[ejchan-helper] Could not detect board/thread id.');
return;
}
const storagePrefix = `ejdm:${board}:${threadId}:`;
const intervalKey = storagePrefix + 'intervalSec';
const enabledKey = storagePrefix + 'enabled';
let intervalSec = clampInterval(Number(GM_getValue(intervalKey, CFG.defaultIntervalSec)));
let enabled = GM_getValue(enabledKey, true);
let timer = null;
let inFlight = false;
let knownPostNos = new Set();
let unreadCount = 0;
let windowFocused = document.hasFocus();
const baseTitle = stripNativeCounter(document.title);
let titleWriteLock = false;
injectStyle();
setTimeout(() => {
disableNativeAutoUpdate();
buildControls();
hideNativeUpdater();
knownPostNos = new Set(getLocalPosts().map(p => p.no));
setupFocusTracking();
setupTitleManager();
setupMutationObserver();
resetUnreadCounter();
checkOnce('initial');
if (enabled) startLoop();
}, 800);
function clampInterval(n) {
if (!Number.isFinite(n)) n = CFG.defaultIntervalSec;
n = Math.round(n);
return Math.max(CFG.minIntervalSec, Math.min(CFG.maxIntervalSec, n));
}
function injectStyle() {
const probeLink =
document.querySelector('#thread-links a') ||
document.querySelector('.post a') ||
document.querySelector('a');
const probeText =
document.querySelector('#thread-links') ||
document.querySelector('.post.reply') ||
document.body;
const probePost =
document.querySelector('.post.reply') ||
document.querySelector('.post.op') ||
document.body;
const linkStyle = probeLink ? getComputedStyle(probeLink) : null;
const textStyle = probeText ? getComputedStyle(probeText) : null;
const postStyle = probePost ? getComputedStyle(probePost) : null;
const uiColor =
linkStyle?.color ||
textStyle?.color ||
'currentColor';
const textColor =
textStyle?.color ||
'currentColor';
const postBg =
postStyle?.backgroundColor ||
'transparent';
const postBorder =
postStyle?.borderTopColor ||
uiColor;
const css = `
:root {
--ejdm-ui-color: ${uiColor};
--ejdm-text-color: ${textColor};
--ejdm-post-bg: ${postBg};
--ejdm-post-border: ${postBorder};
--ejdm-delete-bg: #b00020;
--ejdm-delete-fg: #ffffff;
}
#updater {
display: none !important;
}
#ejdm-controls {
margin-left: 8px;
white-space: nowrap;
color: var(--ejdm-ui-color) !important;
font: inherit;
}
#ejdm-controls,
#ejdm-controls label,
#ejdm-controls span,
#ejdm-controls input,
#ejdm-check-now {
color: var(--ejdm-ui-color) !important;
font: inherit;
}
#ejdm-controls input[type="number"] {
width: 3.5em;
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
color: var(--ejdm-ui-color) !important;
border: 1px solid var(--ejdm-ui-color);
border-radius: 3px;
padding: 1px 3px;
box-shadow: none !important;
outline: none;
appearance: textfield;
-moz-appearance: textfield;
}
#ejdm-controls input[type="number"]::-webkit-outer-spin-button,
#ejdm-controls input[type="number"]::-webkit-inner-spin-button {
opacity: 0.7;
}
#ejdm-controls input[type="number"]:focus {
outline: 1px solid var(--ejdm-ui-color);
outline-offset: 1px;
}
#ejdm-enabled {
accent-color: var(--ejdm-ui-color);
}
#ejdm-check-now {
cursor: pointer;
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
color: var(--ejdm-ui-color) !important;
border: 1px solid var(--ejdm-ui-color);
border-radius: 3px;
padding: 1px 5px;
box-shadow: none !important;
appearance: none;
-webkit-appearance: none;
}
#ejdm-check-now:hover {
filter: brightness(1.2);
}
#ejdm-check-now:active {
filter: brightness(0.85);
}
#ejdm-status {
margin-left: 4px;
opacity: 0.85;
color: var(--ejdm-ui-color) !important;
}
.ejdm-deleted-post {
opacity: 0.82;
outline: 1px dashed var(--ejdm-delete-bg);
outline-offset: 2px;
}
.ejdm-deleted-tag {
display: inline-block;
margin-right: 6px;
padding: 1px 5px;
border-radius: 3px;
background: var(--ejdm-delete-bg);
color: var(--ejdm-delete-fg);
font-weight: bold;
font-size: 12px;
line-height: 1.4;
}
`;
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
}
function buildControls() {
if (document.querySelector('#ejdm-controls')) return;
const threadLinks = document.querySelector('#thread-links');
const updater = document.querySelector('#updater');
if (!threadLinks && !updater) return;
const controls = document.createElement('span');
controls.id = 'ejdm-controls';
controls.innerHTML = `
<label title="Проверять новые и удалённые посты">
<input id="ejdm-enabled" type="checkbox">
Проверка
</label>
<input id="ejdm-interval" type="number" min="${CFG.minIntervalSec}" max="${CFG.maxIntervalSec}" step="1">
сек
<button id="ejdm-check-now" type="button">Проверить</button>
<span id="ejdm-status"></span>
`;
if (updater) {
updater.insertAdjacentElement('afterend', controls);
} else {
threadLinks.appendChild(controls);
}
const enabledBox = controls.querySelector('#ejdm-enabled');
const intervalInput = controls.querySelector('#ejdm-interval');
const checkBtn = controls.querySelector('#ejdm-check-now');
enabledBox.checked = enabled;
intervalInput.value = String(intervalSec);
enabledBox.addEventListener('change', () => {
enabled = enabledBox.checked;
GM_setValue(enabledKey, enabled);
if (enabled) {
disableNativeAutoUpdate();
hideNativeUpdater();
startLoop();
checkOnce('enabled');
} else {
stopLoop();
setStatus('выкл');
}
});
intervalInput.addEventListener('change', () => {
intervalSec = clampInterval(Number(intervalInput.value));
intervalInput.value = String(intervalSec);
GM_setValue(intervalKey, intervalSec);
if (enabled) startLoop();
});
checkBtn.addEventListener('click', () => {
checkOnce('manual');
});
setStatus(enabled ? `каждые ${intervalSec}с` : 'выкл');
}
function hideNativeUpdater() {
const updater = document.querySelector('#updater');
if (updater) updater.style.display = 'none';
}
function disableNativeAutoUpdate() {
const box = document.querySelector('#auto_update_status');
if (!box) return;
// Native auto-reload has a private closure timer.
// Clicking its checkbox is the safest way to call its stop_auto_update().
if (box.checked) {
log('disabling native auto-update');
box.click();
}
const secs = document.querySelector('#update_secs');
if (secs) secs.textContent = '';
}
function startLoop() {
stopLoop();
disableNativeAutoUpdate();
hideNativeUpdater();
timer = setInterval(() => {
checkOnce('timer');
}, intervalSec * 1000);
setStatus(`каждые ${intervalSec}с`);
}
function stopLoop() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
async function checkOnce(reason = 'unknown') {
if (inFlight) return;
inFlight = true;
try {
disableNativeAutoUpdate();
hideNativeUpdater();
setStatus('проверка...');
log('check:', reason);
const serverSet = await fetchServerPostSet();
const before = compareAndMark(serverSet);
const missingCount = before.missingLocally.length;
if (missingCount > 0) {
// Important:
// Do NOT insert posts ourselves.
// Native updater correctly initializes hover previews, backlinks, reply maps, etc.
triggerNativeUpdate();
setTimeout(async () => {
try {
const freshServerSet = await fetchServerPostSet();
compareAndMark(freshServerSet);
updateFinalStatus(missingCount);
updateManagedTitle();
hideNativeUpdater();
} catch (err) {
console.error('[ejchan-helper] post-native check failed:', err);
setStatus('ошибка');
}
}, CFG.nativeUpdateWaitMs);
} else {
updateFinalStatus(0);
updateManagedTitle();
}
} catch (err) {
console.error('[ejchan-helper] check failed:', err);
setStatus('ошибка');
} finally {
inFlight = false;
}
}
async function fetchServerPostSet() {
try {
return await fetchServerPostSetJson();
} catch (err) {
console.warn('[ejchan-helper] JSON check failed, falling back to HTML:', err);
return await fetchServerPostSetHtml();
}
}
async function fetchServerPostSetJson() {
const jsonUrl = location.pathname.replace(/\.html(?:$|\?.*)/, '.json') + '?_=' + Date.now();
const res = await fetch(jsonUrl, {
credentials: 'same-origin',
cache: 'no-store',
headers: {
Accept: 'application/json,*/*;q=0.8'
}
});
if (!res.ok) throw new Error(`JSON HTTP ${res.status}`);
const data = await res.json();
if (!data || !Array.isArray(data.posts)) {
throw new Error('Invalid JSON shape: no posts[]');
}
return new Set(data.posts.map(p => Number(p.no)).filter(Boolean));
}
async function fetchServerPostSetHtml() {
const htmlUrl = location.pathname + '?_=' + Date.now();
const res = await fetch(htmlUrl, {
credentials: 'same-origin',
cache: 'no-store',
headers: {
Accept: 'text/html,*/*;q=0.8'
}
});
if (!res.ok) throw new Error(`HTML HTTP ${res.status}`);
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const nums = [...doc.querySelectorAll(
'.thread .post.op[id^="op_"], .thread .post.reply[id^="reply_"]'
)]
.map(getPostNoFromEl)
.filter(Boolean);
return new Set(nums);
}
function triggerNativeUpdate() {
const btn = document.querySelector('#update_thread');
if (!btn) {
console.warn('[ejchan-helper] Native update button not found.');
return;
}
log('triggering native updater');
btn.click();
setTimeout(() => {
disableNativeAutoUpdate();
hideNativeUpdater();
}, 100);
}
function compareAndMark(serverSet) {
const localPosts = getLocalPosts();
const localSet = new Set(localPosts.map(p => p.no));
const deletedLocallyPresent = [];
const missingLocally = [];
for (const { no, el } of localPosts) {
if (!serverSet.has(no)) {
markDeleted(el, no);
deletedLocallyPresent.push(no);
} else {
unmarkDeleted(el);
}
}
for (const no of serverSet) {
if (!localSet.has(no)) {
missingLocally.push(no);
}
}
return {
deletedLocallyPresent,
missingLocally
};
}
function getLocalPosts() {
return [...document.querySelectorAll(
'.thread .post.op[id^="op_"], .thread .post.reply[id^="reply_"]'
)]
.map(el => {
const no = getPostNoFromEl(el);
return no ? { no, el } : null;
})
.filter(Boolean);
}
function getPostNoFromEl(el) {
const m = String(el?.id || '').match(/(?:op|reply)_(\d+)/);
return m ? Number(m[1]) : null;
}
function markDeleted(postEl, no) {
if (!postEl || postEl.classList.contains('ejdm-deleted-post')) return;
postEl.classList.add('ejdm-deleted-post');
postEl.dataset.ejdmDeleted = 'true';
const introLeft =
postEl.querySelector('.intro .post-left') ||
postEl.querySelector('.intro') ||
postEl;
const tag = document.createElement('span');
tag.className = 'ejdm-deleted-tag';
tag.textContent = '(Удалено)';
tag.title = `Пост ${no} отсутствует в свежем JSON/HTML с сервера`;
introLeft.insertAdjacentElement('afterbegin', tag);
}
function unmarkDeleted(postEl) {
if (!postEl || !postEl.classList.contains('ejdm-deleted-post')) return;
postEl.classList.remove('ejdm-deleted-post');
delete postEl.dataset.ejdmDeleted;
postEl.querySelectorAll('.ejdm-deleted-tag').forEach(el => el.remove());
}
function formatTime24(date = new Date()) {
return new Intl.DateTimeFormat('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}).format(date);
}
function updateFinalStatus(newCount = 0) {
const deletedTotal = document.querySelectorAll('.ejdm-deleted-post').length;
const time = formatTime24();
const parts = [time];
if (newCount > 0) {
parts.push(`новых ${newCount}`);
}
if (deletedTotal > 0) {
parts.push(`удалено ${deletedTotal}`);
} else if (newCount === 0) {
parts.push('ок');
}
setStatus(parts.join(', '));
}
function setStatus(text) {
const status = document.querySelector('#ejdm-status');
if (status) status.textContent = text ? `[${text}]` : '';
}
// ------------------------------------------------------------
// Correct unread title counter
// ------------------------------------------------------------
function stripNativeCounter(title) {
return String(title || '').replace(/^\(\d+\)\s+/, '');
}
function isTabActive() {
return !document.hidden && windowFocused && document.hasFocus();
}
function setupFocusTracking() {
window.addEventListener('focus', () => {
windowFocused = true;
if (isTabActive()) resetUnreadCounter();
});
window.addEventListener('blur', () => {
windowFocused = false;
});
document.addEventListener('visibilitychange', () => {
if (!document.hidden && document.hasFocus()) {
windowFocused = true;
resetUnreadCounter();
}
});
document.addEventListener('focusin', () => {
windowFocused = true;
if (isTabActive()) resetUnreadCounter();
});
}
function resetUnreadCounter() {
unreadCount = 0;
updateManagedTitle();
}
function getManagedTitle() {
return unreadCount > 0 ? `(${unreadCount}) ${baseTitle}` : baseTitle;
}
function updateManagedTitle() {
const wanted = getManagedTitle();
if (document.title === wanted) return;
titleWriteLock = true;
document.title = wanted;
setTimeout(() => {
titleWriteLock = false;
}, 0);
}
function setupTitleManager() {
updateManagedTitle();
const titleEl = document.querySelector('title');
if (!titleEl) return;
const obs = new MutationObserver(() => {
if (titleWriteLock) return;
const wanted = getManagedTitle();
if (document.title !== wanted) {
setTimeout(updateManagedTitle, 0);
}
});
obs.observe(titleEl, {
childList: true,
characterData: true,
subtree: true
});
}
function setupMutationObserver() {
const obs = new MutationObserver(mutations => {
const newlySeen = [];
for (const m of mutations) {
for (const node of m.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
const posts = [];
if (node.matches?.('.post.op[id^="op_"], .post.reply[id^="reply_"]')) {
posts.push(node);
}
posts.push(...node.querySelectorAll?.('.post.op[id^="op_"], .post.reply[id^="reply_"]') || []);
for (const post of posts) {
const no = getPostNoFromEl(post);
if (!no) continue;
if (!knownPostNos.has(no)) {
knownPostNos.add(no);
newlySeen.push(post);
}
}
}
}
if (newlySeen.length > 0 && !isTabActive()) {
unreadCount += newlySeen.length;
updateManagedTitle();
} else if (newlySeen.length > 0) {
updateManagedTitle();
}
});
obs.observe(threadEl, {
childList: true,
subtree: true
});
}
})();