Tistory HTML Cleaner

티스토리 에디터 HTML 정리

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

Advertisement:

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

Advertisement:

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Tistory HTML Cleaner
// @namespace    https://romantech.net/
// @version      1.2.18
// @description  티스토리 에디터 HTML 정리
// @homepageURL  https://github.com/romantech/tistory-html-cleaner
// @source       https://github.com/romantech/tistory-html-cleaner
// @match        https://*.tistory.com/manage/newpost*
// @match        https://*.tistory.com/manage/post*
// @sandbox      raw
// @grant        GM.registerMenuCommand
// @grant        GM_registerMenuCommand
// @grant        unsafeWindow
// @run-at       document-idle
// @noframes
// ==/UserScript==

(function() {
	"use strict";
	var LOG_PREFIX = "[TistoryCleaner]";
	var CLEAN_OPTION = Object.freeze({
		BLOCKQUOTE: "blockquote",
		SPAN: "span",
		DIV: "div",
		NBSP: "nbsp",
		HTTP_LINKS: "httpLinks",
		BACKTICK_CODE: "backtickCode",
		SMART_QUOTES: "smartQuotes",
		P_STYLE: "pStyle",
		H_STYLE: "hStyle",
		CODE_STYLE: "codeStyle",
		PRE_STYLE: "preStyle",
		A_ATTRIBUTES: "aAttributes"
	});
	var CLEAN_OPTION_LABEL = Object.freeze({
		[CLEAN_OPTION.BLOCKQUOTE]: "blockquote 태그 제거",
		[CLEAN_OPTION.SPAN]: "span 태그 제거",
		[CLEAN_OPTION.DIV]: "div 태그 제거",
		[CLEAN_OPTION.NBSP]: "특수 공백 치환",
		[CLEAN_OPTION.HTTP_LINKS]: "HTTP 링크를 HTTPS로 변경",
		[CLEAN_OPTION.BACKTICK_CODE]: "백틱 코드 변환",
		[CLEAN_OPTION.SMART_QUOTES]: "스마트 따옴표 치환",
		[CLEAN_OPTION.P_STYLE]: "문단 스타일 제거",
		[CLEAN_OPTION.H_STYLE]: "제목 스타일 제거",
		[CLEAN_OPTION.CODE_STYLE]: "code 스타일 제거",
		[CLEAN_OPTION.PRE_STYLE]: "pre 스타일 제거",
		[CLEAN_OPTION.A_ATTRIBUTES]: "링크 속성 정리"
	});
	var DEFAULT_CLEAN_OPTIONS = Object.freeze([
		CLEAN_OPTION.SPAN,
		CLEAN_OPTION.DIV,
		CLEAN_OPTION.NBSP,
		CLEAN_OPTION.HTTP_LINKS,
		CLEAN_OPTION.BACKTICK_CODE,
		CLEAN_OPTION.SMART_QUOTES,
		CLEAN_OPTION.P_STYLE,
		CLEAN_OPTION.H_STYLE,
		CLEAN_OPTION.CODE_STYLE,
		CLEAN_OPTION.PRE_STYLE,
		CLEAN_OPTION.A_ATTRIBUTES
	]);
	var EDITOR_MODE = Object.freeze({
		DEFAULT: "기본모드",
		MARKDOWN: "마크다운",
		HTML: "HTML"
	});
	var MODE_SELECTOR = Object.freeze({
		[EDITOR_MODE.DEFAULT]: "#editor-mode-kakao-tistory",
		[EDITOR_MODE.MARKDOWN]: "#editor-mode-markdown-tistory",
		[EDITOR_MODE.HTML]: "#editor-mode-html-tistory"
	});
	var WAIT_CONFIG = Object.freeze({
		MENU_OPEN: Object.freeze({
			timeout: 1500,
			interval: 50
		}),
		MENU_CLOSE: Object.freeze({
			timeout: 1e3,
			interval: 50
		}),
		MODE_SWITCH: Object.freeze({
			timeout: 3e3,
			interval: 150
		}),
		READY: Object.freeze({
			timeout: 15e3,
			interval: 200
		})
	});
	var SKIP_TEXT_SELECTOR = "code, pre, script, style";
	var all = (root, selector) => [...root.querySelectorAll(selector)];
	var toChange = (type, count) => count > 0 ? {
		type,
		count
	} : null;
	function createContainer(html) {
		return new DOMParser().parseFromString(`<body>${html}</body>`, "text/html").body;
	}
	function unwrap(element) {
		const fragment = document.createDocumentFragment();
		while (element.firstChild) fragment.append(element.firstChild);
		element.replaceWith(fragment);
	}
	function collectTextNodes(root, predicate) {
		const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
		const nodes = [];
		let current = walker.nextNode();
		while (current) {
			const node = current;
			const text = node.nodeValue ?? "";
			const isImageShortcode = text.includes("[##_Image|") && text.includes("_##]");
			if (!node.parentElement?.closest(SKIP_TEXT_SELECTOR) && !isImageShortcode && predicate(text, node)) nodes.push(node);
			current = walker.nextNode();
		}
		return nodes;
	}
	function fixHttpLinks(container) {
		let count = 0;
		for (const link of all(container, "a[href]")) {
			const href = link.getAttribute("href") ?? "";
			if (/^http:\/\//i.test(href)) {
				link.setAttribute("href", href.replace(/^http:\/\//i, "https://"));
				count += 1;
			}
		}
		for (const node of collectTextNodes(container, (text) => /http:\/\//i.test(text))) {
			const before = node.nodeValue ?? "";
			count += before.match(/http:\/\//gi)?.length ?? 0;
			node.nodeValue = before.replace(/http:\/\//gi, "https://");
		}
		return toChange(CLEAN_OPTION.HTTP_LINKS, count);
	}
	function normalizeSmartQuotes(container) {
		let count = 0;
		for (const node of collectTextNodes(container, (text) => /[‘’“”]/.test(text))) {
			const before = node.nodeValue ?? "";
			count += before.match(/[‘’“”]/g)?.length ?? 0;
			node.nodeValue = before.replace(/[‘’]/g, "'").replace(/[“”]/g, "\"");
		}
		return toChange(CLEAN_OPTION.SMART_QUOTES, count);
	}
	function replaceNbsp(container) {
		let count = 0;
		for (const node of collectTextNodes(container, (text) => text.includes("\xA0"))) {
			const before = node.nodeValue ?? "";
			count += before.match(/\u00A0/g)?.length ?? 0;
			node.nodeValue = before.replaceAll("\xA0", " ");
		}
		return toChange(CLEAN_OPTION.NBSP, count);
	}
	function sanitizeAnchorAttributes(container) {
		let changedCount = 0;
		for (const link of all(container, "a[href]")) {
			const vueScopeAttributes = link.getAttributeNames().filter((name) => name.startsWith("data-v-"));
			if (link.classList.contains("btn-toggle-moreless")) {
				if (!link.hasAttribute("style") && !vueScopeAttributes.length) continue;
				link.removeAttribute("style");
				for (const name of vueScopeAttributes) link.removeAttribute(name);
				changedCount += 1;
				continue;
			}
			const hadStyle = link.hasAttribute("style");
			const hadClass = link.hasAttribute("class");
			const href = link.getAttribute("href") ?? "";
			let isExternalHttpLink = false;
			try {
				const url = new URL(href, location.href);
				isExternalHttpLink = ["http:", "https:"].includes(url.protocol) && url.origin !== location.origin;
			} catch {}
			const rel = new Set((link.getAttribute("rel") ?? "").split(/\s+/).filter(Boolean));
			const shouldFixTarget = isExternalHttpLink && link.getAttribute("target") !== "_blank";
			const shouldFixRel = isExternalHttpLink && (!rel.has("noopener") || !rel.has("noreferrer"));
			if (!hadStyle && !hadClass && !vueScopeAttributes.length && !shouldFixTarget && !shouldFixRel) continue;
			link.removeAttribute("style");
			link.removeAttribute("class");
			for (const name of vueScopeAttributes) link.removeAttribute(name);
			if (isExternalHttpLink) {
				rel.add("noopener");
				rel.add("noreferrer");
				link.setAttribute("target", "_blank");
				link.setAttribute("rel", [...rel].join(" "));
			}
			changedCount += 1;
		}
		return toChange(CLEAN_OPTION.A_ATTRIBUTES, changedCount);
	}
	function stripStyle(container, selector, type) {
		const elements = all(container, selector);
		for (const element of elements) element.removeAttribute("style");
		return toChange(type, elements.length);
	}
	var stripParagraphStyles = (container) => stripStyle(container, "p[style]", CLEAN_OPTION.P_STYLE);
	var stripHeadingStyles = (container) => stripStyle(container, "h1[style], h2[style], h3[style], h4[style], h5[style], h6[style]", CLEAN_OPTION.H_STYLE);
	var stripCodeStyles = (container) => stripStyle(container, "code[style]", CLEAN_OPTION.CODE_STYLE);
	var stripPreStyles = (container) => stripStyle(container, "pre[style]", CLEAN_OPTION.PRE_STYLE);
	var PRESERVED_DIV_SELECTOR = [
		"div[data-ke-type=\"html\"]",
		"div[data-ke-type=\"moreLess\"]",
		"div.og-text",
		"div.og-image"
	].join(", ");
	function stripTags(container, selector, type) {
		const elements = all(container, selector);
		elements.forEach(unwrap);
		return toChange(type, elements.length);
	}
	var stripBlockquotes = (container) => stripTags(container, "blockquote", CLEAN_OPTION.BLOCKQUOTE);
	var stripSpans = (container) => stripTags(container, "span", CLEAN_OPTION.SPAN);
	function stripDivs(container) {
		const elements = all(container, "div").filter((element) => !element.closest(PRESERVED_DIV_SELECTOR));
		elements.forEach(unwrap);
		return toChange(CLEAN_OPTION.DIV, elements.length);
	}
	function wrapBacktickCode(container) {
		let count = 0;
		for (const node of collectTextNodes(container, (text) => text.includes("`"))) {
			const text = node.nodeValue ?? "";
			const matches = [...text.matchAll(/`([^`\n]+?)`/g)];
			if (!matches.length || !node.parentNode) continue;
			const fragment = document.createDocumentFragment();
			let lastIndex = 0;
			let replaced = false;
			for (const match of matches) {
				const [fullMatch, codeText] = match;
				const start = match.index ?? 0;
				const end = start + fullMatch.length;
				if ((text[start - 1] ?? "") === "`" || (text[end] ?? "") === "`") continue;
				if (start > lastIndex) fragment.append(text.slice(lastIndex, start));
				const code = document.createElement("code");
				code.textContent = codeText;
				fragment.append(code);
				lastIndex = end;
				count += 1;
				replaced = true;
			}
			if (!replaced) continue;
			if (lastIndex < text.length) fragment.append(text.slice(lastIndex));
			node.replaceWith(fragment);
		}
		return toChange(CLEAN_OPTION.BACKTICK_CODE, count);
	}
	var CLEANERS = Object.freeze({
		[CLEAN_OPTION.BLOCKQUOTE]: stripBlockquotes,
		[CLEAN_OPTION.SPAN]: stripSpans,
		[CLEAN_OPTION.DIV]: stripDivs,
		[CLEAN_OPTION.NBSP]: replaceNbsp,
		[CLEAN_OPTION.HTTP_LINKS]: fixHttpLinks,
		[CLEAN_OPTION.BACKTICK_CODE]: wrapBacktickCode,
		[CLEAN_OPTION.SMART_QUOTES]: normalizeSmartQuotes,
		[CLEAN_OPTION.P_STYLE]: stripParagraphStyles,
		[CLEAN_OPTION.H_STYLE]: stripHeadingStyles,
		[CLEAN_OPTION.CODE_STYLE]: stripCodeStyles,
		[CLEAN_OPTION.PRE_STYLE]: stripPreStyles,
		[CLEAN_OPTION.A_ATTRIBUTES]: sanitizeAnchorAttributes
	});
	var warn$1 = (...args) => console.warn(LOG_PREFIX, ...args);
	function runCleanPipeline(html, options = DEFAULT_CLEAN_OPTIONS) {
		const originalHtml = String(html ?? "");
		const container = createContainer(originalHtml);
		const changes = [];
		for (const option of options) {
			const cleaner = CLEANERS[option];
			if (!cleaner) {
				warn$1(`알 수 없는 정리 옵션: ${option}`);
				continue;
			}
			const change = cleaner(container);
			if (change) changes.push(change);
		}
		return {
			html: changes.length ? container.innerHTML : originalHtml,
			changes
		};
	}
	var log = (...args) => console.log(LOG_PREFIX, ...args);
	var warn = (...args) => console.warn(LOG_PREFIX, ...args);
	var wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
	async function waitFor(check, { timeout = 2500, interval = 50 } = {}) {
		const start = performance.now();
		while (performance.now() - start < timeout) {
			try {
				if (await check()) return true;
			} catch {}
			await wait(interval);
		}
		return false;
	}
	function findCodeMirror() {
		const element = document.querySelector(".CodeMirror");
		return element?.CodeMirror ?? element?.wrappedJSObject?.CodeMirror ?? null;
	}
	function getCodeMirror() {
		const cm = findCodeMirror();
		if (!cm) warn("CodeMirror 인스턴스를 찾지 못함");
		return cm ?? null;
	}
	function getModeButton() {
		return document.querySelector("#editor-mode-layer-btn-open");
	}
	function getModeMenuItems() {
		return Object.values(MODE_SELECTOR).map((selector) => document.querySelector(selector)).filter(Boolean);
	}
	function readCurrentMode() {
		return Object.entries(MODE_SELECTOR).find(([, selector]) => document.querySelector(`${selector}.mce-menu-active`))?.[0] ?? "";
	}
	async function openModeMenu() {
		if (getModeMenuItems().length > 0) return true;
		const button = getModeButton();
		if (!button) {
			warn("모드 메뉴 버튼을 찾지 못함");
			return false;
		}
		button.click();
		const opened = await waitFor(() => getModeMenuItems().length > 0, WAIT_CONFIG.MENU_OPEN);
		if (!opened) warn("모드 메뉴 열기 실패");
		return opened;
	}
	async function closeModeMenu() {
		document.body.click();
		await waitFor(() => getModeMenuItems().length === 0, WAIT_CONFIG.MENU_CLOSE);
	}
	async function withOpenedModeMenu(task) {
		if (!await openModeMenu()) return null;
		try {
			return await task();
		} finally {
			await closeModeMenu();
		}
	}
	async function getCurrentEditorMode() {
		return await withOpenedModeMenu(readCurrentMode) ?? "";
	}
	async function changeEditorMode(targetMode, { suppressConfirm = false } = {}) {
		const targetSelector = MODE_SELECTOR[targetMode];
		if (!targetSelector) {
			warn(`지원하지 않는 모드: ${targetMode}`);
			return {
				success: false,
				changed: false,
				currentMode: ""
			};
		}
		if (!await openModeMenu()) return {
			success: false,
			changed: false,
			currentMode: ""
		};
		try {
			const currentMode = readCurrentMode();
			log("현재 모드:", currentMode, "→ 목표 모드:", targetMode);
			if (currentMode === targetMode) return {
				success: true,
				changed: false,
				currentMode
			};
			const target = document.querySelector(targetSelector);
			if (!target) {
				warn(`${targetMode} 메뉴 항목을 찾지 못함: ${targetSelector}`);
				return {
					success: false,
					changed: false,
					currentMode
				};
			}
			const originalConfirm = unsafeWindow.confirm;
			let shouldSuppressConfirm = suppressConfirm;
			if (suppressConfirm) unsafeWindow.confirm = (...args) => {
				if (!shouldSuppressConfirm) return originalConfirm(...args);
				shouldSuppressConfirm = false;
				return true;
			};
			try {
				target.click();
			} finally {
				if (suppressConfirm) unsafeWindow.confirm = originalConfirm;
			}
			await closeModeMenu();
			const switched = await waitFor(async () => await getCurrentEditorMode() === targetMode, WAIT_CONFIG.MODE_SWITCH);
			const nextMode = switched ? targetMode : await getCurrentEditorMode();
			log(`${targetMode} 모드 전환 ${switched ? "완료" : "실패"}`);
			return {
				success: switched,
				changed: switched,
				currentMode: nextMode
			};
		} finally {
			await closeModeMenu();
		}
	}
	function cleanEditorHtml(options) {
		const cm = getCodeMirror();
		if (!cm) return {
			success: false,
			changed: false,
			changes: []
		};
		const before = cm.getValue();
		const result = runCleanPipeline(before, options);
		if (result.html === before) {
			log("변경 사항 없음");
			return {
				success: true,
				changed: false,
				changes: result.changes
			};
		}
		cm.operation(() => cm.replaceRange(result.html, cm.posFromIndex(0), cm.posFromIndex(before.length)));
		cm.save();
		cm.refresh();
		log("Tistory HTML 정리 완료");
		console.table(result.changes);
		return {
			success: true,
			changed: true,
			changes: result.changes
		};
	}
	async function waitUntilReady(options = {}) {
		const ready = await waitFor(() => !!getModeButton() && !!findCodeMirror(), {
			...WAIT_CONFIG.READY,
			...options
		});
		if (!ready) warn("에디터 준비 타임아웃");
		return ready;
	}
	async function enterHtmlModeAndClean(options = DEFAULT_CLEAN_OPTIONS, { restoreOriginalMode = true } = {}) {
		const originalMode = await getCurrentEditorMode();
		const result = {
			success: false,
			changed: false,
			changes: [],
			originalMode,
			currentMode: originalMode
		};
		if (!MODE_SELECTOR[originalMode]) {
			warn(`현재 에디터 모드를 확인하지 못함: ${originalMode || "(빈 값)"}`);
			return result;
		}
		if (!(await changeEditorMode(EDITOR_MODE.HTML, { suppressConfirm: true })).success) {
			result.currentMode = await getCurrentEditorMode();
			return result;
		}
		result.currentMode = EDITOR_MODE.HTML;
		try {
			Object.assign(result, cleanEditorHtml(options));
		} catch (error) {
			warn("HTML 정리 중 오류:", error);
		} finally {
			if (restoreOriginalMode && originalMode !== EDITOR_MODE.HTML) {
				const restored = await changeEditorMode(originalMode, { suppressConfirm: true });
				result.currentMode = restored.currentMode;
				if (!restored.success) result.success = false;
			} else result.currentMode = await getCurrentEditorMode();
		}
		return result;
	}
	var registerMenuCommand = typeof GM_registerMenuCommand === "function" ? GM_registerMenuCommand : GM.registerMenuCommand;
	async function runCleanCommand(options = DEFAULT_CLEAN_OPTIONS) {
		if (!await waitUntilReady()) {
			alert("티스토리 에디터가 준비되지 않았습니다.");
			return;
		}
		const result = await enterHtmlModeAndClean(options);
		const summary = result.changes.map(({ type, count }) => `- ${CLEAN_OPTION_LABEL[type] ?? type}: ${count}개`).join("\n");
		alert(result.success ? result.changed ? `HTML 정리를 완료했습니다.\n\n${summary}` : "정리할 내용이 없습니다." : "HTML 정리에 실패했습니다. 콘솔 로그를 확인하세요.");
	}
	registerMenuCommand("전체 정리", () => runCleanCommand());
	for (const option of Object.values(CLEAN_OPTION)) registerMenuCommand(CLEAN_OPTION_LABEL[option], () => runCleanCommand([option]));
})();