// ==UserScript== // @name 知乎作者长篇小说导入助手 // @namespace https://www.zhihu.com/ // @version 0.1.0 // @description 在知乎作者平台长篇写作页导入章节文件,自动填入小节标题和正文。 // @author th // @license MIT // @match https://www.zhihu.com/author-platform/writer* // @run-at document-idle // @grant none // ==/UserScript== (function () { 'use strict'; const BUTTON_ID = 'zhihu-novel-import-button'; const FILE_INPUT_ID = 'zhihu-novel-import-file-input'; const STYLE_ID = 'zhihu-novel-import-style'; const HEADER_SELECTOR = '.CreatorSalt-writer-long-header-rightCtrl'; const TITLE_SELECTOR = 'input[placeholder*="小节标题"], input[placeholder*="请输入小节标题"]'; const EDITOR_SELECTOR = '.public-DraftEditor-content[contenteditable="true"], [contenteditable="true"][role="textbox"]'; function injectStyle() { if (document.getElementById(STYLE_ID)) return; const style = document.createElement('style'); style.id = STYLE_ID; style.textContent = ` #${BUTTON_ID}.zhihu-novel-import-btn { color: #056de8 !important; border-color: #b8d8ff !important; background: #f3f8ff !important; box-shadow: none !important; display: inline-flex !important; align-items: center !important; gap: 4px !important; } #${BUTTON_ID}.zhihu-novel-import-btn:hover, #${BUTTON_ID}.zhihu-novel-import-btn:focus { color: #045dc6 !important; border-color: #7db8ff !important; background: #eaf4ff !important; } #${BUTTON_ID}.zhihu-novel-import-btn[disabled] { cursor: wait !important; opacity: 0.72 !important; } .zhihu-novel-import-toast { position: fixed; top: 72px; right: 24px; z-index: 999999; max-width: 360px; padding: 10px 14px; color: #056de8; font-size: 14px; line-height: 1.5; background: #f3f8ff; border: 1px solid #b8d8ff; border-radius: 6px; box-shadow: 0 6px 20px rgba(5, 109, 232, 0.12); } .zhihu-novel-import-toast.is-error { color: #b42318; background: #fff4f2; border-color: #ffccc7; } `; document.head.appendChild(style); } function ensureFileInput() { const existing = document.getElementById(FILE_INPUT_ID); if (existing) return existing; const input = document.createElement('input'); input.id = FILE_INPUT_ID; input.type = 'file'; input.accept = '.txt,.md,.markdown,.text'; input.style.display = 'none'; input.addEventListener('change', handleFileChange); document.body.appendChild(input); return input; } function createButton() { const button = document.createElement('button'); button.id = BUTTON_ID; button.type = 'button'; button.className = 'ant-btn ant-btn-default CreatorSalt-writer-long-header-rightCtrl-secondary-btn zhihu-novel-import-btn'; button.innerHTML = ` 导入文件 `; button.addEventListener('click', () => { const input = ensureFileInput(); input.value = ''; input.click(); }); return button; } function mountButton() { injectStyle(); const header = document.querySelector(HEADER_SELECTOR); if (!header || document.getElementById(BUTTON_ID)) return; ensureFileInput(); const button = createButton(); const divider = header.querySelector('.CreatorSalt-writer-long-header-rightCtrl-divider'); header.insertBefore(button, divider || header.firstChild); } async function handleFileChange(event) { const input = event.currentTarget; const file = input.files && input.files[0]; if (!file) return; const button = document.getElementById(BUTTON_ID); setButtonBusy(button, true); try { const rawText = await readFileText(file); const title = extractTitleFromFileName(file.name); const body = normalizeBody(rawText); const titleInput = await waitForNode(() => document.querySelector(TITLE_SELECTOR), 5000); const editor = await waitForNode(() => document.querySelector(EDITOR_SELECTOR), 5000); setNativeInputValue(titleInput, title); await replaceDraftEditorText(editor, body); showToast(`已导入:${title}`); } catch (error) { console.error('[知乎小说导入助手] 导入失败:', error); showToast(error instanceof Error ? error.message : '导入失败,请打开控制台查看详情', true); } finally { setButtonBusy(button, false); input.value = ''; } } function setButtonBusy(button, busy) { if (!button) return; button.disabled = busy; const text = button.querySelector('span'); if (text) text.textContent = busy ? '导入中...' : '导入文件'; } async function readFileText(file) { const buffer = await file.arrayBuffer(); const utf8Text = new TextDecoder('utf-8').decode(buffer); const replacementCount = (utf8Text.match(/\uFFFD/g) || []).length; if (replacementCount > 0) { try { return new TextDecoder('gb18030').decode(buffer); } catch (error) { console.warn('[知乎小说导入助手] GB18030 解码失败,回退到 UTF-8:', error); } } return utf8Text; } function extractTitleFromFileName(fileName) { const nameWithoutExt = fileName.replace(/\.[^.]+$/, '').trim(); const title = nameWithoutExt .replace(/^\s*\d+\s*[-_-—–..、\s]+\s*/, '') .replace(/^\s*第\s*[0-90-9零〇一二两三四五六七八九十百千万]+\s*[章节卷回部集篇]\s*[::\-—–_、..\s]*/, '') .replace(/^\s*\d+\s*[章节节回]\s*[::\-—–_、..\s]*/, '') .trim(); return title || nameWithoutExt; } function normalizeBody(text) { return text .replace(/^\uFEFF/, '') .replace(/\r\n?/g, '\n') .replace(/[ \t]+\n/g, '\n') .replace(/\n{2,}/g, '\n') .trim(); } function setNativeInputValue(input, value) { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; if (!setter) { input.value = value; } else { setter.call(input, value); } input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } async function replaceDraftEditorText(editor, text) { editor.focus(); await sleep(0); selectNodeContents(editor); dispatchPaste(editor, text); await sleep(120); if (isEditorTextCloseEnough(editor, text)) return; editor.focus(); selectNodeContents(editor); const inserted = document.execCommand('insertText', false, text); dispatchInput(editor, text); await sleep(120); if (!inserted && !isEditorTextCloseEnough(editor, text)) { throw new Error('正文编辑器填入失败,请确认当前页面已打开可编辑正文区域'); } } function dispatchPaste(editor, text) { try { const data = new DataTransfer(); data.setData('text/plain', text); const event = new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data, }); editor.dispatchEvent(event); } catch (error) { console.warn('[知乎小说导入助手] 模拟粘贴失败,准备使用 insertText 回退:', error); } } function dispatchInput(editor, text) { try { editor.dispatchEvent( new InputEvent('input', { bubbles: true, cancelable: true, data: text, inputType: 'insertText', }), ); } catch (_) { editor.dispatchEvent(new Event('input', { bubbles: true })); } } function selectNodeContents(node) { const selection = window.getSelection(); if (!selection) return; const range = document.createRange(); range.selectNodeContents(node); selection.removeAllRanges(); selection.addRange(range); } function isEditorTextCloseEnough(editor, expectedText) { const currentText = normalizeBody(editor.innerText || editor.textContent || ''); return currentText === expectedText || currentText.includes(expectedText.slice(0, 80)); } function waitForNode(getNode, timeout) { const existing = getNode(); if (existing) return Promise.resolve(existing); return new Promise((resolve, reject) => { const observer = new MutationObserver(() => { const node = getNode(); if (!node) return; window.clearTimeout(timer); observer.disconnect(); resolve(node); }); const timer = window.setTimeout(() => { observer.disconnect(); reject(new Error('没有找到标题输入框或正文编辑器,请确认页面已加载完成')); }, timeout); observer.observe(document.documentElement, { childList: true, subtree: true, }); }); } function showToast(message, isError = false) { const existing = document.querySelector('.zhihu-novel-import-toast'); if (existing) existing.remove(); const toast = document.createElement('div'); toast.className = `zhihu-novel-import-toast${isError ? ' is-error' : ''}`; toast.textContent = message; document.body.appendChild(toast); window.setTimeout(() => toast.remove(), isError ? 5000 : 2600); } function sleep(ms) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } mountButton(); new MutationObserver(mountButton).observe(document.documentElement, { childList: true, subtree: true, }); })();