Greasy Fork is available in English.
티스토리 에디터 HTML 정리
// ==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]));
})();