Greasy Fork is available in English.

Tistory HTML Cleaner

티스토리 에디터 HTML 정리

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

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]));
})();