Tampermonkey 확장을 이용한 별점 필터
여름엔냉면
정보
0
69
0
2025.12.07 12:59
// ==UserScript==
// @name Newtoki Top-N Rating Filter v2.3 (Filter Toggle + Draggable + Collapsible + NO-REQUEST)
// @namespace http://tampermonkey.net/
// @version 2.3
// @description 별점순 페이지 DOM 수집 → 북마크순 등 Top-N 필터, 필터 ON/OFF 토글, UI 이동/접기 포함, 403 완전 방지
// @include https://newtoki*.com/*
// @include https://newtoki*.org/*
// @include https://*.newtoki*.com/*
// @include https://*.newtoki*.org/*
// @run-at document-end
// @grant none
// ==/UserScript==
(function () {
"use strict";
// ==========================
// 설정 + 상태 저장 키
// ==========================
const STORAGE_KEY_PREFIX = "nt_topn_rankdb_v23_";
const STORAGE_KEY_TOPN = "nt_topn_filter_value_v23";
const STORAGE_KEY_TOGGLE = "nt_topn_filter_toggle_v23"; // ★ 필터 ON/OFF 저장
const DEFAULT_TOPN = 30;
let topN = loadTopN();
let filterEnabled = loadToggle(); // ★ ON(true)/OFF(false)
let uiRoot, uiStatus, uiInput, uiToggleBtn;
let currentRoot = null;
const log = (...a) => console.log("[NT-TopN v2.3]", ...a);
// ==========================
// 기본 저장 기능
// ==========================
function loadTopN() {
const v = parseInt(localStorage.getItem(STORAGE_KEY_TOPN), 10);
return Number.isFinite(v) && v > 0 ? v : DEFAULT_TOPN;
}
function saveTopN(n) {
localStorage.setItem(STORAGE_KEY_TOPN, String(n));
}
function loadToggle() {
const v = localStorage.getItem(STORAGE_KEY_TOGGLE);
if (v === "0") return false;
return true; // 기본 ON
}
function saveToggle(flag) {
localStorage.setItem(STORAGE_KEY_TOGGLE, flag ? "1" : "0");
}
function getToonId() {
try {
const u = new URL(location.href);
return decodeURIComponent(u.searchParams.get("toon") || "");
} catch { return ""; }
}
const dbKey = () => STORAGE_KEY_PREFIX + (getToonId() || "_default");
function loadRankDB() {
try {
const raw = localStorage.getItem(dbKey());
if (!raw) return { maxRank: 0, items: {} };
const o = JSON.parse(raw);
if (!o.items) o.items = {};
if (!o.maxRank) o.maxRank = 0;
return o;
} catch { return { maxRank: 0, items: {} }; }
}
function saveRankDB(db) {
localStorage.setItem(dbKey(), JSON.stringify(db));
}
function resetRankDB() {
localStorage.removeItem(dbKey());
}
function normalizeUrl(href) {
try {
const u = new URL(href);
u.searchParams.delete("sst");
u.searchParams.delete("sod");
u.searchParams.delete("page");
u.hash = "";
return u.pathname + (u.search ? "?" + u.searchParams.toString() : "");
} catch {
return href;
}
}
function extractLink(card) {
return card.querySelector(".img-wrap a[href]") ||
card.querySelector('a[href*="/webtoon/"]') ||
card.querySelector('a[href*="toon="]') ||
card.querySelector("a[href]");
}
const isListPage = () => location.pathname.startsWith("/webtoon") && location.search.includes("toon=");
const getSortType = () => new URL(location.href).searchParams.get("sst") || "";
// ==========================
// UI 생성 (드래그 + 접기 + 토글 추가)
// ==========================
function showUI() {
if (uiRoot) return;
// 박스
const box = document.createElement("div");
box.style.position = "fixed";
box.style.top = "20px";
box.style.left = "20px";
box.style.background = "rgba(0,0,0,0.85)";
box.style.color = "#fff";
box.style.borderRadius = "8px";
box.style.fontFamily = "system-ui";
box.style.zIndex = "999999999";
box.style.minWidth = "320px";
box.style.boxShadow = "0 0 10px rgba(0,0,0,0.4)";
box.style.userSelect = "none";
// 상단 바
const topbar = document.createElement("div");
topbar.style.padding = "8px 10px";
topbar.style.cursor = "move";
topbar.style.display = "flex";
topbar.style.justifyContent = "space-between";
topbar.style.alignItems = "center";
const title = document.createElement("div");
title.textContent = "NT Top-N Filter v2.3";
const btnToggleCollapse = document.createElement("div");
btnToggleCollapse.textContent = "−";
btnToggleCollapse.style.cursor = "pointer";
btnToggleCollapse.style.fontWeight = "bold";
btnToggleCollapse.style.fontSize = "16px";
topbar.appendChild(title);
topbar.appendChild(btnToggleCollapse);
// 내부 내용
const content = document.createElement("div");
content.style.padding = "10px";
uiStatus = document.createElement("div");
uiStatus.textContent = "상태: 초기화 중…";
uiStatus.style.marginBottom = "8px";
uiStatus.style.fontSize = "12px";
// 행: 상위 N 설정
const row = document.createElement("div");
row.style.display = "flex";
row.style.alignItems = "center";
row.style.gap = "8px";
row.style.marginBottom = "6px";
const label = document.createElement("span");
label.textContent = "상위 표시 개수:";
label.style.fontSize = "13px";
uiInput = document.createElement("input");
uiInput.type = "number";
uiInput.min = "1";
uiInput.value = String(topN);
uiInput.style.width = "70px";
uiInput.style.padding = "4px 6px";
uiInput.style.borderRadius = "4px";
uiInput.style.border = "1px solid #666";
uiInput.style.background = "#222";
uiInput.style.color = "#fff";
const btnApply = document.createElement("button");
btnApply.textContent = "적용";
btnApply.style.padding = "4px 10px";
btnApply.style.background = "#3C8AFF";
btnApply.style.border = "none";
btnApply.style.borderRadius = "4px";
btnApply.style.color = "#fff";
btnApply.style.cursor = "pointer";
btnApply.onclick = () => {
const v = parseInt(uiInput.value, 10);
topN = Number.isFinite(v) && v > 0 ? v : DEFAULT_TOPN;
saveTopN(topN);
applyFilter();
setStatus(`상위 ${topN}개 적용됨`);
};
row.appendChild(label);
row.appendChild(uiInput);
row.appendChild(btnApply);
// ★★ 필터 ON/OFF 버튼 ★★
const row2 = document.createElement("div");
row2.style.display = "flex";
row2.style.alignItems = "center";
row2.style.gap = "8px";
row2.style.marginBottom = "8px";
const toggleLabel = document.createElement("span");
toggleLabel.textContent = "필터 상태:";
uiToggleBtn = document.createElement("button");
uiToggleBtn.style.padding = "4px 10px";
uiToggleBtn.style.border = "none";
uiToggleBtn.style.borderRadius = "4px";
uiToggleBtn.style.cursor = "pointer";
uiToggleBtn.style.fontWeight = "600";
refreshToggleButtonUI();
uiToggleBtn.onclick = () => {
filterEnabled = !filterEnabled;
saveToggle(filterEnabled);
refreshToggleButtonUI();
applyFilter();
};
row2.appendChild(toggleLabel);
row2.appendChild(uiToggleBtn);
const hint = document.createElement("div");
hint.style.fontSize = "11px";
hint.style.opacity = "0.9";
hint.innerHTML =
"※ 403 방지 → 절대 요청 없음<br>" +
"1) 별점순에서 DOM 기반 랭킹 수집<br>" +
"2) 북마크순 등에서 Top-N 필터";
content.appendChild(uiStatus);
content.appendChild(row);
content.appendChild(row2);
content.appendChild(hint);
box.appendChild(topbar);
box.appendChild(content);
document.body.appendChild(box);
uiRoot = box;
// ==========================
// 드래그 기능
// ==========================
let dragging = false, offX, offY;
topbar.onmousedown = (e) => {
dragging = true;
offX = e.clientX - box.offsetLeft;
offY = e.clientY - box.offsetTop;
};
document.onmousemove = (e) => {
if (!dragging) return;
box.style.left = (e.clientX - offX) + "px";
box.style.top = (e.clientY - offY) + "px";
};
document.onmouseup = () => dragging = false;
// ==========================
// 접기/펼치기 기능
// ==========================
let collapsed = false;
btnToggleCollapse.onclick = () => {
collapsed = !collapsed;
content.style.display = collapsed ? "none" : "block";
btnToggleCollapse.textContent = collapsed ? "+" : "−";
};
}
function refreshToggleButtonUI() {
if (!uiToggleBtn) return;
if (filterEnabled) {
uiToggleBtn.textContent = "ON";
uiToggleBtn.style.background = "#00B050";
uiToggleBtn.style.color = "#fff";
} else {
uiToggleBtn.textContent = "OFF";
uiToggleBtn.style.background = "#777";
uiToggleBtn.style.color = "#fff";
}
}
function setStatus(msg) {
if (uiStatus) uiStatus.textContent = "상태: " + msg;
}
// ==========================
// DOM 대기
// ==========================
function waitForListRoot(cb) {
let r = document.querySelector("#webtoon-list-all");
if (r) return cb(r);
const obs = new MutationObserver(() => {
r = document.querySelector("#webtoon-list-all");
if (r) {
obs.disconnect();
cb(r);
}
});
obs.observe(document.body, { childList: true, subtree: true });
}
function waitForCards(root, cb) {
let cards = root.querySelectorAll("li");
if (cards.length) return cb(cards);
const obs = new MutationObserver(() => {
cards = root.querySelectorAll("li");
if (cards.length) {
obs.disconnect();
cb(cards);
}
});
obs.observe(root, { childList: true, subtree: true });
}
// ==========================
// 별점순 페이지 → 랭킹 수집
// ==========================
function collectRanks(root) {
const db = loadRankDB();
let maxRank = db.maxRank || 0;
const items = db.items || {};
let newCount = 0;
root.querySelectorAll("li").forEach(card => {
const link = extractLink(card);
if (!link) return;
const key = normalizeUrl(link.href);
if (!items[key]) {
maxRank++;
items[key] = maxRank;
newCount++;
}
});
db.maxRank = maxRank;
db.items = items;
saveRankDB(db);
setStatus(`별점순 → ${newCount}개 수집 (총 ${maxRank})`);
}
// ==========================
// 필터 적용
// ==========================
function addBadge(card, rank) {
if (!Number.isFinite(rank)) return;
const wrap = card.querySelector(".img-wrap") || card;
if (getComputedStyle(wrap).position === "static")
wrap.style.position = "relative";
let badge = wrap.querySelector(".nt-badge");
if (!badge) {
badge = document.createElement("div");
badge.className = "nt-badge";
badge.style.position = "absolute";
badge.style.top = "6px";
badge.style.left = "6px";
badge.style.padding = "2px 5px";
badge.style.background = "rgba(0,0,0,0.75)";
badge.style.color = "#00e0ff";
badge.style.fontSize = "11px";
badge.style.fontWeight = "bold";
badge.style.borderRadius = "3px";
wrap.appendChild(badge);
}
badge.textContent = `# ${rank}`;
}
function applyFilter() {
if (!currentRoot) return;
const sort = getSortType();
if (sort === "as_star") {
setStatus("별점순 페이지 (필터 미적용)");
return;
}
const cards = currentRoot.querySelectorAll("li");
const db = loadRankDB();
const items = db.items || {};
if (!filterEnabled) {
// ★ 필터 OFF → 모두 표시
cards.forEach(card => {
card.style.display = "";
const link = extractLink(card);
const key = link ? normalizeUrl(link.href) : null;
if (key && items[key]) addBadge(card, items[key]);
});
setStatus("필터 OFF → 전체 표시");
return;
}
// ★ 필터 ON
let shown = 0;
cards.forEach(card => {
const link = extractLink(card);
if (!link) {
card.style.display = "none";
return;
}
const key = normalizeUrl(link.href);
const rank = items[key];
if (rank && rank <= topN) {
card.style.display = "";
addBadge(card, rank);
shown++;
} else {
card.style.display = "none";
}
});
setStatus(`필터 ON → 상위 ${topN}개 (${shown}개 표시됨)`);
}
// ==========================
// URL 변화 감지
// ==========================
function setupUrlWatcher() {
let last = location.href;
const obs = new MutationObserver(() => {
if (location.href !== last) {
last = location.href;
setStatus("페이지 변경 감지 → 적용");
rerun();
}
});
obs.observe(document.body, { childList: true, subtree: true });
}
// ==========================
// 실행 루틴
// ==========================
function rerun() {
if (!isListPage()) {
setStatus("웹툰 목록 아님");
return;
}
waitForListRoot(root => {
currentRoot = root;
waitForCards(root, () => {
const sort = getSortType();
if (sort === "as_star") {
collectRanks(root);
} else {
applyFilter();
}
});
});
}
// ==========================
// 초기 실행
// ==========================
function init() {
showUI();
if (!isListPage()) {
setStatus("웹툰 목록 페이지 아님");
return;
}
setupUrlWatcher();
rerun();
}
init();
})();
1. Tampermonkey(Tampermonkey - Chrome 웹 스토어) 설치
2. 상기 스크립트 복붙 후 저장(Ctrl+S)
이런 UI가 생성됨
4. 웹툰 목록 페이지에서 '별점순'정렬 후 1페이지부터 순차적으로 수동으로 넘기며 스캔
정렬 방식을 바꾸면 내가 지정한 순위 밖 웹툰은 표시되지 않음.
6. 크롤링을 하지 않기 때문에 차단 우려 없음(4에서 굳이 수동으로 스캔하는 이유)
7. 그냥 자체 필터 기능이 있으면 좋겠다.


