Files
auto_news_upload/browser/formatter-original.user.js
T
2026-10-31 16:15:04 +08:00

398 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==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 });
})();