first commit

This commit is contained in:
Longtail committed 2026-10-31 16:15:04 +08:00
commit 1e6504b4a2
41 files changed
+4964

No files matched your search

+397
View File
@@ -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 });
})();