// ==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,
});
})();