first commit
This commit is contained in:
41 files changed
+4964
No files matched your search
@@ -0,0 +1,850 @@
|
||||
// ==UserScript==
|
||||
// @name 新闻工作台 · 导入排版与流程
|
||||
// @namespace local.dww11.auto-news
|
||||
// @version 0.1.0
|
||||
// @match https://www.dww11.com/*
|
||||
// @match https://dww11.com/*
|
||||
// @match https://house.dww11.com/*
|
||||
// @grant none
|
||||
// @run-at document-idle
|
||||
// @noframes
|
||||
// ==/UserScript==
|
||||
|
||||
|
||||
(function (root, factory) {
|
||||
'use strict';
|
||||
const core = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = core;
|
||||
else core.mount(root);
|
||||
})(typeof window === 'undefined' ? null : window, function () {
|
||||
'use strict';
|
||||
const VERSION = '1.0.0';
|
||||
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
|
||||
const IMAGE_EXT = /\.(?:jpe?g|png|gif)$/i;
|
||||
|
||||
function normalizePath(path) {
|
||||
const parts = [];
|
||||
const value = String(path).replace(/\\/g, '/').normalize('NFC');
|
||||
if (/^(?:\/|[a-z]:|[a-z]+:)/i.test(value)) throw new Error('请使用目录内的相对路径:' + path);
|
||||
for (const part of value.split('/')) {
|
||||
if (!part || part === '.') continue;
|
||||
if (part === '..') {
|
||||
if (!parts.length) throw new Error('图片路径超出所选目录:' + path);
|
||||
parts.pop();
|
||||
} else parts.push(part);
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function imagePath(mdPath, target) {
|
||||
if (/^(?:[a-z][\w+.-]*:|\/\/|\/)/i.test(target)) throw new Error('图片必须在所选目录内,请先下载:' + target);
|
||||
let decoded;
|
||||
try { decoded = decodeURIComponent(target); } catch { throw new Error('图片路径编码无效:' + target); }
|
||||
return normalizePath(mdPath.slice(0, mdPath.lastIndexOf('/') + 1) + decoded);
|
||||
}
|
||||
|
||||
// A small, intentionally bounded Markdown reader. Raw HTML is never executed.
|
||||
// Links/images support balanced parentheses, angle destinations and optional titles.
|
||||
function readLink(text, start) {
|
||||
const image = text.startsWith('![', start);
|
||||
if (!image && text[start] !== '[') return null;
|
||||
const left = start + (image ? 2 : 1);
|
||||
let end = left;
|
||||
for (; end < text.length; end++) {
|
||||
if (text[end] === '\\') { end++; continue; }
|
||||
if (text[end] === ']') break;
|
||||
}
|
||||
if (text.slice(end, end + 2) !== '](') return null;
|
||||
let pos = end + 2, depth = 1, quoted = '', angle = false;
|
||||
for (; pos < text.length; pos++) {
|
||||
const ch = text[pos];
|
||||
if (ch === '\\') { pos++; continue; }
|
||||
if (quoted) { if (ch === quoted) quoted = ''; continue; }
|
||||
if (!angle && depth === 1 && /\s/.test(text[pos - 1]) && (ch === '"' || ch === "'")) { quoted = ch; continue; }
|
||||
if (ch === '<') { angle = true; continue; }
|
||||
if (ch === '>') { angle = false; continue; }
|
||||
if (!angle && ch === '(') depth++;
|
||||
if (!angle && ch === ')' && --depth === 0) break;
|
||||
}
|
||||
if (depth) return null;
|
||||
let target = text.slice(end + 2, pos).trim();
|
||||
const destination = target.match(/^<([^>]+)>\s*(?:["'][\s\S]*["'])?$/);
|
||||
target = destination ? destination[1] : target.replace(/\s+["'][\s\S]*["']\s*$/, '');
|
||||
target = target.replace(/\\([()\\ ])/g, '$1');
|
||||
return { image, label: text.slice(left, end), target, end: pos + 1 };
|
||||
}
|
||||
|
||||
function inline(text, onImage) {
|
||||
let out = '';
|
||||
for (let pos = 0; pos < text.length;) {
|
||||
if (text[pos] === '\\' && pos + 1 < text.length) { out += text[pos + 1]; pos += 2; continue; }
|
||||
const link = readLink(text, pos);
|
||||
if (link) {
|
||||
if (link.image) out += onImage(link.target, link.label);
|
||||
else if (/^(https?:\/\/|mailto:)/i.test(link.target) && !/[\[\]\r\n"']/.test(link.target)) {
|
||||
out += '[url=' + link.target + ']' + inline(link.label, onImage) + '[/url]';
|
||||
} else out += link.label + ' (' + link.target + ')';
|
||||
pos = link.end;
|
||||
continue;
|
||||
}
|
||||
if (text[pos] === '`') {
|
||||
const end = text.indexOf('`', pos + 1);
|
||||
if (end > pos + 1) { out += text.slice(pos + 1, end); pos = end + 1; continue; }
|
||||
}
|
||||
let matched = false;
|
||||
for (const [mark, tag] of [['**', 'b'], ['__', 'b'], ['~~', 's'], ['*', 'i'], ['_', 'i']]) {
|
||||
if (!text.startsWith(mark, pos) || (mark === '_' && /\w/.test(text[pos - 1] || ''))) continue;
|
||||
const end = text.indexOf(mark, pos + mark.length);
|
||||
if (end <= pos + mark.length) continue;
|
||||
out += '[' + tag + ']' + inline(text.slice(pos + mark.length, end), onImage) + '[/' + tag + ']';
|
||||
pos = end + mark.length; matched = true; break;
|
||||
}
|
||||
if (!matched) out += text[pos++];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function markdownToBBCode(markdown, onImage) {
|
||||
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
|
||||
const output = [];
|
||||
let list = '', quote = false, code = false, fence = '';
|
||||
const closeList = () => { if (list) output.push('[/list]'); list = ''; };
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index];
|
||||
const codeMark = line.match(/^\s*(`{3,}|~{3,})/);
|
||||
if (codeMark && (!code || codeMark[1][0] === fence)) {
|
||||
closeList();
|
||||
output.push(code ? '[/code]' : '[code]');
|
||||
code = !code; fence = codeMark[1][0]; continue;
|
||||
}
|
||||
if (code) { output.push(line.replace(/\[\/code\]/gi, '[/code]')); continue; }
|
||||
const quoted = /^\s*>\s?/.test(line);
|
||||
if (quoted !== quote) { closeList(); output.push(quoted ? '[quote]' : '[/quote]'); quote = quoted; }
|
||||
const content = quoted ? line.replace(/^\s*>\s?/, '') : line;
|
||||
const item = content.match(/^\s*(?:([-+*])|\d+[.)])\s+(.+)$/);
|
||||
if (item) {
|
||||
const type = item[1] ? '[list]' : '[list=1]';
|
||||
if (list !== type) { closeList(); output.push(type); list = type; }
|
||||
output.push('[*]' + inline(item[2], onImage)); continue;
|
||||
}
|
||||
closeList();
|
||||
const heading = content.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*$/);
|
||||
if (heading) output.push('[b]' + inline(heading[2], onImage) + '[/b]');
|
||||
else if (/^\s*(?:---+|\*\*\*+|___+)\s*$/.test(content)) output.push('[hr]');
|
||||
else output.push(inline(content, onImage));
|
||||
}
|
||||
closeList();
|
||||
if (quote) output.push('[/quote]');
|
||||
if (code) output.push('[/code]');
|
||||
return output.join('\n').trim();
|
||||
}
|
||||
|
||||
function compile(markdown, mdPath, fileMap, titleOverride) {
|
||||
let body = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
|
||||
const firstHeading = body.match(/^\s*#\s+([^\n]+)\n?/);
|
||||
const title = String(titleOverride || firstHeading?.[1] || mdPath.split('/').pop().replace(/\.md$/i, '')).trim();
|
||||
if (firstHeading) body = body.slice(firstHeading[0].length).trim();
|
||||
if (!title || Array.from(title).length > 80) throw new Error('标题为空或超过 80 字,请修改 Markdown 标题后重试。');
|
||||
if (!body.trim()) throw new Error('正文为空。');
|
||||
const warnings = [];
|
||||
if (/<\/?[a-z][^>]*>/i.test(body)) warnings.push('存在 HTML,将作为文字保留;请检查排版。');
|
||||
if (/^\s*\|.*\|\s*$/m.test(body)) warnings.push('表格暂按原文保留,需手动检查。');
|
||||
if (/!\[[^\]]*\]\s*\[[^\]]*\]/.test(body) || /^\s*\[[^\]]+\]:/m.test(body)) {
|
||||
throw new Error('暂不支持引用式 Markdown 图片/链接,请改成  或 [文字](网址)。');
|
||||
}
|
||||
const images = new Map();
|
||||
// Validate every image before any network request; repeated references upload once.
|
||||
markdownToBBCode(body, (target) => {
|
||||
const path = imagePath(mdPath, target);
|
||||
const file = fileMap.get(path);
|
||||
if (!file) throw new Error('找不到配图:' + path);
|
||||
if (!IMAGE_EXT.test(path)) throw new Error('网站仅支持 JPG、JPEG、PNG、GIF:' + path);
|
||||
if (!file.size || file.size >= MAX_IMAGE_SIZE) throw new Error('配图为空或达到 20 MB:' + path);
|
||||
images.set(path, file);
|
||||
return '[图片:' + path + ']';
|
||||
});
|
||||
return { title, body, mdPath, images, warnings,
|
||||
render(aids) {
|
||||
return markdownToBBCode(body, target => {
|
||||
const path = imagePath(mdPath, target);
|
||||
const aid = aids.get(path);
|
||||
if (!/^[1-9]\d*$/.test(String(aid))) throw new Error('配图尚未上传:' + path);
|
||||
return '[attachimg]' + aid + '[/attachimg]';
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareFiles(files, markdownPath) {
|
||||
const fileMap = new Map();
|
||||
for (const file of Array.from(files)) {
|
||||
const path = normalizePath(file.webkitRelativePath || file.name);
|
||||
if (fileMap.has(path)) throw new Error('文件路径重复:' + path);
|
||||
fileMap.set(path, file);
|
||||
}
|
||||
const candidates = Array.from(fileMap.keys()).filter(p => /\.md$/i.test(p)).sort();
|
||||
if (!candidates.length) throw new Error('所选目录中没有 .md 文件。');
|
||||
if (!markdownPath && candidates.length > 1) return { candidates, files: Array.from(files) };
|
||||
const mdPath = markdownPath || candidates[0];
|
||||
const md = fileMap.get(mdPath);
|
||||
if (!md) throw new Error('指定的 Markdown 文件不存在。');
|
||||
return compile(await md.text(), mdPath, fileMap);
|
||||
}
|
||||
|
||||
async function preparePackage(data) {
|
||||
if (!data || data.schemaVersion !== 1 || typeof data.markdown !== 'string' || !Array.isArray(data.images)) {
|
||||
throw new Error('文章包格式错误,需要 schemaVersion: 1、markdown 和 images。');
|
||||
}
|
||||
const map = new Map();
|
||||
for (const item of data.images) {
|
||||
const path = normalizePath(item.path);
|
||||
if (map.has(path)) throw new Error('文章包图片路径重复:' + path);
|
||||
let blob = item.blob;
|
||||
if (!blob && typeof item.base64 === 'string') {
|
||||
const binary = atob(item.base64);
|
||||
blob = new Blob([Uint8Array.from(binary, ch => ch.charCodeAt(0))], { type: item.mime || 'application/octet-stream' });
|
||||
}
|
||||
if (!blob || typeof blob.arrayBuffer !== 'function') throw new Error('文章包缺少图片数据:' + path);
|
||||
map.set(path, blob);
|
||||
}
|
||||
return compile(data.markdown, normalizePath(data.markdownPath || 'article.md'), map, data.title);
|
||||
}
|
||||
|
||||
function parseAid(text) {
|
||||
const value = String(text).trim();
|
||||
// The site's actual WebUploader adapter uses a positive integer response.
|
||||
if (!/^(?:[1-9]\d*|"[1-9]\d*")$/.test(value)) throw new Error('服务器未返回有效附件编号(可能登录过期、超出权限或上传失败)。');
|
||||
return value.replace(/"/g, '');
|
||||
}
|
||||
|
||||
function mount(win) {
|
||||
const doc = win.document;
|
||||
const params = new URLSearchParams(win.location.search);
|
||||
if (params.get('mod') !== 'post' || params.get('action') !== 'newthread' || !doc.querySelector('#subject') || !doc.querySelector('#e_textarea')) return;
|
||||
if (win.DWW11Importer) return;
|
||||
let article = null, busy = false, candidates = null, lastResult = null, savedBackup = null, stageRevision = 0;
|
||||
const cache = new Map(), urls = [], logs = [];
|
||||
const host = doc.createElement('div');
|
||||
host.id = 'dww11-importer';
|
||||
host.style.cssText = 'position:fixed;right:18px;bottom:18px;z-index:2147483000';
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
shadow.innerHTML = `<style>
|
||||
:host{all:initial}*{box-sizing:border-box}details{font:14px/1.6 system-ui,sans-serif;color:#203044;background:#fff;border:1px solid #d4dee8;border-radius:12px;box-shadow:0 6px 28px #0003;width:360px;max-width:92vw}summary{cursor:pointer;padding:12px 16px;font-weight:650}section{padding:0 16px 16px;max-height:70vh;overflow:auto}button,select{font:inherit;padding:7px 10px;border-radius:6px;border:1px solid #b8c9da;background:#f4f8fc;color:#203044;cursor:pointer;margin:4px 4px 4px 0}button.primary{background:#1767b3;color:white;border-color:#1767b3}button:disabled{opacity:.5;cursor:default}p{margin:8px 0}small{color:#617183}pre{font:12px/1.6 system-ui;white-space:pre-wrap;overflow-wrap:anywhere;max-height:170px;overflow:auto;background:#f4f6f9;padding:8px}img{max-width:90px;max-height:80px;margin:4px;object-fit:contain}.error{color:#b32424}.success{color:#18713e}select{max-width:100%}[hidden]{display:none!important}
|
||||
</style><details open><summary>本地文章导入 · 1.0</summary><section>
|
||||
<small>选择整个文章文件夹,检查后上传配图并填写。发布由你操作。网站自带的自动保存仍可能运行。</small>
|
||||
<p><button id="choose" type="button">选择文章目录</button><button id="package" type="button">导入文章包</button></p>
|
||||
<input id="folder" type="file" webkitdirectory multiple hidden><input id="json" type="file" accept=".json,application/json" hidden>
|
||||
<select id="md" hidden aria-label="选择 Markdown"></select>
|
||||
<p id="status" role="status" aria-live="polite">尚未选择目录</p><div id="preview" hidden><strong id="title"></strong><pre id="body"></pre><div id="images"></div><p id="warnings"></p></div>
|
||||
<button id="import" class="primary" type="button" disabled>上传配图并填写</button>
|
||||
<button id="diagnostics" type="button">导出诊断</button><pre id="log" hidden></pre>
|
||||
</section></details>`;
|
||||
doc.body.appendChild(host);
|
||||
const $ = id => shadow.getElementById(id);
|
||||
const summary = () => ({ version: VERSION, busy, ready: !!article, title: article?.title || null,
|
||||
markdownPath: article?.mdPath || null, imageCount: article?.images.size || 0,
|
||||
warnings: article?.warnings || [], result: lastResult });
|
||||
function notify(phase, message, extra = {}) {
|
||||
$('status').textContent = message;
|
||||
$('status').className = phase === 'error' ? 'error' : phase === 'complete' ? 'success' : '';
|
||||
logs.push({ time: new Date().toISOString(), phase, message, ...extra });
|
||||
$('log').hidden = false;
|
||||
$('log').textContent = logs.slice(-8).map(l => l.message).join('\n');
|
||||
win.dispatchEvent(new CustomEvent('dww11-importer:progress', { detail: { phase, message, ...extra } }));
|
||||
}
|
||||
function releasePreviews() { while (urls.length) URL.revokeObjectURL(urls.pop()); }
|
||||
function clearStage() {
|
||||
article = null; lastResult = null; candidates = null; releasePreviews();
|
||||
$('preview').hidden = true; $('md').hidden = true; $('import').disabled = true;
|
||||
}
|
||||
function showArticle(prepared) {
|
||||
article = prepared; lastResult = null; releasePreviews();
|
||||
$('preview').hidden = false;
|
||||
$('title').textContent = article.title;
|
||||
$('body').textContent = article.body;
|
||||
$('warnings').textContent = article.warnings.join(';');
|
||||
$('images').replaceChildren();
|
||||
for (const [path, blob] of article.images) {
|
||||
const img = doc.createElement('img');
|
||||
img.src = URL.createObjectURL(blob); urls.push(img.src); img.alt = path; img.title = path;
|
||||
$('images').appendChild(img);
|
||||
}
|
||||
$('import').disabled = false;
|
||||
notify('ready', `已检查:${article.images.size} 张图片。点击“上传配图并填写”开始导入。`);
|
||||
return summary();
|
||||
}
|
||||
async function stageFiles(files, options = {}) {
|
||||
if (busy) throw new Error('正在导入,请稍候。');
|
||||
clearStage();
|
||||
const revision = ++stageRevision;
|
||||
const list = Array.from(files);
|
||||
const prepared = await prepareFiles(list, options.markdownPath);
|
||||
if (revision !== stageRevision) return { cancelled: true };
|
||||
if (prepared.candidates) {
|
||||
candidates = prepared.files;
|
||||
$('md').replaceChildren(new Option('请选择目录中的 Markdown 文件', ''));
|
||||
for (const path of prepared.candidates) $('md').appendChild(new Option(path, path));
|
||||
$('md').hidden = false;
|
||||
notify('choose-markdown', '目录中有多个 Markdown 文件,请选择一篇。');
|
||||
return { candidates: prepared.candidates };
|
||||
}
|
||||
return showArticle(prepared);
|
||||
}
|
||||
async function stagePackage(data) {
|
||||
if (busy) throw new Error('正在导入,请稍候。');
|
||||
clearStage();
|
||||
const revision = ++stageRevision;
|
||||
const prepared = await preparePackage(data);
|
||||
if (revision !== stageRevision) return { cancelled: true };
|
||||
return showArticle(prepared);
|
||||
}
|
||||
const snapshot = () => ({ title: doc.querySelector('#subject').value,
|
||||
mode: Number(win.wysiwyg), text: win.getEditorContents() });
|
||||
function assertEditor() {
|
||||
for (const name of ['getEditorContents', 'switchEditor']) {
|
||||
if (typeof win[name] !== 'function') throw new Error('未识别到网站编辑器:' + name + '。请刷新页面后重试。');
|
||||
}
|
||||
if (!win.imgUpload?.settings || typeof win.updateImageList !== 'function') throw new Error('网站图片上传组件尚未就绪,请刷新后重试。');
|
||||
if (Number(win.allowbbcode) === 0 || Number(win.allowimgcode) === 0) throw new Error('当前版块或账号不支持正文格式/图片。');
|
||||
}
|
||||
async function uploadImage(blob, path, settings, signal) {
|
||||
signal?.throwIfAborted();
|
||||
const url = new URL(settings.upload_url, win.location.href);
|
||||
if (url.origin !== win.location.origin || url.pathname !== '/misc.php' || url.searchParams.get('operation') !== 'upload') {
|
||||
throw new Error('上传地址与预期不符,已停止。');
|
||||
}
|
||||
const hash = Array.from(new Uint8Array(await win.crypto.subtle.digest('SHA-256', await blob.arrayBuffer())), n => n.toString(16).padStart(2, '0')).join('');
|
||||
const key = url.href + ':' + settings.post_params.uid + ':' + hash;
|
||||
if (cache.has(key)) { notify('upload', '复用已上传图片:' + path); return cache.get(key); }
|
||||
const data = new FormData();
|
||||
for (const [key, value] of Object.entries(settings.post_params)) data.append(key, String(value));
|
||||
data.set('type', 'image');
|
||||
data.set('filetype', blob.type || (/\.png$/i.test(path) ? 'image/png' : /\.gif$/i.test(path) ? 'image/gif' : 'image/jpeg'));
|
||||
data.append(settings.file_post_name || 'Filedata', blob, path.split('/').pop());
|
||||
const control = new AbortController(), timer = setTimeout(() => control.abort(), 90000);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
signal?.addEventListener('abort', () => control.abort(), { once: true });
|
||||
const response = await win.fetch(url.href, { method: 'POST', body: data, credentials: 'same-origin', redirect: 'error', signal: control.signal });
|
||||
if (!response.ok) throw new Error('图片上传请求失败:HTTP ' + response.status);
|
||||
const aid = parseAid(await response.text());
|
||||
cache.set(key, aid);
|
||||
return aid;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') throw new Error('上传超时;该图片可能已到达服务器,请检查网站图片列表后再重试。');
|
||||
throw error;
|
||||
} finally { clearTimeout(timer); }
|
||||
}
|
||||
async function registerImages(aids, signal) {
|
||||
signal?.throwIfAborted();
|
||||
if (!aids.length) return;
|
||||
const ids = new Set(aids);
|
||||
doc.querySelectorAll('#imgattachlist [id^="image_td_"],#imgattachlist [id^="image_"]').forEach(el => {
|
||||
const id = el.id.match(/^(?:image_td_|image_)(\d+)$/)?.[1]; if (id) ids.add(id);
|
||||
});
|
||||
const ready = () => aids.every(id => doc.getElementById('image_' + id));
|
||||
if (ready()) return;
|
||||
// Use the site's own list loader, which registers attachment form fields too.
|
||||
win.updateImageList(1, Array.from(ids).join('|'));
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { observer.disconnect(); reject(new Error('图片已上传,但网站图片列表加载超时。重试会复用本页已上传图片。')); }, 25000);
|
||||
signal?.addEventListener('abort', () => { clearTimeout(timer); observer.disconnect(); reject(new Error('流程已终止')); }, { once: true });
|
||||
const check = () => { if (ready()) { clearTimeout(timer); observer.disconnect(); resolve(); } };
|
||||
const observer = new MutationObserver(check);
|
||||
observer.observe(doc.body, { childList: true, subtree: true }); check();
|
||||
});
|
||||
}
|
||||
function setEditor(title, bbcode) {
|
||||
win.switchEditor(0);
|
||||
if (Number(win.wysiwyg) !== 0) throw new Error('无法切换编辑器为纯文本模式。');
|
||||
const textarea = doc.querySelector('#e_textarea');
|
||||
textarea.value = bbcode;
|
||||
doc.querySelector('#subject').value = title;
|
||||
for (const el of [textarea, doc.querySelector('#subject')]) {
|
||||
el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
// Let Discuz generate the WYSIWYG content with native attachment metadata.
|
||||
win.switchEditor(1);
|
||||
const switcher = doc.querySelector('#e_switchercheck');
|
||||
if (switcher) switcher.checked = Number(win.wysiwyg) === 0;
|
||||
}
|
||||
async function importPrepared(options = {}) {
|
||||
const { signal, automatic = false } = options;
|
||||
signal?.throwIfAborted();
|
||||
if (busy) throw new Error('正在导入,请勿重复点击。');
|
||||
if (!article) throw new Error('请先选择文章目录或文章包。');
|
||||
assertEditor();
|
||||
for (const id of ['bbcodeoff', 'htmlon']) {
|
||||
if (doc.getElementById(id)?.checked) throw new Error('请先关闭“禁用 Discuz! 代码”和“HTML 代码”,再导入。');
|
||||
}
|
||||
const before = snapshot();
|
||||
if ((before.title.trim() || before.text.replace(/<[^>]*>/g, '').trim() || /<img\b/i.test(before.text)) &&
|
||||
(automatic ? (() => { throw new Error('编辑器已有内容,请人工处理旧稿后重试'); })() : !win.confirm('当前编辑器已有内容。导入将替换标题和正文,是否继续?'))) return { cancelled: true };
|
||||
const selected = article;
|
||||
busy = true;
|
||||
const controls = ['choose', 'package', 'import', 'md'].map($);
|
||||
controls.forEach(el => { el.disabled = true; });
|
||||
// Prevent accidental submission of a partly imported article.
|
||||
const blockSubmit = event => { event.preventDefault(); event.stopImmediatePropagation(); notify('busy', '正在导入,请等待完成后再操作发布。'); };
|
||||
const form = doc.querySelector('#postform');
|
||||
form.addEventListener('submit', blockSubmit, true);
|
||||
const submitButtons = Array.from(form.querySelectorAll('button[type="submit"],button[name],input[type="submit"]'));
|
||||
const previousDisabled = submitButtons.map(el => el.disabled);
|
||||
submitButtons.forEach(el => { el.disabled = true; });
|
||||
let committed = false;
|
||||
try {
|
||||
const aids = new Map();
|
||||
const settings = win.imgUpload.settings;
|
||||
const max = Math.min(MAX_IMAGE_SIZE, Number(settings.file_size_limit) * 1024 || MAX_IMAGE_SIZE);
|
||||
for (const [path, blob] of selected.images) if (blob.size >= max) throw new Error('配图超过当前网站限制:' + path);
|
||||
let index = 0;
|
||||
for (const [path, blob] of selected.images) {
|
||||
notify('upload', `上传 ${++index}/${selected.images.size}:${path}`, { completed: index - 1, total: selected.images.size });
|
||||
aids.set(path, await uploadImage(blob, path, settings, signal));
|
||||
}
|
||||
notify('register', '正在加载网站图片列表…');
|
||||
await registerImages(Array.from(new Set(aids.values())), signal);
|
||||
signal?.throwIfAborted();
|
||||
const now = snapshot();
|
||||
if ((now.title !== before.title || now.text !== before.text) && (automatic ? (() => { throw new Error('上传期间编辑器被修改,已保留当前内容'); })() : !win.confirm('上传期间你修改了编辑器。是否用导入文章替换最新内容?'))) {
|
||||
notify('cancelled', '已保留当前正文;图片已上传,可稍后再次导入。'); return { cancelled: true };
|
||||
}
|
||||
const backup = snapshot();
|
||||
const bbcode = selected.render(aids);
|
||||
// Preserve a local, downloadable snapshot before changing title or body.
|
||||
savedBackup = { title: backup.title, content: backup.text, mode: backup.mode, createdAt: new Date().toISOString() };
|
||||
try {
|
||||
for (const id of ['bbcodeoff', 'htmlon']) {
|
||||
const el = doc.getElementById(id);
|
||||
if (el && id === 'bbcodeoff' && el.checked) throw new Error('请先取消“禁用 Discuz! 代码”,再重试。');
|
||||
if (el && id === 'htmlon' && el.checked) throw new Error('请先关闭“HTML 代码”,再重试。');
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
setEditor(selected.title, bbcode);
|
||||
const raw = win.getEditorContents();
|
||||
if (Number(win.wysiwyg) === 1) {
|
||||
for (const id of new Set(aids.values())) if (!raw.includes('attachimg_' + id)) throw new Error('富文本图片转换未通过检查。');
|
||||
} else if (raw !== bbcode) throw new Error('正文填写校验失败。');
|
||||
if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。');
|
||||
committed = true;
|
||||
} catch (error) {
|
||||
// Restore the exact editor representation saved immediately before filling.
|
||||
win.switchEditor(backup.mode);
|
||||
if (backup.mode) win.editdoc.body.innerHTML = backup.text;
|
||||
else doc.querySelector('#e_textarea').value = backup.text;
|
||||
doc.querySelector('#subject').value = backup.title;
|
||||
const switcher = doc.querySelector('#e_switchercheck'); if (switcher) switcher.checked = !backup.mode;
|
||||
throw error;
|
||||
}
|
||||
lastResult = { title: selected.title, images: Object.fromEntries(aids), completedAt: new Date().toISOString() };
|
||||
notify('complete', `已填写标题、正文和 ${aids.size} 张配图。请检查排版,随后手动发表。`);
|
||||
return lastResult;
|
||||
} catch (error) {
|
||||
notify('error', error.message + (committed ? '' : ' 标题和正文未替换;已上传的图片会保留。'));
|
||||
throw error;
|
||||
} finally {
|
||||
busy = false; form.removeEventListener('submit', blockSubmit, true);
|
||||
submitButtons.forEach((el, index) => { el.disabled = previousDisabled[index]; });
|
||||
controls.forEach(el => { el.disabled = false; });
|
||||
$('import').disabled = !article;
|
||||
}
|
||||
}
|
||||
function downloadJSON(value, filename) {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }));
|
||||
const link = doc.createElement('a'); link.href = url; link.download = filename; link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
// Public v1 interface. Staging is read-only; importPrepared is the explicit upload/fill step.
|
||||
// No postMessage listener, localhost service, publishing endpoint or saved credentials.
|
||||
const api = Object.freeze({ version: VERSION, schemaVersion: 1, stageFiles, stagePackage,
|
||||
getState: summary, openPicker: () => { if (!busy) $('folder').click(); }, importPrepared,
|
||||
getBackup: () => savedBackup });
|
||||
win.DWW11Importer = api;
|
||||
$('choose').onclick = () => { $('folder').value = ''; $('folder').click(); };
|
||||
$('package').onclick = () => { $('json').value = ''; $('json').click(); };
|
||||
const handleError = error => notify('error', error.message || String(error));
|
||||
$('folder').onchange = () => stageFiles($('folder').files).catch(handleError);
|
||||
$('json').onchange = async () => {
|
||||
try { const file = $('json').files[0]; if (file) await stagePackage(JSON.parse(await file.text())); }
|
||||
catch (error) { handleError(error); }
|
||||
};
|
||||
$('md').onchange = () => {
|
||||
const files = candidates, path = $('md').value;
|
||||
if (files && path) stageFiles(files, { markdownPath: path }).catch(handleError);
|
||||
};
|
||||
$('import').onclick = () => importPrepared().catch(handleError);
|
||||
$('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json');
|
||||
win.addEventListener('pagehide', releasePreviews, { once: true });
|
||||
}
|
||||
return { VERSION, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, mount };
|
||||
});
|
||||
|
||||
// Extracted from dww11-wechat-oneclick.user.js v1.0.9; package source is authoritative.
|
||||
(() => {
|
||||
'use strict';
|
||||
const cleanText = value => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
function flattenParagraphs(body) {
|
||||
const selector = 'p,div,section,article,figure,figcaption,h1,h2,h3,h4,h5,h6';
|
||||
const marker = () => {
|
||||
const br = body.ownerDocument.createElement('br');
|
||||
br.dataset.dww11Boundary = '1'; return br;
|
||||
};
|
||||
for (const block of Array.from(body.querySelectorAll(selector)).reverse()) {
|
||||
if (block.closest('table,ul,ol,blockquote')) continue;
|
||||
block.replaceWith(marker(), ...block.childNodes, marker());
|
||||
}
|
||||
let previous = false, pending = [];
|
||||
function finish() {
|
||||
const actual = pending.filter(br => !br.hasAttribute('data-dww11-boundary'));
|
||||
let retained = false;
|
||||
for (const br of pending) {
|
||||
if (!br.hasAttribute('data-dww11-boundary')) continue;
|
||||
if (previous && !actual.length && !retained) { br.removeAttribute('data-dww11-boundary'); retained = true; }
|
||||
else br.remove();
|
||||
}
|
||||
pending = [];
|
||||
}
|
||||
const walker = body.ownerDocument.createTreeWalker(body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
|
||||
const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||
for (const node of nodes) {
|
||||
if (node.nodeType === 1 && node.tagName === 'BR') pending.push(node);
|
||||
else if ((node.nodeType === 3 && node.textContent.trim()) || (node.nodeType === 1 && ['IMG', 'VIDEO', 'HR', 'IFRAME'].includes(node.tagName))) {
|
||||
finish(); previous = true;
|
||||
}
|
||||
}
|
||||
// 末尾不保留由容器边界制造的额外换行。
|
||||
for (const br of pending) if (br.hasAttribute('data-dww11-boundary')) br.remove();
|
||||
}
|
||||
function spaceImages(body) {
|
||||
let previous = null, gapNodes = [], blankLines = 0;
|
||||
const changes = [];
|
||||
function content(kind, node) {
|
||||
if (previous && previous.kind !== kind) {
|
||||
let anchor = node;
|
||||
while (anchor.parentElement && anchor.parentElement !== body &&
|
||||
!['TD', 'TH', 'LI', 'BLOCKQUOTE'].includes(anchor.parentElement.tagName) && !anchor.parentElement.contains(previous.node)) anchor = anchor.parentElement;
|
||||
changes.push({ anchor, nodes: gapNodes.slice(), count: Math.max(2, blankLines) });
|
||||
}
|
||||
previous = { kind, node }; gapNodes = []; blankLines = 0;
|
||||
}
|
||||
function visit(node) {
|
||||
if (node.nodeType === 3) {
|
||||
if (node.textContent.trim()) content('text', node); else gapNodes.push(node);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== 1 || ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(node.tagName)) return;
|
||||
if (node.tagName === 'BR') { gapNodes.push(node); blankLines++; return; }
|
||||
if (node.tagName === 'IMG') { content('image', node); return; }
|
||||
for (const child of node.childNodes) visit(child);
|
||||
}
|
||||
for (const child of body.childNodes) visit(child);
|
||||
for (const { anchor, nodes, count } of changes) {
|
||||
for (const node of nodes) node.remove();
|
||||
const fragment = body.ownerDocument.createDocumentFragment();
|
||||
for (let i = 0; i < count; i++) fragment.append(body.ownerDocument.createElement('br'));
|
||||
anchor.parentNode.insertBefore(fragment, anchor);
|
||||
}
|
||||
}
|
||||
function formatBody(body, author, sourceText = null) {
|
||||
const doc = body.ownerDocument;
|
||||
body.querySelectorAll('[data-dww11-wx-source="1"]').forEach(el => el.remove());
|
||||
// Discuz 会把内联 color 值直接转为 [color=…]。
|
||||
// !important 不能出现在该值中;颜色和字号只用原生 FONT 属性。
|
||||
for (const el of [body, ...body.querySelectorAll('*')]) {
|
||||
if (['SCRIPT', 'STYLE'].includes(el.tagName)) continue;
|
||||
for (const property of ['font', 'font-size', 'color', '-webkit-text-fill-color']) {
|
||||
el.style.removeProperty(property);
|
||||
}
|
||||
// 其他排版属性也不保留 CSS 优先级,避免它泄漏进论坛标签。
|
||||
for (const property of Array.from(el.style)) {
|
||||
if (el.style.getPropertyPriority(property)) {
|
||||
el.style.setProperty(property, el.style.getPropertyValue(property));
|
||||
}
|
||||
}
|
||||
if (!el.style.cssText) el.removeAttribute('style');
|
||||
}
|
||||
// 去掉采集器或旧版脚本生成的嵌套 FONT,保留其中的内容和图片。
|
||||
for (const font of Array.from(body.querySelectorAll('font'))) {
|
||||
font.replaceWith(...font.childNodes);
|
||||
}
|
||||
flattenParagraphs(body);
|
||||
spaceImages(body);
|
||||
// 所有正文内容和换行放在同一 FONT 中。不能使用只有 BR 的空 FONT:
|
||||
// 论坛转换器可能清除空格式标签,导致空行退回默认1号或直接丢失。
|
||||
const articleFont = doc.createElement('font');
|
||||
articleFont.size = '4'; articleFont.color = '#000000';
|
||||
articleFont.dataset.dww11WxBody = '1';
|
||||
articleFont.append(...body.childNodes);
|
||||
body.append(articleFont);
|
||||
// A 自身的浏览器默认蓝色会覆盖外层继承色,链接文字另设黑色。
|
||||
for (const link of articleFont.querySelectorAll('a')) {
|
||||
const linkFont = doc.createElement('font');
|
||||
linkFont.size = '4'; linkFont.color = '#000000';
|
||||
linkFont.append(...link.childNodes); link.append(linkFont);
|
||||
}
|
||||
// 只用于编辑器图片适配;帖子展示仍交给网站原有图片/附件规则。
|
||||
for (const img of articleFont.querySelectorAll('img')) {
|
||||
img.style.maxWidth = '100%'; img.style.height = 'auto';
|
||||
}
|
||||
const source = doc.createElement('p');
|
||||
source.dataset.dww11WxSource = '1';
|
||||
const font = doc.createElement('font');
|
||||
font.size = '1'; font.color = '#c0c0c0';
|
||||
const lines = (sourceText ?? `来源:${author},如有侵权,请联系删除。`).split('\n');
|
||||
lines.forEach((line, index) => {
|
||||
if (index) font.append(doc.createElement('br'));
|
||||
font.append(doc.createTextNode(line));
|
||||
});
|
||||
source.append(font); body.append(source);
|
||||
}
|
||||
function existingSource(body) {
|
||||
// 按段落和 BR 读取末尾逻辑行,兼容嵌套 FONT、纯文本及末尾空段落。
|
||||
const lines = [];
|
||||
let parts = [];
|
||||
function flush() {
|
||||
const text = cleanText(parts.map(part => part.node.textContent.slice(part.start, part.end)).join(''));
|
||||
if (text) lines.push({ text, parts });
|
||||
parts = [];
|
||||
}
|
||||
function visit(node) {
|
||||
if (node.nodeType === 3) {
|
||||
// 保留文本中的显式换行,并记录精确位置,避免删除同容器中的正文。
|
||||
const value = node.textContent;
|
||||
let start = 0;
|
||||
for (let i = 0; i <= value.length; i++) {
|
||||
if (i !== value.length && value[i] !== '\n') continue;
|
||||
// HTML 缩进换行通常只是空白;只有后续是来源或版权行才分行。
|
||||
if (i < value.length && !/^\s*(?:(?:来源|文章来源|本文来源|图文来源|文字来源|作者|作者信息|来源作者|转自|转载自|出处)\s*[::]|如有侵权|若有侵权|侵权请|转载请|版权归)/.test(value.slice(i + 1))) continue;
|
||||
if (i > start) parts.push({ node, start, end: i });
|
||||
if (i < value.length) flush();
|
||||
start = i + 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== 1 || ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(node.tagName)) return;
|
||||
if (node.tagName === 'BR') { flush(); return; }
|
||||
if (['IMG', 'VIDEO', 'AUDIO', 'IFRAME', 'HR'].includes(node.tagName)) {
|
||||
flush(); lines.push({ text: '\ufffc', parts: [] }); return;
|
||||
}
|
||||
const block = /^(P|DIV|SECTION|ARTICLE|FIGURE|FIGCAPTION|H[1-6]|LI|TR|BLOCKQUOTE|PRE)$/.test(node.tagName);
|
||||
if (block) flush();
|
||||
for (const child of node.childNodes) visit(child);
|
||||
if (block) flush();
|
||||
}
|
||||
for (const child of body.childNodes) visit(child);
|
||||
flush();
|
||||
let first = -1;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (/^(?:来源|文章来源|本文来源|图文来源|文字来源|作者|作者信息|来源作者|转自|转载自|出处)\s*[::]\s*\S/.test(lines[i].text)) {
|
||||
first = i;
|
||||
} else if (!/^(?:如有侵权|若有侵权|侵权请|转载请|版权归)/.test(lines[i].text)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (first < 0) return null;
|
||||
const start = lines[first].parts[0];
|
||||
const range = body.ownerDocument.createRange();
|
||||
range.setStart(start.node, start.start);
|
||||
range.setEnd(body, body.childNodes.length);
|
||||
return {
|
||||
text: lines.slice(first).map(line => line.text).join('\n'),
|
||||
remove: () => range.deleteContents()
|
||||
};
|
||||
}
|
||||
|
||||
window.DWW11Formatter = Object.freeze({
|
||||
format(expectedSource) {
|
||||
if (Number(window.wysiwyg) !== 1) throw new Error('请切换到可视化编辑器后排版');
|
||||
const body = document.querySelector('#e_iframe')?.contentDocument?.body;
|
||||
if (!body) throw new Error('编辑器未就绪');
|
||||
const footer = existingSource(body);
|
||||
const expected = `来源:${expectedSource},如有侵权,请联系删除。`;
|
||||
if (!footer || footer.text !== expected) throw new Error('文末来源与文章包不一致,请人工检查');
|
||||
const images = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src'));
|
||||
const backup = body.innerHTML;
|
||||
try {
|
||||
footer.remove();
|
||||
formatBody(body, expectedSource, expected);
|
||||
const after = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src'));
|
||||
if (JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变');
|
||||
if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常');
|
||||
body.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
return {images: after, source: expected};
|
||||
} catch (error) { body.innerHTML = backup; throw error; }
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
/* Page-side orchestrator. This file never clicks or submits the publication form. */
|
||||
(() => {
|
||||
'use strict';
|
||||
if (window.DWW11NewsRunner) return;
|
||||
window.DWW11NewsRunner = true;
|
||||
const SLOT='auto-news-current';
|
||||
const owner=sessionStorage.getItem('auto-news-owner') || crypto.randomUUID();
|
||||
sessionStorage.setItem('auto-news-owner',owner);
|
||||
let run=null, working=false, halted=true, aborter=null, active=null, tickBusy=false;
|
||||
const pending=new Map();
|
||||
function rpc(route,body={}) {
|
||||
return new Promise((resolve,reject)=>{
|
||||
const id=crypto.randomUUID();
|
||||
const timer=setTimeout(()=>{pending.delete(id);reject(new Error('本地连接无响应'));},65000);
|
||||
pending.set(id,{resolve,reject,timer});
|
||||
document.dispatchEvent(new CustomEvent('dww11:local-request',{detail:JSON.stringify({id,route,body:{owner,...body}})}));
|
||||
});
|
||||
}
|
||||
document.addEventListener('dww11:local-response',event=>{
|
||||
let data;try{data=JSON.parse(event.detail);}catch{return;}
|
||||
const waiter=pending.get(data.id);if(!waiter)return;
|
||||
clearTimeout(waiter.timer);pending.delete(data.id);
|
||||
data.error?waiter.reject(new Error(data.error)):waiter.resolve(data.value);
|
||||
});
|
||||
const status=message=>document.dispatchEvent(new CustomEvent('dww11:connection-status',{detail:message}));
|
||||
document.addEventListener('dww11:auto-stop',()=>{halted=true;aborter?.abort(new Error('流程已终止'));});
|
||||
function account() {
|
||||
const element=document.querySelector('#mumucms_username');
|
||||
if(!element)return null;
|
||||
const name=[...element.childNodes].filter(n=>n.nodeType===Node.TEXT_NODE).map(n=>n.textContent).join('').trim();
|
||||
const links=[element,...element.querySelectorAll('a')].filter(e=>e.href);
|
||||
let uid=null;
|
||||
for(const link of links) {
|
||||
const href=link.href;
|
||||
uid=new URL(href,location.href).searchParams.get('uid') || href.match(/space-uid-(\d+)/)?.[1];
|
||||
if(uid)break;
|
||||
}
|
||||
return uid&&name?{uid,name}:null;
|
||||
}
|
||||
function extension() {
|
||||
try{return JSON.parse(document.querySelector('#dww11-extension-panel')?.getAttribute('data-auto-news-state') || '{}');}catch{return {};}
|
||||
}
|
||||
function assertLive(epoch) {
|
||||
if(halted||!run?.running||run.epoch!==epoch)throw new Error('流程已暂停或终止');
|
||||
if(active?.account?.uid!==account()?.uid)throw new Error('当前账号与任务不一致');
|
||||
}
|
||||
function editorSnapshot() {
|
||||
const body=document.querySelector('#e_iframe')?.contentDocument?.body;
|
||||
if(!body)throw new Error('找不到可视化编辑器');
|
||||
return {title:document.querySelector('#subject')?.value || '',
|
||||
images:[...body.querySelectorAll('img')].map(x=>({id:x.id,src:x.getAttribute('src')})),
|
||||
// Snapshot only final submitted content; never includes hidden tokens or the full form.
|
||||
html:body.innerHTML.slice(0,500000), text:body.innerText.slice(0,100000)};
|
||||
}
|
||||
function saveLocal(value) {sessionStorage.setItem(SLOT,JSON.stringify(value));}
|
||||
function loadLocal(){try{return JSON.parse(sessionStorage.getItem(SLOT)||'null');}catch{return null;}}
|
||||
async function event(state,extra={}) {return rpc('event',{id:active.id,state,epoch:run.epoch,...extra});}
|
||||
async function fail(error) {
|
||||
status(error.message);
|
||||
if(active)try{await event('needs_attention',{error:error.message});}catch{}
|
||||
halted=true;aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));
|
||||
}
|
||||
function editorPage(job) {
|
||||
const u=new URL(location.href);
|
||||
return u.searchParams.get('mod')==='post'&&u.searchParams.get('action')==='newthread'&&u.searchParams.get('fid')===String(job.fid);
|
||||
}
|
||||
async function prepare(job) {
|
||||
working=true;active=job;aborter=new AbortController();const epoch=run.epoch;
|
||||
try {
|
||||
assertLive(epoch);
|
||||
if(!editorPage(job)) {
|
||||
saveLocal({id:job.id});
|
||||
location.assign(`https://www.dww11.com/forum.php?mod=post&action=newthread&fid=${job.fid}`);return;
|
||||
}
|
||||
if(!window.DWW11Importer||!window.DWW11Formatter)throw new Error('导入或排版模块尚未就绪,请检查安装');
|
||||
status('正在检查文章包…');
|
||||
const pack=await rpc('package',{id:job.id});assertLive(epoch);
|
||||
const prepared=await window.DWW11Importer.stagePackage(pack);assertLive(epoch);
|
||||
if(prepared.warnings?.length)throw new Error('文章需要人工处理:'+prepared.warnings.join(';'));
|
||||
await event('importing');assertLive(epoch);status('上传图片并填写文章…');
|
||||
const result=await window.DWW11Importer.importPrepared({automatic:true,signal:aborter.signal});assertLive(epoch);
|
||||
if(result?.cancelled)throw new Error('导入已取消');
|
||||
await event('formatting');assertLive(epoch);
|
||||
window.DWW11Formatter.format(job.source);assertLive(epoch);
|
||||
const snapshot=editorSnapshot();
|
||||
if(snapshot.title!==job.title)throw new Error('标题校验失败');
|
||||
const aids=new Set(Object.values(result.images));
|
||||
for(const aid of aids) if(!snapshot.images.some(x=>x.id==='attachimg_'+aid))throw new Error('配图附件缺失:'+aid);
|
||||
await event('awaiting_user_review',{snapshot});assertLive(epoch);
|
||||
active={...job,state:'awaiting_user_review'};saveLocal({id:job.id});
|
||||
status('已完成排版,请核对内容并亲手点击“发表帖子”。');
|
||||
}catch(error){await fail(error);}finally{working=false;}
|
||||
}
|
||||
// Record only trusted user activation. Never replay a click, call submit(), or send a post request.
|
||||
document.addEventListener('click',event=>{
|
||||
if(!event.isTrusted||!event.target.closest?.('#postsubmit')||active?.state!=='awaiting_user_review')return;
|
||||
try {
|
||||
const snapshot=editorSnapshot();
|
||||
const local={id:active.id,snapshot,clickedAt:Date.now(),observed:false};saveLocal(local);
|
||||
rpc('event',{id:active.id,state:'user_submitting',epoch:run?.epoch,snapshot})
|
||||
.then(()=>{const latest=loadLocal();if(latest?.clickedAt===local.clickedAt)saveLocal({...local,observed:true});})
|
||||
.catch(error=>status('提交记录待恢复:'+error.message));
|
||||
active.state='user_submitting';status('已观察到人工点击,等待结果页核对。');
|
||||
}catch(error){status('提交快照记录失败:'+error.message);}
|
||||
},true);
|
||||
async function observeResult(job) {
|
||||
const local=loadLocal();
|
||||
if(local?.id===job.id&&local.snapshot&&job.state==='awaiting_user_review') {
|
||||
await rpc('event',{id:job.id,state:'user_submitting',epoch:run.epoch,snapshot:local.snapshot});
|
||||
job.state='user_submitting';
|
||||
}
|
||||
if(job.state==='user_submitting') {
|
||||
// Result selectors intentionally await user-provided HTML; URL alone is not proof.
|
||||
if(!editorPage(job) || (local?.clickedAt && Date.now()-local.clickedAt>45000)) {
|
||||
await rpc('event',{id:job.id,state:'submit_unknown',error:'等待结果页 HTML 适配;候选页面:'+location.href});
|
||||
await rpc('stop');
|
||||
status('已记录候选结果页;尚不能自动核对。流程已暂停,请补充结果页 HTML。');
|
||||
}
|
||||
}else if(job.state==='submit_unknown')status('发布结果待核对;不会重发、计数或换号。');
|
||||
}
|
||||
async function switchAccount() {
|
||||
if(working||halted)return;
|
||||
if(location.origin!=='https://www.dww11.com'||location.pathname!=='/') {
|
||||
location.assign('https://www.dww11.com/');return;
|
||||
}
|
||||
const ext=extension(),button=document.querySelector('#dww11-extension-next');
|
||||
if(!ext.ready||!ext.idle||!button||button.disabled) {status('请安装并重新加载带联动接口的换号插件');return;}
|
||||
await rpc('switch-begin');
|
||||
if(halted||!run?.running)return;
|
||||
button.click();status('正在换号;等待插件完成并核对新账号');
|
||||
}
|
||||
async function tick() {
|
||||
if(tickBusy)return;tickBusy=true;
|
||||
try {
|
||||
run=await rpc('heartbeat',{account:account(),url:location.href,extension:extension()});
|
||||
halted=!run.running;
|
||||
if(!run.running) {aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));}
|
||||
if(working)return;
|
||||
// Passive result recording is permitted after Stop, but no new business actions.
|
||||
if(run.active) {
|
||||
const local=loadLocal();
|
||||
if(local?.id===run.active&&local.snapshot&&!run.running)status('流程已暂停;提交结果仍需核对');
|
||||
}
|
||||
if(!run.running) {
|
||||
if(run.active && run.owner===owner) {const saved=await rpc('current');if(saved.job) {active=saved.job;await observeResult(saved.job);}}
|
||||
status(run.reason || '流程已暂停');return;
|
||||
}
|
||||
const next=await rpc('next',{account:account(),extension:extension()});run=next.run;
|
||||
if(next.job)active=next.job;
|
||||
if(next.action==='prepare'||(next.action==='resume'&&next.job.state==='preparing'))void prepare(next.job);
|
||||
else if(next.action==='resume') {
|
||||
await observeResult(next.job);
|
||||
if(next.job.state==='awaiting_user_review') {
|
||||
if(!editorPage(next.job)||!document.querySelector('#subject')?.value) await fail(new Error('待审稿页面已丢失,请人工核对旧稿,不自动重新导入'));
|
||||
else status('等待你亲手发表;程序不再修改编辑器');
|
||||
}
|
||||
else if(['importing','formatting'].includes(next.job.state))await fail(new Error('导入阶段发生中断,请检查编辑器和已上传图片后处理任务'));
|
||||
else if(next.job.state==='needs_attention')status(next.job.error);
|
||||
}else if(next.action==='switch')await switchAccount();
|
||||
else if(next.action==='switch_wait')status('等待换号结果;若插件已暂停,请人工处理后继续');
|
||||
else if(next.action==='login')status('未能读取当前账号 UID,请登录并确认账号元素');
|
||||
else if(next.action==='other_tab')status('另一个标签持有任务;如需接管请先暂停');
|
||||
else if(next.action==='interval')status('等待设定的处理间隔');
|
||||
else status(run.reason || '等待任务');
|
||||
}catch(error){status(error.message);halted=true;aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));}
|
||||
finally{tickBusy=false;}
|
||||
}
|
||||
const takeover=document.createElement('button');takeover.type='button';takeover.textContent='接管已暂停任务';
|
||||
takeover.style.cssText='position:fixed;left:16px;top:16px;z-index:2147483646;padding:8px;background:#fff;border:1px solid #aab;border-radius:6px';
|
||||
takeover.onclick=async()=>{try{await rpc('takeover');status('已接管;核对当前页面后在工作台继续');}catch(error){status(error.message);}};
|
||||
document.body.append(takeover);
|
||||
setInterval(tick,2000);tick();
|
||||
})();
|
||||
@@ -0,0 +1,397 @@
|
||||
// ==UserScript==
|
||||
// @name 广谈大湾网 · 微信文章一键导入并排版
|
||||
// @namespace local.dww11.wechat-oneclick
|
||||
// @version 1.0.9
|
||||
// @description 使用网站现有微信采集器,修正标题,统一正文4号黑色,追加1号银色来源;不点击保存或发布。
|
||||
// @match https://www.dww11.com/forum.php*
|
||||
// @match https://dww11.com/forum.php*
|
||||
// @grant GM_xmlhttpRequest
|
||||
// @grant unsafeWindow
|
||||
// @connect mp.weixin.qq.com
|
||||
// @run-at document-idle
|
||||
// @noframes
|
||||
// ==/UserScript==
|
||||
|
||||
(() => {
|
||||
'use strict';
|
||||
const ID = 'dww11-wx-oneclick';
|
||||
const FORMAT_ID = 'dww11-format-oneclick';
|
||||
let busy = false;
|
||||
function setBusy(value) {
|
||||
busy = value;
|
||||
for (const id of [ID, FORMAT_ID]) {
|
||||
const button = document.getElementById(id);
|
||||
if (button) button.disabled = value;
|
||||
}
|
||||
}
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const visible = el => el && el.getClientRects().length && getComputedStyle(el).display !== 'none';
|
||||
async function waitFor(fn, timeout = 10000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const result = fn();
|
||||
if (result) return result;
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error('等待编辑器或采集窗口超时,请关闭遮挡窗口后重试。');
|
||||
}
|
||||
function normalizeURL(raw) {
|
||||
const url = new URL(raw.trim().replace(/[\u200b-\u200d\ufeff]/g, ''));
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'mp.weixin.qq.com' || url.port || url.username || url.password || !/^\/(s(?:\/|$)|s$)/.test(url.pathname)) {
|
||||
throw new Error('请输入 https://mp.weixin.qq.com/s/… 或 /s?… 微信文章地址。');
|
||||
}
|
||||
url.hash = '';
|
||||
return url.href;
|
||||
}
|
||||
function cleanText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
function metadata(html) {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const text = selector => cleanText(doc.querySelector(selector)?.textContent);
|
||||
const meta = selector => cleanText(doc.querySelector(selector)?.getAttribute('content'));
|
||||
// 与下载组件一致,先确认是真正的文章,避免把验证页标题当成文章。
|
||||
if (!doc.querySelector('#js_content')) throw new Error('未找到微信文章正文,可能需要验证、登录,或文章已失效。');
|
||||
const title = text('#activity-name .js_title_inner') || text('#activity-name') || meta('meta[property="og:title"]') || meta('meta[name="twitter:title"]') || text('h1') || text('title');
|
||||
// 不读取 rich_media_meta_text 容器:它可能包含重复的隐藏作者,或实际上是日期/IP。
|
||||
// 来源使用蓝色链接 #js_name 的公众号名称,不使用灰色署名作者。
|
||||
const author = text('#js_name');
|
||||
if (!title || !author) throw new Error('微信未返回完整标题和公众号名称,可能需要验证或登录。');
|
||||
return { title, author };
|
||||
}
|
||||
function fetchMetadata(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
GM_xmlhttpRequest({
|
||||
method: 'GET', url, timeout: 25000, anonymous: true,
|
||||
onload(response) {
|
||||
try {
|
||||
if (response.status !== 200) throw new Error('微信返回状态 ' + response.status);
|
||||
if (response.finalUrl && new URL(response.finalUrl).hostname !== 'mp.weixin.qq.com') throw new Error('微信地址发生了非预期跳转。');
|
||||
resolve(metadata(response.responseText));
|
||||
} catch (error) { reject(error); }
|
||||
},
|
||||
onerror: () => reject(new Error('无法读取微信文章。')),
|
||||
ontimeout: () => reject(new Error('读取微信文章超时。'))
|
||||
});
|
||||
});
|
||||
}
|
||||
async function getMetadata(url) {
|
||||
try { return await fetchMetadata(url); }
|
||||
catch (error) {
|
||||
const title = prompt(error.message + '\n请从源文章复制完整标题(取消则停止导入):');
|
||||
if (title === null) return null;
|
||||
const author = prompt('请填写源文章蓝色链接处的公众号名称(取消则停止导入):');
|
||||
if (author === null) return null;
|
||||
if (!cleanText(title) || !cleanText(author)) throw new Error('标题和作者不能为空。');
|
||||
return { title: cleanText(title), author: cleanText(author) };
|
||||
}
|
||||
}
|
||||
function editorBody() {
|
||||
return document.querySelector('#e_iframe')?.contentDocument?.body;
|
||||
}
|
||||
async function ensureVisualEditor() {
|
||||
const checkbox = document.querySelector('#e_switchercheck');
|
||||
if (checkbox?.checked) checkbox.click();
|
||||
await waitFor(() => visible(document.querySelector('#e_iframe')) && editorBody());
|
||||
}
|
||||
// 只监听本次现有采集器的响应,不重写其请求或图片附件处理。
|
||||
function watchImport(jq, url) {
|
||||
let timer, resolveResult, rejectResult;
|
||||
const result = new Promise((resolve, reject) => { resolveResult = resolve; rejectResult = reject; });
|
||||
const events = jq(document);
|
||||
const cleanup = () => { clearTimeout(timer); events.off('.dww11wx'); };
|
||||
const finish = error => { cleanup(); error ? rejectResult(error) : resolveResult(); };
|
||||
events.on('ajaxComplete.dww11wx', (_event, xhr, settings) => {
|
||||
let requestURL, sentURL;
|
||||
try {
|
||||
requestURL = new URL(settings.url, location.href);
|
||||
sentURL = typeof settings.data === 'string' ? new URLSearchParams(settings.data).get('url') : settings.data?.url;
|
||||
} catch { return; }
|
||||
if (requestURL.origin !== location.origin || requestURL.searchParams.get('id') !== 'wxcj:cj' || sentURL !== url) return;
|
||||
const parts = String(xhr.responseText || '').split('----wxcj----');
|
||||
if (xhr.status < 200 || xhr.status >= 300 || parts.length < 4 || !parts[2]?.trim()) {
|
||||
finish(new Error('网站采集失败或返回内容不完整。未执行排版,请检查采集提示。'));
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
});
|
||||
timer = setTimeout(() => finish(new Error('网站采集超过90秒,未执行排版。采集器仍可能在后台返回,请勿连续重复导入。')), 90000);
|
||||
return { result, cleanup };
|
||||
}
|
||||
// 将微信 P/SECTION 的隐式换行改成真实 BR,避免论坛忽略 SECTION 边界。
|
||||
function flattenParagraphs(body) {
|
||||
const selector = 'p,div,section,article,figure,figcaption,h1,h2,h3,h4,h5,h6';
|
||||
const marker = () => {
|
||||
const br = body.ownerDocument.createElement('br');
|
||||
br.dataset.dww11Boundary = '1'; return br;
|
||||
};
|
||||
for (const block of Array.from(body.querySelectorAll(selector)).reverse()) {
|
||||
if (block.closest('table,ul,ol,blockquote')) continue;
|
||||
block.replaceWith(marker(), ...block.childNodes, marker());
|
||||
}
|
||||
let previous = false, pending = [];
|
||||
function finish() {
|
||||
const actual = pending.filter(br => !br.hasAttribute('data-dww11-boundary'));
|
||||
let retained = false;
|
||||
for (const br of pending) {
|
||||
if (!br.hasAttribute('data-dww11-boundary')) continue;
|
||||
if (previous && !actual.length && !retained) { br.removeAttribute('data-dww11-boundary'); retained = true; }
|
||||
else br.remove();
|
||||
}
|
||||
pending = [];
|
||||
}
|
||||
const walker = body.ownerDocument.createTreeWalker(body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
|
||||
const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||
for (const node of nodes) {
|
||||
if (node.nodeType === 1 && node.tagName === 'BR') pending.push(node);
|
||||
else if ((node.nodeType === 3 && node.textContent.trim()) || (node.nodeType === 1 && ['IMG', 'VIDEO', 'HR', 'IFRAME'].includes(node.tagName))) {
|
||||
finish(); previous = true;
|
||||
}
|
||||
}
|
||||
// 末尾不保留由容器边界制造的额外换行。
|
||||
for (const br of pending) if (br.hasAttribute('data-dww11-boundary')) br.remove();
|
||||
}
|
||||
function spaceImages(body) {
|
||||
let previous = null, gapNodes = [], blankLines = 0;
|
||||
const changes = [];
|
||||
function content(kind, node) {
|
||||
if (previous && previous.kind !== kind) {
|
||||
let anchor = node;
|
||||
while (anchor.parentElement && anchor.parentElement !== body &&
|
||||
!['TD', 'TH', 'LI', 'BLOCKQUOTE'].includes(anchor.parentElement.tagName) && !anchor.parentElement.contains(previous.node)) anchor = anchor.parentElement;
|
||||
changes.push({ anchor, nodes: gapNodes.slice(), count: Math.max(2, blankLines) });
|
||||
}
|
||||
previous = { kind, node }; gapNodes = []; blankLines = 0;
|
||||
}
|
||||
function visit(node) {
|
||||
if (node.nodeType === 3) {
|
||||
if (node.textContent.trim()) content('text', node); else gapNodes.push(node);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== 1 || ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(node.tagName)) return;
|
||||
if (node.tagName === 'BR') { gapNodes.push(node); blankLines++; return; }
|
||||
if (node.tagName === 'IMG') { content('image', node); return; }
|
||||
for (const child of node.childNodes) visit(child);
|
||||
}
|
||||
for (const child of body.childNodes) visit(child);
|
||||
for (const { anchor, nodes, count } of changes) {
|
||||
for (const node of nodes) node.remove();
|
||||
const fragment = body.ownerDocument.createDocumentFragment();
|
||||
for (let i = 0; i < count; i++) fragment.append(body.ownerDocument.createElement('br'));
|
||||
anchor.parentNode.insertBefore(fragment, anchor);
|
||||
}
|
||||
}
|
||||
function formatBody(body, author, sourceText = null) {
|
||||
const doc = body.ownerDocument;
|
||||
body.querySelectorAll('[data-dww11-wx-source="1"]').forEach(el => el.remove());
|
||||
// Discuz 会把内联 color 值直接转为 [color=…]。
|
||||
// !important 不能出现在该值中;颜色和字号只用原生 FONT 属性。
|
||||
for (const el of [body, ...body.querySelectorAll('*')]) {
|
||||
if (['SCRIPT', 'STYLE'].includes(el.tagName)) continue;
|
||||
for (const property of ['font', 'font-size', 'color', '-webkit-text-fill-color']) {
|
||||
el.style.removeProperty(property);
|
||||
}
|
||||
// 其他排版属性也不保留 CSS 优先级,避免它泄漏进论坛标签。
|
||||
for (const property of Array.from(el.style)) {
|
||||
if (el.style.getPropertyPriority(property)) {
|
||||
el.style.setProperty(property, el.style.getPropertyValue(property));
|
||||
}
|
||||
}
|
||||
if (!el.style.cssText) el.removeAttribute('style');
|
||||
}
|
||||
// 去掉采集器或旧版脚本生成的嵌套 FONT,保留其中的内容和图片。
|
||||
for (const font of Array.from(body.querySelectorAll('font'))) {
|
||||
font.replaceWith(...font.childNodes);
|
||||
}
|
||||
flattenParagraphs(body);
|
||||
spaceImages(body);
|
||||
// 所有正文内容和换行放在同一 FONT 中。不能使用只有 BR 的空 FONT:
|
||||
// 论坛转换器可能清除空格式标签,导致空行退回默认1号或直接丢失。
|
||||
const articleFont = doc.createElement('font');
|
||||
articleFont.size = '4'; articleFont.color = '#000000';
|
||||
articleFont.dataset.dww11WxBody = '1';
|
||||
articleFont.append(...body.childNodes);
|
||||
body.append(articleFont);
|
||||
// A 自身的浏览器默认蓝色会覆盖外层继承色,链接文字另设黑色。
|
||||
for (const link of articleFont.querySelectorAll('a')) {
|
||||
const linkFont = doc.createElement('font');
|
||||
linkFont.size = '4'; linkFont.color = '#000000';
|
||||
linkFont.append(...link.childNodes); link.append(linkFont);
|
||||
}
|
||||
// 只用于编辑器图片适配;帖子展示仍交给网站原有图片/附件规则。
|
||||
for (const img of articleFont.querySelectorAll('img')) {
|
||||
img.style.maxWidth = '100%'; img.style.height = 'auto';
|
||||
}
|
||||
const source = doc.createElement('p');
|
||||
source.dataset.dww11WxSource = '1';
|
||||
const font = doc.createElement('font');
|
||||
font.size = '1'; font.color = '#c0c0c0';
|
||||
const lines = (sourceText ?? `来源:${author},如有侵权,请联系删除。`).split('\n');
|
||||
lines.forEach((line, index) => {
|
||||
if (index) font.append(doc.createElement('br'));
|
||||
font.append(doc.createTextNode(line));
|
||||
});
|
||||
source.append(font); body.append(source);
|
||||
}
|
||||
function existingSource(body) {
|
||||
// 按段落和 BR 读取末尾逻辑行,兼容嵌套 FONT、纯文本及末尾空段落。
|
||||
const lines = [];
|
||||
let parts = [];
|
||||
function flush() {
|
||||
const text = cleanText(parts.map(part => part.node.textContent.slice(part.start, part.end)).join(''));
|
||||
if (text) lines.push({ text, parts });
|
||||
parts = [];
|
||||
}
|
||||
function visit(node) {
|
||||
if (node.nodeType === 3) {
|
||||
// 保留文本中的显式换行,并记录精确位置,避免删除同容器中的正文。
|
||||
const value = node.textContent;
|
||||
let start = 0;
|
||||
for (let i = 0; i <= value.length; i++) {
|
||||
if (i !== value.length && value[i] !== '\n') continue;
|
||||
// HTML 缩进换行通常只是空白;只有后续是来源或版权行才分行。
|
||||
if (i < value.length && !/^\s*(?:(?:来源|文章来源|本文来源|图文来源|文字来源|作者|作者信息|来源作者|转自|转载自|出处)\s*[::]|如有侵权|若有侵权|侵权请|转载请|版权归)/.test(value.slice(i + 1))) continue;
|
||||
if (i > start) parts.push({ node, start, end: i });
|
||||
if (i < value.length) flush();
|
||||
start = i + 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== 1 || ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(node.tagName)) return;
|
||||
if (node.tagName === 'BR') { flush(); return; }
|
||||
if (['IMG', 'VIDEO', 'AUDIO', 'IFRAME', 'HR'].includes(node.tagName)) {
|
||||
flush(); lines.push({ text: '\ufffc', parts: [] }); return;
|
||||
}
|
||||
const block = /^(P|DIV|SECTION|ARTICLE|FIGURE|FIGCAPTION|H[1-6]|LI|TR|BLOCKQUOTE|PRE)$/.test(node.tagName);
|
||||
if (block) flush();
|
||||
for (const child of node.childNodes) visit(child);
|
||||
if (block) flush();
|
||||
}
|
||||
for (const child of body.childNodes) visit(child);
|
||||
flush();
|
||||
let first = -1;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (/^(?:来源|文章来源|本文来源|图文来源|文字来源|作者|作者信息|来源作者|转自|转载自|出处)\s*[::]\s*\S/.test(lines[i].text)) {
|
||||
first = i;
|
||||
} else if (!/^(?:如有侵权|若有侵权|侵权请|转载请|版权归)/.test(lines[i].text)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (first < 0) return null;
|
||||
const start = lines[first].parts[0];
|
||||
const range = body.ownerDocument.createRange();
|
||||
range.setStart(start.node, start.start);
|
||||
range.setEnd(body, body.childNodes.length);
|
||||
return {
|
||||
text: lines.slice(first).map(line => line.text).join('\n'),
|
||||
remove: () => range.deleteContents()
|
||||
};
|
||||
}
|
||||
async function runFormat(button) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await ensureVisualEditor();
|
||||
const body = editorBody();
|
||||
const source = existingSource(body);
|
||||
const hasText = cleanText(body.textContent).replace(source ? cleanText(source.text) : '', '').trim();
|
||||
if (!hasText && !body.querySelector('img,video,audio,iframe,hr,table')) {
|
||||
throw new Error('正文为空,请先输入或粘贴文章。');
|
||||
}
|
||||
const raw = source ? '' : prompt('未在文末检测到来源/作者信息,请补充来源公众号名称(取消则停止排版):');
|
||||
if (raw === null) return;
|
||||
const author = cleanText(raw);
|
||||
if (!source && !author) throw new Error('来源公众号名称不能为空。');
|
||||
button.textContent = '正在排版…';
|
||||
// 已有来源保留原文,仅调整字号颜色;没有时才生成来源行。
|
||||
source?.remove();
|
||||
formatBody(body, author, source?.text ?? null);
|
||||
alert(`排版完成。\n正文:4号黑色;来源:1号银色。\n${source ? source.text : `来源:${author}`}\n请人工检查后自行决定是否保存或发布。`);
|
||||
} catch (error) { alert('排版未完成:' + error.message); }
|
||||
finally {
|
||||
setBusy(false);
|
||||
button.textContent = '一键排版';
|
||||
}
|
||||
}
|
||||
async function run(button) {
|
||||
if (busy) return;
|
||||
const raw = prompt('粘贴微信公众号文章 URL:');
|
||||
if (raw === null || !raw.trim()) return;
|
||||
setBusy(true);
|
||||
let watcher;
|
||||
try {
|
||||
const url = normalizeURL(raw);
|
||||
const jq = unsafeWindow.myJQ;
|
||||
if (typeof jq !== 'function' || !jq.fn?.on) throw new Error('没有找到网站微信采集器的 myJQ,请刷新页面后重试。');
|
||||
const plugin = document.querySelector('#e_controls .wxcj');
|
||||
if (!plugin) throw new Error('当前页面没有微信采集组件。');
|
||||
button.textContent = '读取标题和作者…';
|
||||
const info = await getMetadata(url);
|
||||
if (!info) return;
|
||||
const subject = document.querySelector('#subject');
|
||||
if (!subject) throw new Error('没有找到标题框。');
|
||||
if (subject.maxLength > 0 && info.title.length > subject.maxLength) throw new Error('完整标题超过标题框长度限制,请先处理标题长度。');
|
||||
await ensureVisualEditor();
|
||||
button.textContent = '采集文章中…';
|
||||
plugin.click();
|
||||
const input = await waitFor(() => Array.from(document.querySelectorAll('.wxjc-dialog input')).find(visible));
|
||||
const submit = input.closest('.wxjc-dialog').querySelector('.wxjc-subimt');
|
||||
if (!submit) throw new Error('没有找到采集按钮。');
|
||||
input.value = url;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
watcher = watchImport(jq, url);
|
||||
// 先登记失败处理,避免点击失败时留下未处理的 Promise。
|
||||
watcher.result.catch(() => {});
|
||||
submit.click();
|
||||
await watcher.result;
|
||||
button.textContent = '正在排版…';
|
||||
const body = await waitFor(() => editorBody()?.innerHTML.trim() && editorBody());
|
||||
formatBody(body, info.author);
|
||||
subject.value = info.title;
|
||||
subject.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
subject.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
subject.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
|
||||
// 正文保持在可视化编辑器,由网站正常提交流程转成 BBCode。
|
||||
// 不把 HTML 写入纯文本 message,也不调用任何保存/发布入口。
|
||||
alert(`导入并排版完成。\n标题:${info.title}\n作者:${info.author}\n正文:4号黑色;来源:1号银色。\n请人工检查后自行决定是否保存或发布。`);
|
||||
} catch (error) { alert('导入未完成:' + error.message); }
|
||||
finally {
|
||||
watcher?.cleanup();
|
||||
setBusy(false);
|
||||
button.textContent = '一键导入微信公众号并排版';
|
||||
}
|
||||
}
|
||||
function mount() {
|
||||
const toolbar = document.querySelector('#e_controls');
|
||||
if (!toolbar || !document.querySelector('#subject')) return;
|
||||
const style = 'position:relative;z-index:2;padding:0 12px;height:32px;line-height:30px;border:1px solid #159447;border-radius:4px;background:#159447;color:white;font-size:13px;cursor:pointer;';
|
||||
// 右浮动的同一组内使用 flex,保证排版按钮始终位于导入按钮左侧。
|
||||
let group = document.getElementById('dww11-wx-actions');
|
||||
if (!group) {
|
||||
group = document.createElement('span');
|
||||
group.id = 'dww11-wx-actions';
|
||||
group.style.cssText = 'float:right;display:inline-flex;align-items:center;gap:8px;margin:6px 8px;';
|
||||
toolbar.append(group);
|
||||
}
|
||||
if (!document.getElementById(FORMAT_ID)) {
|
||||
const button = document.createElement('button');
|
||||
button.id = FORMAT_ID; button.type = 'button';
|
||||
button.textContent = '一键排版'; button.style.cssText = style;
|
||||
button.disabled = busy;
|
||||
button.addEventListener('click', () => runFormat(button));
|
||||
group.prepend(button);
|
||||
}
|
||||
if (toolbar.querySelector('.wxcj') && !document.getElementById(ID)) {
|
||||
const button = document.createElement('button');
|
||||
button.id = ID; button.type = 'button';
|
||||
button.textContent = '一键导入微信公众号并排版'; button.style.cssText = style;
|
||||
button.disabled = busy;
|
||||
button.addEventListener('click', () => run(button));
|
||||
group.append(button);
|
||||
}
|
||||
}
|
||||
mount();
|
||||
const observer = new MutationObserver(mount);
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
})();
|
||||
@@ -0,0 +1,195 @@
|
||||
// Extracted from dww11-wechat-oneclick.user.js v1.0.9; package source is authoritative.
|
||||
(() => {
|
||||
'use strict';
|
||||
const cleanText = value => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
function flattenParagraphs(body) {
|
||||
const selector = 'p,div,section,article,figure,figcaption,h1,h2,h3,h4,h5,h6';
|
||||
const marker = () => {
|
||||
const br = body.ownerDocument.createElement('br');
|
||||
br.dataset.dww11Boundary = '1'; return br;
|
||||
};
|
||||
for (const block of Array.from(body.querySelectorAll(selector)).reverse()) {
|
||||
if (block.closest('table,ul,ol,blockquote')) continue;
|
||||
block.replaceWith(marker(), ...block.childNodes, marker());
|
||||
}
|
||||
let previous = false, pending = [];
|
||||
function finish() {
|
||||
const actual = pending.filter(br => !br.hasAttribute('data-dww11-boundary'));
|
||||
let retained = false;
|
||||
for (const br of pending) {
|
||||
if (!br.hasAttribute('data-dww11-boundary')) continue;
|
||||
if (previous && !actual.length && !retained) { br.removeAttribute('data-dww11-boundary'); retained = true; }
|
||||
else br.remove();
|
||||
}
|
||||
pending = [];
|
||||
}
|
||||
const walker = body.ownerDocument.createTreeWalker(body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
|
||||
const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||
for (const node of nodes) {
|
||||
if (node.nodeType === 1 && node.tagName === 'BR') pending.push(node);
|
||||
else if ((node.nodeType === 3 && node.textContent.trim()) || (node.nodeType === 1 && ['IMG', 'VIDEO', 'HR', 'IFRAME'].includes(node.tagName))) {
|
||||
finish(); previous = true;
|
||||
}
|
||||
}
|
||||
// 末尾不保留由容器边界制造的额外换行。
|
||||
for (const br of pending) if (br.hasAttribute('data-dww11-boundary')) br.remove();
|
||||
}
|
||||
function spaceImages(body) {
|
||||
let previous = null, gapNodes = [], blankLines = 0;
|
||||
const changes = [];
|
||||
function content(kind, node) {
|
||||
if (previous && previous.kind !== kind) {
|
||||
let anchor = node;
|
||||
while (anchor.parentElement && anchor.parentElement !== body &&
|
||||
!['TD', 'TH', 'LI', 'BLOCKQUOTE'].includes(anchor.parentElement.tagName) && !anchor.parentElement.contains(previous.node)) anchor = anchor.parentElement;
|
||||
changes.push({ anchor, nodes: gapNodes.slice(), count: Math.max(2, blankLines) });
|
||||
}
|
||||
previous = { kind, node }; gapNodes = []; blankLines = 0;
|
||||
}
|
||||
function visit(node) {
|
||||
if (node.nodeType === 3) {
|
||||
if (node.textContent.trim()) content('text', node); else gapNodes.push(node);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== 1 || ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(node.tagName)) return;
|
||||
if (node.tagName === 'BR') { gapNodes.push(node); blankLines++; return; }
|
||||
if (node.tagName === 'IMG') { content('image', node); return; }
|
||||
for (const child of node.childNodes) visit(child);
|
||||
}
|
||||
for (const child of body.childNodes) visit(child);
|
||||
for (const { anchor, nodes, count } of changes) {
|
||||
for (const node of nodes) node.remove();
|
||||
const fragment = body.ownerDocument.createDocumentFragment();
|
||||
for (let i = 0; i < count; i++) fragment.append(body.ownerDocument.createElement('br'));
|
||||
anchor.parentNode.insertBefore(fragment, anchor);
|
||||
}
|
||||
}
|
||||
function formatBody(body, author, sourceText = null) {
|
||||
const doc = body.ownerDocument;
|
||||
body.querySelectorAll('[data-dww11-wx-source="1"]').forEach(el => el.remove());
|
||||
// Discuz 会把内联 color 值直接转为 [color=…]。
|
||||
// !important 不能出现在该值中;颜色和字号只用原生 FONT 属性。
|
||||
for (const el of [body, ...body.querySelectorAll('*')]) {
|
||||
if (['SCRIPT', 'STYLE'].includes(el.tagName)) continue;
|
||||
for (const property of ['font', 'font-size', 'color', '-webkit-text-fill-color']) {
|
||||
el.style.removeProperty(property);
|
||||
}
|
||||
// 其他排版属性也不保留 CSS 优先级,避免它泄漏进论坛标签。
|
||||
for (const property of Array.from(el.style)) {
|
||||
if (el.style.getPropertyPriority(property)) {
|
||||
el.style.setProperty(property, el.style.getPropertyValue(property));
|
||||
}
|
||||
}
|
||||
if (!el.style.cssText) el.removeAttribute('style');
|
||||
}
|
||||
// 去掉采集器或旧版脚本生成的嵌套 FONT,保留其中的内容和图片。
|
||||
for (const font of Array.from(body.querySelectorAll('font'))) {
|
||||
font.replaceWith(...font.childNodes);
|
||||
}
|
||||
flattenParagraphs(body);
|
||||
spaceImages(body);
|
||||
// 所有正文内容和换行放在同一 FONT 中。不能使用只有 BR 的空 FONT:
|
||||
// 论坛转换器可能清除空格式标签,导致空行退回默认1号或直接丢失。
|
||||
const articleFont = doc.createElement('font');
|
||||
articleFont.size = '4'; articleFont.color = '#000000';
|
||||
articleFont.dataset.dww11WxBody = '1';
|
||||
articleFont.append(...body.childNodes);
|
||||
body.append(articleFont);
|
||||
// A 自身的浏览器默认蓝色会覆盖外层继承色,链接文字另设黑色。
|
||||
for (const link of articleFont.querySelectorAll('a')) {
|
||||
const linkFont = doc.createElement('font');
|
||||
linkFont.size = '4'; linkFont.color = '#000000';
|
||||
linkFont.append(...link.childNodes); link.append(linkFont);
|
||||
}
|
||||
// 只用于编辑器图片适配;帖子展示仍交给网站原有图片/附件规则。
|
||||
for (const img of articleFont.querySelectorAll('img')) {
|
||||
img.style.maxWidth = '100%'; img.style.height = 'auto';
|
||||
}
|
||||
const source = doc.createElement('p');
|
||||
source.dataset.dww11WxSource = '1';
|
||||
const font = doc.createElement('font');
|
||||
font.size = '1'; font.color = '#c0c0c0';
|
||||
const lines = (sourceText ?? `来源:${author},如有侵权,请联系删除。`).split('\n');
|
||||
lines.forEach((line, index) => {
|
||||
if (index) font.append(doc.createElement('br'));
|
||||
font.append(doc.createTextNode(line));
|
||||
});
|
||||
source.append(font); body.append(source);
|
||||
}
|
||||
function existingSource(body) {
|
||||
// 按段落和 BR 读取末尾逻辑行,兼容嵌套 FONT、纯文本及末尾空段落。
|
||||
const lines = [];
|
||||
let parts = [];
|
||||
function flush() {
|
||||
const text = cleanText(parts.map(part => part.node.textContent.slice(part.start, part.end)).join(''));
|
||||
if (text) lines.push({ text, parts });
|
||||
parts = [];
|
||||
}
|
||||
function visit(node) {
|
||||
if (node.nodeType === 3) {
|
||||
// 保留文本中的显式换行,并记录精确位置,避免删除同容器中的正文。
|
||||
const value = node.textContent;
|
||||
let start = 0;
|
||||
for (let i = 0; i <= value.length; i++) {
|
||||
if (i !== value.length && value[i] !== '\n') continue;
|
||||
// HTML 缩进换行通常只是空白;只有后续是来源或版权行才分行。
|
||||
if (i < value.length && !/^\s*(?:(?:来源|文章来源|本文来源|图文来源|文字来源|作者|作者信息|来源作者|转自|转载自|出处)\s*[::]|如有侵权|若有侵权|侵权请|转载请|版权归)/.test(value.slice(i + 1))) continue;
|
||||
if (i > start) parts.push({ node, start, end: i });
|
||||
if (i < value.length) flush();
|
||||
start = i + 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== 1 || ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(node.tagName)) return;
|
||||
if (node.tagName === 'BR') { flush(); return; }
|
||||
if (['IMG', 'VIDEO', 'AUDIO', 'IFRAME', 'HR'].includes(node.tagName)) {
|
||||
flush(); lines.push({ text: '\ufffc', parts: [] }); return;
|
||||
}
|
||||
const block = /^(P|DIV|SECTION|ARTICLE|FIGURE|FIGCAPTION|H[1-6]|LI|TR|BLOCKQUOTE|PRE)$/.test(node.tagName);
|
||||
if (block) flush();
|
||||
for (const child of node.childNodes) visit(child);
|
||||
if (block) flush();
|
||||
}
|
||||
for (const child of body.childNodes) visit(child);
|
||||
flush();
|
||||
let first = -1;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (/^(?:来源|文章来源|本文来源|图文来源|文字来源|作者|作者信息|来源作者|转自|转载自|出处)\s*[::]\s*\S/.test(lines[i].text)) {
|
||||
first = i;
|
||||
} else if (!/^(?:如有侵权|若有侵权|侵权请|转载请|版权归)/.test(lines[i].text)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (first < 0) return null;
|
||||
const start = lines[first].parts[0];
|
||||
const range = body.ownerDocument.createRange();
|
||||
range.setStart(start.node, start.start);
|
||||
range.setEnd(body, body.childNodes.length);
|
||||
return {
|
||||
text: lines.slice(first).map(line => line.text).join('\n'),
|
||||
remove: () => range.deleteContents()
|
||||
};
|
||||
}
|
||||
|
||||
window.DWW11Formatter = Object.freeze({
|
||||
format(expectedSource) {
|
||||
if (Number(window.wysiwyg) !== 1) throw new Error('请切换到可视化编辑器后排版');
|
||||
const body = document.querySelector('#e_iframe')?.contentDocument?.body;
|
||||
if (!body) throw new Error('编辑器未就绪');
|
||||
const footer = existingSource(body);
|
||||
const expected = `来源:${expectedSource},如有侵权,请联系删除。`;
|
||||
if (!footer || footer.text !== expected) throw new Error('文末来源与文章包不一致,请人工检查');
|
||||
const images = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src'));
|
||||
const backup = body.innerHTML;
|
||||
try {
|
||||
footer.remove();
|
||||
formatBody(body, expectedSource, expected);
|
||||
const after = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src'));
|
||||
if (JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变');
|
||||
if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常');
|
||||
body.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
return {images: after, source: expected};
|
||||
} catch (error) { body.innerHTML = backup; throw error; }
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,480 @@
|
||||
// ==UserScript==
|
||||
// @name 广谈大湾网 · 本地文章目录导入
|
||||
// @namespace local.dww11.article-importer
|
||||
// @version 1.0.0
|
||||
// @description 选择整个文章目录,导入 Markdown 标题、正文与本地配图;保留人工发布与程序接口。
|
||||
// @match https://www.dww11.com/forum.php*
|
||||
// @match https://dww11.com/forum.php*
|
||||
// @run-at document-idle
|
||||
// @grant none
|
||||
// @noframes
|
||||
// @license MIT
|
||||
// ==/UserScript==
|
||||
|
||||
(function (root, factory) {
|
||||
'use strict';
|
||||
const core = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = core;
|
||||
else core.mount(root);
|
||||
})(typeof window === 'undefined' ? null : window, function () {
|
||||
'use strict';
|
||||
const VERSION = '1.0.0';
|
||||
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
|
||||
const IMAGE_EXT = /\.(?:jpe?g|png|gif)$/i;
|
||||
|
||||
function normalizePath(path) {
|
||||
const parts = [];
|
||||
const value = String(path).replace(/\\/g, '/').normalize('NFC');
|
||||
if (/^(?:\/|[a-z]:|[a-z]+:)/i.test(value)) throw new Error('请使用目录内的相对路径:' + path);
|
||||
for (const part of value.split('/')) {
|
||||
if (!part || part === '.') continue;
|
||||
if (part === '..') {
|
||||
if (!parts.length) throw new Error('图片路径超出所选目录:' + path);
|
||||
parts.pop();
|
||||
} else parts.push(part);
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function imagePath(mdPath, target) {
|
||||
if (/^(?:[a-z][\w+.-]*:|\/\/|\/)/i.test(target)) throw new Error('图片必须在所选目录内,请先下载:' + target);
|
||||
let decoded;
|
||||
try { decoded = decodeURIComponent(target); } catch { throw new Error('图片路径编码无效:' + target); }
|
||||
return normalizePath(mdPath.slice(0, mdPath.lastIndexOf('/') + 1) + decoded);
|
||||
}
|
||||
|
||||
// A small, intentionally bounded Markdown reader. Raw HTML is never executed.
|
||||
// Links/images support balanced parentheses, angle destinations and optional titles.
|
||||
function readLink(text, start) {
|
||||
const image = text.startsWith('![', start);
|
||||
if (!image && text[start] !== '[') return null;
|
||||
const left = start + (image ? 2 : 1);
|
||||
let end = left;
|
||||
for (; end < text.length; end++) {
|
||||
if (text[end] === '\\') { end++; continue; }
|
||||
if (text[end] === ']') break;
|
||||
}
|
||||
if (text.slice(end, end + 2) !== '](') return null;
|
||||
let pos = end + 2, depth = 1, quoted = '', angle = false;
|
||||
for (; pos < text.length; pos++) {
|
||||
const ch = text[pos];
|
||||
if (ch === '\\') { pos++; continue; }
|
||||
if (quoted) { if (ch === quoted) quoted = ''; continue; }
|
||||
if (!angle && depth === 1 && /\s/.test(text[pos - 1]) && (ch === '"' || ch === "'")) { quoted = ch; continue; }
|
||||
if (ch === '<') { angle = true; continue; }
|
||||
if (ch === '>') { angle = false; continue; }
|
||||
if (!angle && ch === '(') depth++;
|
||||
if (!angle && ch === ')' && --depth === 0) break;
|
||||
}
|
||||
if (depth) return null;
|
||||
let target = text.slice(end + 2, pos).trim();
|
||||
const destination = target.match(/^<([^>]+)>\s*(?:["'][\s\S]*["'])?$/);
|
||||
target = destination ? destination[1] : target.replace(/\s+["'][\s\S]*["']\s*$/, '');
|
||||
target = target.replace(/\\([()\\ ])/g, '$1');
|
||||
return { image, label: text.slice(left, end), target, end: pos + 1 };
|
||||
}
|
||||
|
||||
function inline(text, onImage) {
|
||||
let out = '';
|
||||
for (let pos = 0; pos < text.length;) {
|
||||
if (text[pos] === '\\' && pos + 1 < text.length) { out += text[pos + 1]; pos += 2; continue; }
|
||||
const link = readLink(text, pos);
|
||||
if (link) {
|
||||
if (link.image) out += onImage(link.target, link.label);
|
||||
else if (/^(https?:\/\/|mailto:)/i.test(link.target) && !/[\[\]\r\n"']/.test(link.target)) {
|
||||
out += '[url=' + link.target + ']' + inline(link.label, onImage) + '[/url]';
|
||||
} else out += link.label + ' (' + link.target + ')';
|
||||
pos = link.end;
|
||||
continue;
|
||||
}
|
||||
if (text[pos] === '`') {
|
||||
const end = text.indexOf('`', pos + 1);
|
||||
if (end > pos + 1) { out += text.slice(pos + 1, end); pos = end + 1; continue; }
|
||||
}
|
||||
let matched = false;
|
||||
for (const [mark, tag] of [['**', 'b'], ['__', 'b'], ['~~', 's'], ['*', 'i'], ['_', 'i']]) {
|
||||
if (!text.startsWith(mark, pos) || (mark === '_' && /\w/.test(text[pos - 1] || ''))) continue;
|
||||
const end = text.indexOf(mark, pos + mark.length);
|
||||
if (end <= pos + mark.length) continue;
|
||||
out += '[' + tag + ']' + inline(text.slice(pos + mark.length, end), onImage) + '[/' + tag + ']';
|
||||
pos = end + mark.length; matched = true; break;
|
||||
}
|
||||
if (!matched) out += text[pos++];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function markdownToBBCode(markdown, onImage) {
|
||||
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
|
||||
const output = [];
|
||||
let list = '', quote = false, code = false, fence = '';
|
||||
const closeList = () => { if (list) output.push('[/list]'); list = ''; };
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index];
|
||||
const codeMark = line.match(/^\s*(`{3,}|~{3,})/);
|
||||
if (codeMark && (!code || codeMark[1][0] === fence)) {
|
||||
closeList();
|
||||
output.push(code ? '[/code]' : '[code]');
|
||||
code = !code; fence = codeMark[1][0]; continue;
|
||||
}
|
||||
if (code) { output.push(line.replace(/\[\/code\]/gi, '[/code]')); continue; }
|
||||
const quoted = /^\s*>\s?/.test(line);
|
||||
if (quoted !== quote) { closeList(); output.push(quoted ? '[quote]' : '[/quote]'); quote = quoted; }
|
||||
const content = quoted ? line.replace(/^\s*>\s?/, '') : line;
|
||||
const item = content.match(/^\s*(?:([-+*])|\d+[.)])\s+(.+)$/);
|
||||
if (item) {
|
||||
const type = item[1] ? '[list]' : '[list=1]';
|
||||
if (list !== type) { closeList(); output.push(type); list = type; }
|
||||
output.push('[*]' + inline(item[2], onImage)); continue;
|
||||
}
|
||||
closeList();
|
||||
const heading = content.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*$/);
|
||||
if (heading) output.push('[b]' + inline(heading[2], onImage) + '[/b]');
|
||||
else if (/^\s*(?:---+|\*\*\*+|___+)\s*$/.test(content)) output.push('[hr]');
|
||||
else output.push(inline(content, onImage));
|
||||
}
|
||||
closeList();
|
||||
if (quote) output.push('[/quote]');
|
||||
if (code) output.push('[/code]');
|
||||
return output.join('\n').trim();
|
||||
}
|
||||
|
||||
function compile(markdown, mdPath, fileMap, titleOverride) {
|
||||
let body = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
|
||||
const firstHeading = body.match(/^\s*#\s+([^\n]+)\n?/);
|
||||
const title = String(titleOverride || firstHeading?.[1] || mdPath.split('/').pop().replace(/\.md$/i, '')).trim();
|
||||
if (firstHeading) body = body.slice(firstHeading[0].length).trim();
|
||||
if (!title || Array.from(title).length > 80) throw new Error('标题为空或超过 80 字,请修改 Markdown 标题后重试。');
|
||||
if (!body.trim()) throw new Error('正文为空。');
|
||||
const warnings = [];
|
||||
if (/<\/?[a-z][^>]*>/i.test(body)) warnings.push('存在 HTML,将作为文字保留;请检查排版。');
|
||||
if (/^\s*\|.*\|\s*$/m.test(body)) warnings.push('表格暂按原文保留,需手动检查。');
|
||||
if (/!\[[^\]]*\]\s*\[[^\]]*\]/.test(body) || /^\s*\[[^\]]+\]:/m.test(body)) {
|
||||
throw new Error('暂不支持引用式 Markdown 图片/链接,请改成  或 [文字](网址)。');
|
||||
}
|
||||
const images = new Map();
|
||||
// Validate every image before any network request; repeated references upload once.
|
||||
markdownToBBCode(body, (target) => {
|
||||
const path = imagePath(mdPath, target);
|
||||
const file = fileMap.get(path);
|
||||
if (!file) throw new Error('找不到配图:' + path);
|
||||
if (!IMAGE_EXT.test(path)) throw new Error('网站仅支持 JPG、JPEG、PNG、GIF:' + path);
|
||||
if (!file.size || file.size >= MAX_IMAGE_SIZE) throw new Error('配图为空或达到 20 MB:' + path);
|
||||
images.set(path, file);
|
||||
return '[图片:' + path + ']';
|
||||
});
|
||||
return { title, body, mdPath, images, warnings,
|
||||
render(aids) {
|
||||
return markdownToBBCode(body, target => {
|
||||
const path = imagePath(mdPath, target);
|
||||
const aid = aids.get(path);
|
||||
if (!/^[1-9]\d*$/.test(String(aid))) throw new Error('配图尚未上传:' + path);
|
||||
return '[attachimg]' + aid + '[/attachimg]';
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareFiles(files, markdownPath) {
|
||||
const fileMap = new Map();
|
||||
for (const file of Array.from(files)) {
|
||||
const path = normalizePath(file.webkitRelativePath || file.name);
|
||||
if (fileMap.has(path)) throw new Error('文件路径重复:' + path);
|
||||
fileMap.set(path, file);
|
||||
}
|
||||
const candidates = Array.from(fileMap.keys()).filter(p => /\.md$/i.test(p)).sort();
|
||||
if (!candidates.length) throw new Error('所选目录中没有 .md 文件。');
|
||||
if (!markdownPath && candidates.length > 1) return { candidates, files: Array.from(files) };
|
||||
const mdPath = markdownPath || candidates[0];
|
||||
const md = fileMap.get(mdPath);
|
||||
if (!md) throw new Error('指定的 Markdown 文件不存在。');
|
||||
return compile(await md.text(), mdPath, fileMap);
|
||||
}
|
||||
|
||||
async function preparePackage(data) {
|
||||
if (!data || data.schemaVersion !== 1 || typeof data.markdown !== 'string' || !Array.isArray(data.images)) {
|
||||
throw new Error('文章包格式错误,需要 schemaVersion: 1、markdown 和 images。');
|
||||
}
|
||||
const map = new Map();
|
||||
for (const item of data.images) {
|
||||
const path = normalizePath(item.path);
|
||||
if (map.has(path)) throw new Error('文章包图片路径重复:' + path);
|
||||
let blob = item.blob;
|
||||
if (!blob && typeof item.base64 === 'string') {
|
||||
const binary = atob(item.base64);
|
||||
blob = new Blob([Uint8Array.from(binary, ch => ch.charCodeAt(0))], { type: item.mime || 'application/octet-stream' });
|
||||
}
|
||||
if (!blob || typeof blob.arrayBuffer !== 'function') throw new Error('文章包缺少图片数据:' + path);
|
||||
map.set(path, blob);
|
||||
}
|
||||
return compile(data.markdown, normalizePath(data.markdownPath || 'article.md'), map, data.title);
|
||||
}
|
||||
|
||||
function parseAid(text) {
|
||||
const value = String(text).trim();
|
||||
// The site's actual WebUploader adapter uses a positive integer response.
|
||||
if (!/^(?:[1-9]\d*|"[1-9]\d*")$/.test(value)) throw new Error('服务器未返回有效附件编号(可能登录过期、超出权限或上传失败)。');
|
||||
return value.replace(/"/g, '');
|
||||
}
|
||||
|
||||
function mount(win) {
|
||||
const doc = win.document;
|
||||
const params = new URLSearchParams(win.location.search);
|
||||
if (params.get('mod') !== 'post' || params.get('action') !== 'newthread' || !doc.querySelector('#subject') || !doc.querySelector('#e_textarea')) return;
|
||||
if (win.DWW11Importer) return;
|
||||
let article = null, busy = false, candidates = null, lastResult = null, savedBackup = null, stageRevision = 0;
|
||||
const cache = new Map(), urls = [], logs = [];
|
||||
const host = doc.createElement('div');
|
||||
host.id = 'dww11-importer';
|
||||
host.style.cssText = 'position:fixed;right:18px;bottom:18px;z-index:2147483000';
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
shadow.innerHTML = `<style>
|
||||
:host{all:initial}*{box-sizing:border-box}details{font:14px/1.6 system-ui,sans-serif;color:#203044;background:#fff;border:1px solid #d4dee8;border-radius:12px;box-shadow:0 6px 28px #0003;width:360px;max-width:92vw}summary{cursor:pointer;padding:12px 16px;font-weight:650}section{padding:0 16px 16px;max-height:70vh;overflow:auto}button,select{font:inherit;padding:7px 10px;border-radius:6px;border:1px solid #b8c9da;background:#f4f8fc;color:#203044;cursor:pointer;margin:4px 4px 4px 0}button.primary{background:#1767b3;color:white;border-color:#1767b3}button:disabled{opacity:.5;cursor:default}p{margin:8px 0}small{color:#617183}pre{font:12px/1.6 system-ui;white-space:pre-wrap;overflow-wrap:anywhere;max-height:170px;overflow:auto;background:#f4f6f9;padding:8px}img{max-width:90px;max-height:80px;margin:4px;object-fit:contain}.error{color:#b32424}.success{color:#18713e}select{max-width:100%}[hidden]{display:none!important}
|
||||
</style><details open><summary>本地文章导入 · 1.0</summary><section>
|
||||
<small>选择整个文章文件夹,检查后上传配图并填写。发布由你操作。网站自带的自动保存仍可能运行。</small>
|
||||
<p><button id="choose" type="button">选择文章目录</button><button id="package" type="button">导入文章包</button></p>
|
||||
<input id="folder" type="file" webkitdirectory multiple hidden><input id="json" type="file" accept=".json,application/json" hidden>
|
||||
<select id="md" hidden aria-label="选择 Markdown"></select>
|
||||
<p id="status" role="status" aria-live="polite">尚未选择目录</p><div id="preview" hidden><strong id="title"></strong><pre id="body"></pre><div id="images"></div><p id="warnings"></p></div>
|
||||
<button id="import" class="primary" type="button" disabled>上传配图并填写</button>
|
||||
<button id="diagnostics" type="button">导出诊断</button><pre id="log" hidden></pre>
|
||||
</section></details>`;
|
||||
doc.body.appendChild(host);
|
||||
const $ = id => shadow.getElementById(id);
|
||||
const summary = () => ({ version: VERSION, busy, ready: !!article, title: article?.title || null,
|
||||
markdownPath: article?.mdPath || null, imageCount: article?.images.size || 0,
|
||||
warnings: article?.warnings || [], result: lastResult });
|
||||
function notify(phase, message, extra = {}) {
|
||||
$('status').textContent = message;
|
||||
$('status').className = phase === 'error' ? 'error' : phase === 'complete' ? 'success' : '';
|
||||
logs.push({ time: new Date().toISOString(), phase, message, ...extra });
|
||||
$('log').hidden = false;
|
||||
$('log').textContent = logs.slice(-8).map(l => l.message).join('\n');
|
||||
win.dispatchEvent(new CustomEvent('dww11-importer:progress', { detail: { phase, message, ...extra } }));
|
||||
}
|
||||
function releasePreviews() { while (urls.length) URL.revokeObjectURL(urls.pop()); }
|
||||
function clearStage() {
|
||||
article = null; lastResult = null; candidates = null; releasePreviews();
|
||||
$('preview').hidden = true; $('md').hidden = true; $('import').disabled = true;
|
||||
}
|
||||
function showArticle(prepared) {
|
||||
article = prepared; lastResult = null; releasePreviews();
|
||||
$('preview').hidden = false;
|
||||
$('title').textContent = article.title;
|
||||
$('body').textContent = article.body;
|
||||
$('warnings').textContent = article.warnings.join(';');
|
||||
$('images').replaceChildren();
|
||||
for (const [path, blob] of article.images) {
|
||||
const img = doc.createElement('img');
|
||||
img.src = URL.createObjectURL(blob); urls.push(img.src); img.alt = path; img.title = path;
|
||||
$('images').appendChild(img);
|
||||
}
|
||||
$('import').disabled = false;
|
||||
notify('ready', `已检查:${article.images.size} 张图片。点击“上传配图并填写”开始导入。`);
|
||||
return summary();
|
||||
}
|
||||
async function stageFiles(files, options = {}) {
|
||||
if (busy) throw new Error('正在导入,请稍候。');
|
||||
clearStage();
|
||||
const revision = ++stageRevision;
|
||||
const list = Array.from(files);
|
||||
const prepared = await prepareFiles(list, options.markdownPath);
|
||||
if (revision !== stageRevision) return { cancelled: true };
|
||||
if (prepared.candidates) {
|
||||
candidates = prepared.files;
|
||||
$('md').replaceChildren(new Option('请选择目录中的 Markdown 文件', ''));
|
||||
for (const path of prepared.candidates) $('md').appendChild(new Option(path, path));
|
||||
$('md').hidden = false;
|
||||
notify('choose-markdown', '目录中有多个 Markdown 文件,请选择一篇。');
|
||||
return { candidates: prepared.candidates };
|
||||
}
|
||||
return showArticle(prepared);
|
||||
}
|
||||
async function stagePackage(data) {
|
||||
if (busy) throw new Error('正在导入,请稍候。');
|
||||
clearStage();
|
||||
const revision = ++stageRevision;
|
||||
const prepared = await preparePackage(data);
|
||||
if (revision !== stageRevision) return { cancelled: true };
|
||||
return showArticle(prepared);
|
||||
}
|
||||
const snapshot = () => ({ title: doc.querySelector('#subject').value,
|
||||
mode: Number(win.wysiwyg), text: win.getEditorContents() });
|
||||
function assertEditor() {
|
||||
for (const name of ['getEditorContents', 'switchEditor']) {
|
||||
if (typeof win[name] !== 'function') throw new Error('未识别到网站编辑器:' + name + '。请刷新页面后重试。');
|
||||
}
|
||||
if (!win.imgUpload?.settings || typeof win.updateImageList !== 'function') throw new Error('网站图片上传组件尚未就绪,请刷新后重试。');
|
||||
if (Number(win.allowbbcode) === 0 || Number(win.allowimgcode) === 0) throw new Error('当前版块或账号不支持正文格式/图片。');
|
||||
}
|
||||
async function uploadImage(blob, path, settings, signal) {
|
||||
signal?.throwIfAborted();
|
||||
const url = new URL(settings.upload_url, win.location.href);
|
||||
if (url.origin !== win.location.origin || url.pathname !== '/misc.php' || url.searchParams.get('operation') !== 'upload') {
|
||||
throw new Error('上传地址与预期不符,已停止。');
|
||||
}
|
||||
const hash = Array.from(new Uint8Array(await win.crypto.subtle.digest('SHA-256', await blob.arrayBuffer())), n => n.toString(16).padStart(2, '0')).join('');
|
||||
const key = url.href + ':' + settings.post_params.uid + ':' + hash;
|
||||
if (cache.has(key)) { notify('upload', '复用已上传图片:' + path); return cache.get(key); }
|
||||
const data = new FormData();
|
||||
for (const [key, value] of Object.entries(settings.post_params)) data.append(key, String(value));
|
||||
data.set('type', 'image');
|
||||
data.set('filetype', blob.type || (/\.png$/i.test(path) ? 'image/png' : /\.gif$/i.test(path) ? 'image/gif' : 'image/jpeg'));
|
||||
data.append(settings.file_post_name || 'Filedata', blob, path.split('/').pop());
|
||||
const control = new AbortController(), timer = setTimeout(() => control.abort(), 90000);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
signal?.addEventListener('abort', () => control.abort(), { once: true });
|
||||
const response = await win.fetch(url.href, { method: 'POST', body: data, credentials: 'same-origin', redirect: 'error', signal: control.signal });
|
||||
if (!response.ok) throw new Error('图片上传请求失败:HTTP ' + response.status);
|
||||
const aid = parseAid(await response.text());
|
||||
cache.set(key, aid);
|
||||
return aid;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') throw new Error('上传超时;该图片可能已到达服务器,请检查网站图片列表后再重试。');
|
||||
throw error;
|
||||
} finally { clearTimeout(timer); }
|
||||
}
|
||||
async function registerImages(aids, signal) {
|
||||
signal?.throwIfAborted();
|
||||
if (!aids.length) return;
|
||||
const ids = new Set(aids);
|
||||
doc.querySelectorAll('#imgattachlist [id^="image_td_"],#imgattachlist [id^="image_"]').forEach(el => {
|
||||
const id = el.id.match(/^(?:image_td_|image_)(\d+)$/)?.[1]; if (id) ids.add(id);
|
||||
});
|
||||
const ready = () => aids.every(id => doc.getElementById('image_' + id));
|
||||
if (ready()) return;
|
||||
// Use the site's own list loader, which registers attachment form fields too.
|
||||
win.updateImageList(1, Array.from(ids).join('|'));
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { observer.disconnect(); reject(new Error('图片已上传,但网站图片列表加载超时。重试会复用本页已上传图片。')); }, 25000);
|
||||
signal?.addEventListener('abort', () => { clearTimeout(timer); observer.disconnect(); reject(new Error('流程已终止')); }, { once: true });
|
||||
const check = () => { if (ready()) { clearTimeout(timer); observer.disconnect(); resolve(); } };
|
||||
const observer = new MutationObserver(check);
|
||||
observer.observe(doc.body, { childList: true, subtree: true }); check();
|
||||
});
|
||||
}
|
||||
function setEditor(title, bbcode) {
|
||||
win.switchEditor(0);
|
||||
if (Number(win.wysiwyg) !== 0) throw new Error('无法切换编辑器为纯文本模式。');
|
||||
const textarea = doc.querySelector('#e_textarea');
|
||||
textarea.value = bbcode;
|
||||
doc.querySelector('#subject').value = title;
|
||||
for (const el of [textarea, doc.querySelector('#subject')]) {
|
||||
el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
// Let Discuz generate the WYSIWYG content with native attachment metadata.
|
||||
win.switchEditor(1);
|
||||
const switcher = doc.querySelector('#e_switchercheck');
|
||||
if (switcher) switcher.checked = Number(win.wysiwyg) === 0;
|
||||
}
|
||||
async function importPrepared(options = {}) {
|
||||
const { signal, automatic = false } = options;
|
||||
signal?.throwIfAborted();
|
||||
if (busy) throw new Error('正在导入,请勿重复点击。');
|
||||
if (!article) throw new Error('请先选择文章目录或文章包。');
|
||||
assertEditor();
|
||||
for (const id of ['bbcodeoff', 'htmlon']) {
|
||||
if (doc.getElementById(id)?.checked) throw new Error('请先关闭“禁用 Discuz! 代码”和“HTML 代码”,再导入。');
|
||||
}
|
||||
const before = snapshot();
|
||||
if ((before.title.trim() || before.text.replace(/<[^>]*>/g, '').trim() || /<img\b/i.test(before.text)) &&
|
||||
(automatic ? (() => { throw new Error('编辑器已有内容,请人工处理旧稿后重试'); })() : !win.confirm('当前编辑器已有内容。导入将替换标题和正文,是否继续?'))) return { cancelled: true };
|
||||
const selected = article;
|
||||
busy = true;
|
||||
const controls = ['choose', 'package', 'import', 'md'].map($);
|
||||
controls.forEach(el => { el.disabled = true; });
|
||||
// Prevent accidental submission of a partly imported article.
|
||||
const blockSubmit = event => { event.preventDefault(); event.stopImmediatePropagation(); notify('busy', '正在导入,请等待完成后再操作发布。'); };
|
||||
const form = doc.querySelector('#postform');
|
||||
form.addEventListener('submit', blockSubmit, true);
|
||||
const submitButtons = Array.from(form.querySelectorAll('button[type="submit"],button[name],input[type="submit"]'));
|
||||
const previousDisabled = submitButtons.map(el => el.disabled);
|
||||
submitButtons.forEach(el => { el.disabled = true; });
|
||||
let committed = false;
|
||||
try {
|
||||
const aids = new Map();
|
||||
const settings = win.imgUpload.settings;
|
||||
const max = Math.min(MAX_IMAGE_SIZE, Number(settings.file_size_limit) * 1024 || MAX_IMAGE_SIZE);
|
||||
for (const [path, blob] of selected.images) if (blob.size >= max) throw new Error('配图超过当前网站限制:' + path);
|
||||
let index = 0;
|
||||
for (const [path, blob] of selected.images) {
|
||||
notify('upload', `上传 ${++index}/${selected.images.size}:${path}`, { completed: index - 1, total: selected.images.size });
|
||||
aids.set(path, await uploadImage(blob, path, settings, signal));
|
||||
}
|
||||
notify('register', '正在加载网站图片列表…');
|
||||
await registerImages(Array.from(new Set(aids.values())), signal);
|
||||
signal?.throwIfAborted();
|
||||
const now = snapshot();
|
||||
if ((now.title !== before.title || now.text !== before.text) && (automatic ? (() => { throw new Error('上传期间编辑器被修改,已保留当前内容'); })() : !win.confirm('上传期间你修改了编辑器。是否用导入文章替换最新内容?'))) {
|
||||
notify('cancelled', '已保留当前正文;图片已上传,可稍后再次导入。'); return { cancelled: true };
|
||||
}
|
||||
const backup = snapshot();
|
||||
const bbcode = selected.render(aids);
|
||||
// Preserve a local, downloadable snapshot before changing title or body.
|
||||
savedBackup = { title: backup.title, content: backup.text, mode: backup.mode, createdAt: new Date().toISOString() };
|
||||
try {
|
||||
for (const id of ['bbcodeoff', 'htmlon']) {
|
||||
const el = doc.getElementById(id);
|
||||
if (el && id === 'bbcodeoff' && el.checked) throw new Error('请先取消“禁用 Discuz! 代码”,再重试。');
|
||||
if (el && id === 'htmlon' && el.checked) throw new Error('请先关闭“HTML 代码”,再重试。');
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
setEditor(selected.title, bbcode);
|
||||
const raw = win.getEditorContents();
|
||||
if (Number(win.wysiwyg) === 1) {
|
||||
for (const id of new Set(aids.values())) if (!raw.includes('attachimg_' + id)) throw new Error('富文本图片转换未通过检查。');
|
||||
} else if (raw !== bbcode) throw new Error('正文填写校验失败。');
|
||||
if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。');
|
||||
committed = true;
|
||||
} catch (error) {
|
||||
// Restore the exact editor representation saved immediately before filling.
|
||||
win.switchEditor(backup.mode);
|
||||
if (backup.mode) win.editdoc.body.innerHTML = backup.text;
|
||||
else doc.querySelector('#e_textarea').value = backup.text;
|
||||
doc.querySelector('#subject').value = backup.title;
|
||||
const switcher = doc.querySelector('#e_switchercheck'); if (switcher) switcher.checked = !backup.mode;
|
||||
throw error;
|
||||
}
|
||||
lastResult = { title: selected.title, images: Object.fromEntries(aids), completedAt: new Date().toISOString() };
|
||||
notify('complete', `已填写标题、正文和 ${aids.size} 张配图。请检查排版,随后手动发表。`);
|
||||
return lastResult;
|
||||
} catch (error) {
|
||||
notify('error', error.message + (committed ? '' : ' 标题和正文未替换;已上传的图片会保留。'));
|
||||
throw error;
|
||||
} finally {
|
||||
busy = false; form.removeEventListener('submit', blockSubmit, true);
|
||||
submitButtons.forEach((el, index) => { el.disabled = previousDisabled[index]; });
|
||||
controls.forEach(el => { el.disabled = false; });
|
||||
$('import').disabled = !article;
|
||||
}
|
||||
}
|
||||
function downloadJSON(value, filename) {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }));
|
||||
const link = doc.createElement('a'); link.href = url; link.download = filename; link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
// Public v1 interface. Staging is read-only; importPrepared is the explicit upload/fill step.
|
||||
// No postMessage listener, localhost service, publishing endpoint or saved credentials.
|
||||
const api = Object.freeze({ version: VERSION, schemaVersion: 1, stageFiles, stagePackage,
|
||||
getState: summary, openPicker: () => { if (!busy) $('folder').click(); }, importPrepared,
|
||||
getBackup: () => savedBackup });
|
||||
win.DWW11Importer = api;
|
||||
$('choose').onclick = () => { $('folder').value = ''; $('folder').click(); };
|
||||
$('package').onclick = () => { $('json').value = ''; $('json').click(); };
|
||||
const handleError = error => notify('error', error.message || String(error));
|
||||
$('folder').onchange = () => stageFiles($('folder').files).catch(handleError);
|
||||
$('json').onchange = async () => {
|
||||
try { const file = $('json').files[0]; if (file) await stagePackage(JSON.parse(await file.text())); }
|
||||
catch (error) { handleError(error); }
|
||||
};
|
||||
$('md').onchange = () => {
|
||||
const files = candidates, path = $('md').value;
|
||||
if (files && path) stageFiles(files, { markdownPath: path }).catch(handleError);
|
||||
};
|
||||
$('import').onclick = () => importPrepared().catch(handleError);
|
||||
$('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json');
|
||||
win.addEventListener('pagehide', releasePreviews, { once: true });
|
||||
}
|
||||
return { VERSION, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, mount };
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// ==UserScript==
|
||||
// @name 新闻工作台 · 本地连接
|
||||
// @namespace local.dww11.auto-news
|
||||
// @version 0.1.0
|
||||
// @match https://www.dww11.com/*
|
||||
// @match https://dww11.com/*
|
||||
// @match https://house.dww11.com/*
|
||||
// @grant GM_xmlhttpRequest
|
||||
// @grant GM_getValue
|
||||
// @grant GM_setValue
|
||||
// @grant GM_addValueChangeListener
|
||||
// @connect 127.0.0.1
|
||||
// @run-at document-start
|
||||
// @noframes
|
||||
// ==/UserScript==
|
||||
(() => {
|
||||
'use strict';
|
||||
const allowed = new Set(['heartbeat','next','package','event','accept','switch-begin','takeover','stop','current']);
|
||||
let token = GM_getValue('pairToken',''), port = GM_getValue('pairPort',8770);
|
||||
let stopRevision = 0;
|
||||
const signalStop = () => { stopRevision++; document.dispatchEvent(new CustomEvent('dww11:auto-stop')); };
|
||||
GM_addValueChangeListener('halted', (_key, _old, value) => { if (value) signalStop(); });
|
||||
function request(route, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!token) return reject(new Error('请在左下角填入本地工作台的连接码'));
|
||||
GM_xmlhttpRequest({method:'POST',url:`http://127.0.0.1:${port}/bridge/${route}`,
|
||||
headers:{'Content-Type':'application/json','X-Session-Token':token},data:JSON.stringify(body),timeout:60000,
|
||||
onload(response) { try { const value=JSON.parse(response.responseText); if(response.status!==200) throw new Error(value.error || '本地连接失败'); resolve(value); } catch(error) { reject(error); } },
|
||||
onerror:()=>reject(new Error('本地服务未连接,请检查服务与脚本权限')),
|
||||
ontimeout:()=>reject(new Error('本地服务超时,任务已暂停'))});
|
||||
});
|
||||
}
|
||||
document.addEventListener('dww11:local-request', async event => {
|
||||
let message;
|
||||
try {message=JSON.parse(event.detail);} catch {return;}
|
||||
if (!allowed.has(message.route) || typeof message.id !== 'string') return;
|
||||
try {
|
||||
if (message.route==='stop') { GM_setValue('halted',true); signalStop(); }
|
||||
const revision = stopRevision;
|
||||
let value=await request(message.route,message.body || {});
|
||||
if (message.route==='heartbeat') {
|
||||
if (revision !== stopRevision) value = {...value, running:false, reason:'终止后忽略旧连接响应'};
|
||||
GM_setValue('halted',!value.running);
|
||||
if (!value.running) signalStop();
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('dww11:local-response',{detail:JSON.stringify({id:message.id,value})}));
|
||||
} catch(error) {
|
||||
if (message.route==='heartbeat') {GM_setValue('halted',true); signalStop();}
|
||||
document.dispatchEvent(new CustomEvent('dww11:local-response',{detail:JSON.stringify({id:message.id,error:error.message})}));
|
||||
}
|
||||
});
|
||||
function mount() {
|
||||
if (!document.body || document.getElementById('auto-news-connection')) return;
|
||||
const host=document.createElement('div');host.id='auto-news-connection';
|
||||
host.style.cssText='position:fixed;left:16px;bottom:16px;z-index:2147483647';
|
||||
const shadow=host.attachShadow({mode:'closed'});
|
||||
shadow.innerHTML='<style>section{font:13px/1.6 system-ui;background:white;color:#24364a;border:1px solid #ccd7e0;border-radius:10px;padding:12px;width:250px;box-shadow:0 3px 20px #0002}button,input{font:inherit;box-sizing:border-box;padding:7px;margin:4px 0;width:100%}button{cursor:pointer}#halt{background:#ad302d;color:white;border:0;border-radius:6px}small{display:block}</style><section><strong>新闻工作台 · 本地连接</strong><button id="halt">终止流程</button><details><summary>连接设置</summary><label>本地端口<input id="port" type="number" min="1" max="65535"></label><label>连接码<input id="token" type="password" autocomplete="off"></label><button id="save">保存连接设置</button></details><small id="status">请启动本地工作台并配对</small></section>';
|
||||
const $=id=>shadow.getElementById(id);$('port').value=port;
|
||||
$('save').onclick=()=>{
|
||||
const value=Number($('port').value);if(!Number.isInteger(value)||value<1||value>65535){$('status').textContent='端口无效';return;}
|
||||
if($('token').value.trim()) token=$('token').value.trim();port=value;
|
||||
GM_setValue('pairToken',token);GM_setValue('pairPort',port);$('token').value='';$('status').textContent='已保存;服务重启后需要更新连接码';
|
||||
};
|
||||
$('halt').onclick=async()=>{
|
||||
GM_setValue('halted',true);signalStop();$('status').textContent='已终止;在途请求仍可能完成';
|
||||
try {await request('stop',{});} catch(error) {$('status').textContent+='。'+error.message;}
|
||||
};
|
||||
document.addEventListener('dww11:connection-status',event=>{$('status').textContent=String(event.detail).slice(0,250);});
|
||||
document.body.append(host);
|
||||
if(GM_getValue('halted',true)) signalStop();
|
||||
}
|
||||
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',mount,{once:true}); else mount();
|
||||
})();
|
||||
@@ -0,0 +1,173 @@
|
||||
/* Page-side orchestrator. This file never clicks or submits the publication form. */
|
||||
(() => {
|
||||
'use strict';
|
||||
if (window.DWW11NewsRunner) return;
|
||||
window.DWW11NewsRunner = true;
|
||||
const SLOT='auto-news-current';
|
||||
const owner=sessionStorage.getItem('auto-news-owner') || crypto.randomUUID();
|
||||
sessionStorage.setItem('auto-news-owner',owner);
|
||||
let run=null, working=false, halted=true, aborter=null, active=null, tickBusy=false;
|
||||
const pending=new Map();
|
||||
function rpc(route,body={}) {
|
||||
return new Promise((resolve,reject)=>{
|
||||
const id=crypto.randomUUID();
|
||||
const timer=setTimeout(()=>{pending.delete(id);reject(new Error('本地连接无响应'));},65000);
|
||||
pending.set(id,{resolve,reject,timer});
|
||||
document.dispatchEvent(new CustomEvent('dww11:local-request',{detail:JSON.stringify({id,route,body:{owner,...body}})}));
|
||||
});
|
||||
}
|
||||
document.addEventListener('dww11:local-response',event=>{
|
||||
let data;try{data=JSON.parse(event.detail);}catch{return;}
|
||||
const waiter=pending.get(data.id);if(!waiter)return;
|
||||
clearTimeout(waiter.timer);pending.delete(data.id);
|
||||
data.error?waiter.reject(new Error(data.error)):waiter.resolve(data.value);
|
||||
});
|
||||
const status=message=>document.dispatchEvent(new CustomEvent('dww11:connection-status',{detail:message}));
|
||||
document.addEventListener('dww11:auto-stop',()=>{halted=true;aborter?.abort(new Error('流程已终止'));});
|
||||
function account() {
|
||||
const element=document.querySelector('#mumucms_username');
|
||||
if(!element)return null;
|
||||
const name=[...element.childNodes].filter(n=>n.nodeType===Node.TEXT_NODE).map(n=>n.textContent).join('').trim();
|
||||
const links=[element,...element.querySelectorAll('a')].filter(e=>e.href);
|
||||
let uid=null;
|
||||
for(const link of links) {
|
||||
const href=link.href;
|
||||
uid=new URL(href,location.href).searchParams.get('uid') || href.match(/space-uid-(\d+)/)?.[1];
|
||||
if(uid)break;
|
||||
}
|
||||
return uid&&name?{uid,name}:null;
|
||||
}
|
||||
function extension() {
|
||||
try{return JSON.parse(document.querySelector('#dww11-extension-panel')?.getAttribute('data-auto-news-state') || '{}');}catch{return {};}
|
||||
}
|
||||
function assertLive(epoch) {
|
||||
if(halted||!run?.running||run.epoch!==epoch)throw new Error('流程已暂停或终止');
|
||||
if(active?.account?.uid!==account()?.uid)throw new Error('当前账号与任务不一致');
|
||||
}
|
||||
function editorSnapshot() {
|
||||
const body=document.querySelector('#e_iframe')?.contentDocument?.body;
|
||||
if(!body)throw new Error('找不到可视化编辑器');
|
||||
return {title:document.querySelector('#subject')?.value || '',
|
||||
images:[...body.querySelectorAll('img')].map(x=>({id:x.id,src:x.getAttribute('src')})),
|
||||
// Snapshot only final submitted content; never includes hidden tokens or the full form.
|
||||
html:body.innerHTML.slice(0,500000), text:body.innerText.slice(0,100000)};
|
||||
}
|
||||
function saveLocal(value) {sessionStorage.setItem(SLOT,JSON.stringify(value));}
|
||||
function loadLocal(){try{return JSON.parse(sessionStorage.getItem(SLOT)||'null');}catch{return null;}}
|
||||
async function event(state,extra={}) {return rpc('event',{id:active.id,state,epoch:run.epoch,...extra});}
|
||||
async function fail(error) {
|
||||
status(error.message);
|
||||
if(active)try{await event('needs_attention',{error:error.message});}catch{}
|
||||
halted=true;aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));
|
||||
}
|
||||
function editorPage(job) {
|
||||
const u=new URL(location.href);
|
||||
return u.searchParams.get('mod')==='post'&&u.searchParams.get('action')==='newthread'&&u.searchParams.get('fid')===String(job.fid);
|
||||
}
|
||||
async function prepare(job) {
|
||||
working=true;active=job;aborter=new AbortController();const epoch=run.epoch;
|
||||
try {
|
||||
assertLive(epoch);
|
||||
if(!editorPage(job)) {
|
||||
saveLocal({id:job.id});
|
||||
location.assign(`https://www.dww11.com/forum.php?mod=post&action=newthread&fid=${job.fid}`);return;
|
||||
}
|
||||
if(!window.DWW11Importer||!window.DWW11Formatter)throw new Error('导入或排版模块尚未就绪,请检查安装');
|
||||
status('正在检查文章包…');
|
||||
const pack=await rpc('package',{id:job.id});assertLive(epoch);
|
||||
const prepared=await window.DWW11Importer.stagePackage(pack);assertLive(epoch);
|
||||
if(prepared.warnings?.length)throw new Error('文章需要人工处理:'+prepared.warnings.join(';'));
|
||||
await event('importing');assertLive(epoch);status('上传图片并填写文章…');
|
||||
const result=await window.DWW11Importer.importPrepared({automatic:true,signal:aborter.signal});assertLive(epoch);
|
||||
if(result?.cancelled)throw new Error('导入已取消');
|
||||
await event('formatting');assertLive(epoch);
|
||||
window.DWW11Formatter.format(job.source);assertLive(epoch);
|
||||
const snapshot=editorSnapshot();
|
||||
if(snapshot.title!==job.title)throw new Error('标题校验失败');
|
||||
const aids=new Set(Object.values(result.images));
|
||||
for(const aid of aids) if(!snapshot.images.some(x=>x.id==='attachimg_'+aid))throw new Error('配图附件缺失:'+aid);
|
||||
await event('awaiting_user_review',{snapshot});assertLive(epoch);
|
||||
active={...job,state:'awaiting_user_review'};saveLocal({id:job.id});
|
||||
status('已完成排版,请核对内容并亲手点击“发表帖子”。');
|
||||
}catch(error){await fail(error);}finally{working=false;}
|
||||
}
|
||||
// Record only trusted user activation. Never replay a click, call submit(), or send a post request.
|
||||
document.addEventListener('click',event=>{
|
||||
if(!event.isTrusted||!event.target.closest?.('#postsubmit')||active?.state!=='awaiting_user_review')return;
|
||||
try {
|
||||
const snapshot=editorSnapshot();
|
||||
const local={id:active.id,snapshot,clickedAt:Date.now(),observed:false};saveLocal(local);
|
||||
rpc('event',{id:active.id,state:'user_submitting',epoch:run?.epoch,snapshot})
|
||||
.then(()=>{const latest=loadLocal();if(latest?.clickedAt===local.clickedAt)saveLocal({...local,observed:true});})
|
||||
.catch(error=>status('提交记录待恢复:'+error.message));
|
||||
active.state='user_submitting';status('已观察到人工点击,等待结果页核对。');
|
||||
}catch(error){status('提交快照记录失败:'+error.message);}
|
||||
},true);
|
||||
async function observeResult(job) {
|
||||
const local=loadLocal();
|
||||
if(local?.id===job.id&&local.snapshot&&job.state==='awaiting_user_review') {
|
||||
await rpc('event',{id:job.id,state:'user_submitting',epoch:run.epoch,snapshot:local.snapshot});
|
||||
job.state='user_submitting';
|
||||
}
|
||||
if(job.state==='user_submitting') {
|
||||
// Result selectors intentionally await user-provided HTML; URL alone is not proof.
|
||||
if(!editorPage(job) || (local?.clickedAt && Date.now()-local.clickedAt>45000)) {
|
||||
await rpc('event',{id:job.id,state:'submit_unknown',error:'等待结果页 HTML 适配;候选页面:'+location.href});
|
||||
await rpc('stop');
|
||||
status('已记录候选结果页;尚不能自动核对。流程已暂停,请补充结果页 HTML。');
|
||||
}
|
||||
}else if(job.state==='submit_unknown')status('发布结果待核对;不会重发、计数或换号。');
|
||||
}
|
||||
async function switchAccount() {
|
||||
if(working||halted)return;
|
||||
if(location.origin!=='https://www.dww11.com'||location.pathname!=='/') {
|
||||
location.assign('https://www.dww11.com/');return;
|
||||
}
|
||||
const ext=extension(),button=document.querySelector('#dww11-extension-next');
|
||||
if(!ext.ready||!ext.idle||!button||button.disabled) {status('请安装并重新加载带联动接口的换号插件');return;}
|
||||
await rpc('switch-begin');
|
||||
if(halted||!run?.running)return;
|
||||
button.click();status('正在换号;等待插件完成并核对新账号');
|
||||
}
|
||||
async function tick() {
|
||||
if(tickBusy)return;tickBusy=true;
|
||||
try {
|
||||
run=await rpc('heartbeat',{account:account(),url:location.href,extension:extension()});
|
||||
halted=!run.running;
|
||||
if(!run.running) {aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));}
|
||||
if(working)return;
|
||||
// Passive result recording is permitted after Stop, but no new business actions.
|
||||
if(run.active) {
|
||||
const local=loadLocal();
|
||||
if(local?.id===run.active&&local.snapshot&&!run.running)status('流程已暂停;提交结果仍需核对');
|
||||
}
|
||||
if(!run.running) {
|
||||
if(run.active && run.owner===owner) {const saved=await rpc('current');if(saved.job) {active=saved.job;await observeResult(saved.job);}}
|
||||
status(run.reason || '流程已暂停');return;
|
||||
}
|
||||
const next=await rpc('next',{account:account(),extension:extension()});run=next.run;
|
||||
if(next.job)active=next.job;
|
||||
if(next.action==='prepare'||(next.action==='resume'&&next.job.state==='preparing'))void prepare(next.job);
|
||||
else if(next.action==='resume') {
|
||||
await observeResult(next.job);
|
||||
if(next.job.state==='awaiting_user_review') {
|
||||
if(!editorPage(next.job)||!document.querySelector('#subject')?.value) await fail(new Error('待审稿页面已丢失,请人工核对旧稿,不自动重新导入'));
|
||||
else status('等待你亲手发表;程序不再修改编辑器');
|
||||
}
|
||||
else if(['importing','formatting'].includes(next.job.state))await fail(new Error('导入阶段发生中断,请检查编辑器和已上传图片后处理任务'));
|
||||
else if(next.job.state==='needs_attention')status(next.job.error);
|
||||
}else if(next.action==='switch')await switchAccount();
|
||||
else if(next.action==='switch_wait')status('等待换号结果;若插件已暂停,请人工处理后继续');
|
||||
else if(next.action==='login')status('未能读取当前账号 UID,请登录并确认账号元素');
|
||||
else if(next.action==='other_tab')status('另一个标签持有任务;如需接管请先暂停');
|
||||
else if(next.action==='interval')status('等待设定的处理间隔');
|
||||
else status(run.reason || '等待任务');
|
||||
}catch(error){status(error.message);halted=true;aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));}
|
||||
finally{tickBusy=false;}
|
||||
}
|
||||
const takeover=document.createElement('button');takeover.type='button';takeover.textContent='接管已暂停任务';
|
||||
takeover.style.cssText='position:fixed;left:16px;top:16px;z-index:2147483646;padding:8px;background:#fff;border:1px solid #aab;border-radius:6px';
|
||||
takeover.onclick=async()=>{try{await rpc('takeover');status('已接管;核对当前页面后在工作台继续');}catch(error){status(error.message);}};
|
||||
document.body.append(takeover);
|
||||
setInterval(tick,2000);tick();
|
||||
})();
|
||||
Reference in new issue
Block a user