first commit

This commit is contained in:
Longtail committed 2026-10-31 14:47:15 +08:00
commit 30739db7ce
14 files changed
+1627

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
.DS_Store
__pycache__/
*.py[cod]
.venv/
*.dww11.json
+61
View File
@@ -0,0 +1,61 @@
# dww11 文档与文章导入工具
这个项目用于把网页文章保存到本地,并导入广谈大湾网发帖页面。项目中的工具彼此配合,也可以单独使用。
## 项目内容
### 1. 微信公众号文章下载器
目录:`wechat_official_acc_article_downloader/`
`download_article.py` 可以下载微信公众号或普通网页文章,保存为 Markdown 文件和本地图片。
```bash
cd wechat_official_acc_article_downloader
uv run --no-project --python 3.12 \
--with-requirements requirements.txt \
python download_article.py '文章链接'
```
运行后会在当前目录生成文章文件夹,其中包含 Markdown 文件和图片。需要给其他程序调用时,可以加上 `--json` 输出 JSON 结果。
### 2. 本地文章导入脚本
文件:`dww11-article-importer.user.js`
这是一个 Tampermonkey 用户脚本。它可以读取包含 Markdown 和图片的文章目录,预览内容,并把正文和配图填写到广谈大湾网的新发帖页面。脚本不会自动发表帖子。
使用前请先安装 Tampermonkey,然后在脚本管理面板中导入该文件。详细步骤见 `本地文章导入脚本使用说明.md`。
### 3. 文章打包工具
文件:`dww11_make_package.py`
这个工具可以把文章目录打包成用户脚本可读取的 JSON 文件:
```bash
python3 dww11_make_package.py '文章目录' -o '文章包.json'
```
它只读取和打包本地文件,不会上传或发布文章。
### 4. 微信一键导入脚本
文件:`dww11-wechat-oneclick.user.js`
这个 Tampermonkey 脚本可以在广谈大湾网发帖页面中,通过微信公众号文章链接调用网站采集功能,并自动整理标题、正文、图片和来源说明。导入后请人工检查,脚本不会自动保存或发表。
## 推荐使用流程
1. 用文章下载器下载微信公众号文章。
2. 检查生成的 Markdown 和图片。
3. 使用本地文章导入脚本,或在网站中使用微信一键导入脚本。
4. 确认标题、正文和图片无误后,再由用户手动保存或发表。
## 注意事项
- 需要自行安装 Python、uv 和 Tampermonkey。
- 微信文章可能因验证、登录或页面变化而无法下载。
- 脚本只处理本地文件或当前网页允许访问的内容,不绕过网站权限。
- `.venv`、系统临时文件和生成的文章数据不属于项目源码,不应提交到 Git。
+471
View File
@@ -0,0 +1,471 @@
// ==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) {
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 {
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) {
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);
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() {
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)) &&
!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));
}
notify('register', '正在加载网站图片列表…');
await registerImages(Array.from(new Set(aids.values())));
const now = snapshot();
if ((now.title !== before.title || now.text !== before.text) && !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 代码”,再重试。');
}
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 };
});
+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 });
})();
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Build a portable v1 article package for the DWW11 userscript (standard library only)."""
from __future__ import annotations
import argparse
import base64
import json
import mimetypes
from pathlib import Path
def make_package(directory: str | Path, markdown: str | None = None) -> dict:
"""Return JSON-serializable data; no network access or publishing side effects."""
root = Path(directory).expanduser().resolve(strict=True)
if not root.is_dir():
raise ValueError("文章目录不存在")
choices = sorted(path for path in root.rglob("*") if path.is_file() and path.suffix.lower() == ".md")
if markdown:
selected = (root / markdown).resolve(strict=True)
elif len(choices) == 1:
selected = choices[0].resolve()
else:
raise ValueError("目录必须包含一个 Markdown 文件,或通过 --markdown 指定目录内相对路径")
if not selected.is_relative_to(root) or selected.suffix.lower() != ".md" or not selected.is_file():
raise ValueError("Markdown 必须位于所选文章目录内")
images = []
# Package local supported images. The importer uploads only those referenced by Markdown.
for path in sorted(root.rglob("*")):
if not path.is_file() or path.suffix.lower() not in {".jpg", ".jpeg", ".png", ".gif"}:
continue
if not path.resolve().is_relative_to(root):
raise ValueError(f"图片符号链接指向目录外:{path.name}")
if not 0 < path.stat().st_size < 20 * 1024 * 1024:
raise ValueError(f"图片为空或达到 20 MB:{path.name}")
images.append({"path": path.relative_to(root).as_posix(),
"mime": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
"base64": base64.b64encode(path.read_bytes()).decode("ascii")})
return {"schemaVersion": 1, "markdownPath": selected.relative_to(root).as_posix(),
"markdown": selected.read_text(encoding="utf-8-sig"), "images": images}
def main() -> None:
parser = argparse.ArgumentParser(description="把文章目录打包为油猴脚本可导入的 JSON;不上传或发布")
parser.add_argument("directory", help="包含 Markdown 和图片的目录")
parser.add_argument("--markdown", help="多个 Markdown 时指定目录内的相对路径")
parser.add_argument("-o", "--output", help="输出文件路径;未指定时输出到标准输出")
args = parser.parse_args()
try:
content = json.dumps(make_package(args.directory, args.markdown), ensure_ascii=False, indent=2)
if args.output:
output = Path(args.output).expanduser()
# Never silently overwrite an article or an existing package.
with output.open("x", encoding="utf-8") as handle:
handle.write(content + "\n")
print(json.dumps({"ok": True, "package": str(output.resolve())}, ensure_ascii=False))
else:
print(content)
except (OSError, ValueError) as error:
parser.exit(1, f"打包失败:{error}\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,89 @@
# 网页文章离线下载
使用 uv 管理 Python 环境和依赖(Python 3.10 或更新版本)。将 `download_article.py` 与 `requirements.txt` 放在同一个目录,并确保已安装 uv。
## 安装与运行
在这两个文件所在目录打开终端,执行:
```sh
uv run --no-project --python 3.12 --with-requirements requirements.txt python download_article.py 'https://mp.weixin.qq.com/s/Jcu2J5YlaMdxbAUgLMd56A'
```
uv 会自动准备 Python 3.12 和所需依赖,无需手动创建或激活虚拟环境。后续运行相同命令即可复用缓存。依赖继续通过 `requirements.txt` 管理,无需创建 `pyproject.toml`。
默认在**运行命令时的当前目录**创建文章目录,也可以用 `-o /你的/保存目录` 指定父目录。
```text
当前目录/
└── 文章标题/
├── 文章标题.md
├── image001.png
└── image002.jpg
```
图片后缀按实际文件内容判断;同一地址的图片只下载一次。正文中的图片引用使用相对路径,离线阅读时请保留整个目录。支持微信的延迟加载图片和行内 CSS 背景图,保留常见的标题、段落、列表、链接、表格等 Markdown 格式;公众号的复杂视觉排版不会原样复刻。
Markdown 开头不添加原文链接和作者/公众号信息,文章末尾统一追加 `来源:公众号作者,如有侵权,请联系删除。`。无法获取公众号作者时使用“未知作者”。接口返回的 `source_url` 仍保留,方便调用方追踪来源。
文件名中系统不允许的字符会替换为 `_`,过长标题会截短。已有同名目录时创建 `文章标题 (2)/文章标题 (2).md`,不会覆盖已有内容。
## 其他任务通过命令行调用
```sh
uv run --no-project --python 3.12 --with-requirements requirements.txt python download_article.py 'https://mp.weixin.qq.com/s/Jcu2J5YlaMdxbAUgLMd56A' --json
```
也可以从标准输入读取单个 JSON 请求:
```sh
printf '%s' '{"url":"https://mp.weixin.qq.com/s/Jcu2J5YlaMdxbAUgLMd56A","output_root":".","timeout":30,"allow_missing_images":false}' | uv run --no-project --python 3.12 --with-requirements requirements.txt python download_article.py --stdin-json
```
每次进程处理一个网址。JSON 模式下,标准输出只有一个 JSON 对象,路径均为绝对路径。调用方应解析 JSON,不要依赖错误消息的具体文字。
成功返回退出码 `0`:
```json
{
"ok": true,
"title": "文章标题",
"source_url": "https://example.com/article",
"directory": "/完整路径/文章标题",
"markdown": "/完整路径/文章标题/文章标题.md",
"images": ["/完整路径/文章标题/image001.png"],
"warnings": []
}
```
失败返回退出码 `1`:
```json
{"ok": false, "error": "错误说明", "error_type": "ArticleDownloadError"}
```
命令行参数格式错误由 argparse 返回退出码 `2`,说明写入标准错误。`--stdin-json` 使用 JSON 中的参数,不使用其他命令行下载选项。
## 其他 Python 任务直接调用
将脚本放入调用方的 Python 模块搜索路径,并在调用方的 uv 环境中安装本脚本 `requirements.txt` 所列依赖后:
```python
from download_article import save_article
result = save_article(
url="https://mp.weixin.qq.com/s/Jcu2J5YlaMdxbAUgLMd56A",
output_root=".",
timeout=30,
allow_missing_images=False,
)
saved_directory = result.directory # 已保存文件所在目录(绝对路径字符串)
print(saved_directory)
# 完整结果可调用 result.to_dict()
```
函数失败时抛出异常,调用方可自行捕获。默认任何图片失败都会终止保存并清理半成品。若只希望尽量保存正文,可传 `allow_missing_images=True` 或命令行添加 `--allow-missing-images`,失败图片会替换为文字提示,详情写入 `warnings`。
微信可能返回验证、登录或失效页面,脚本会报告未找到正文,不会绕过访问限制。普通网页使用 article/main/body 提取,不适用于必须执行 JavaScript 才出现正文的页面。原文中的外链仍需联网,文章文字及成功下载的图片可离线查看。
如果接口对外提供服务,请由调用方限制网址及重定向目的地,只允许可信站点;此脚本本身允许访问运行机器能连接的 HTTP 地址。
@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""下载微信/普通网页文章为 Markdown。Python 接口:save_article(url, output_root='.')。"""
from __future__ import annotations
import argparse
import base64
from dataclasses import asdict, dataclass
import json
from pathlib import Path
import re
import shutil
import sys
import tempfile
from urllib.parse import unquote, urljoin, urlsplit
import requests
from bs4 import BeautifulSoup
from markdownify import markdownify
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
class ArticleDownloadError(RuntimeError):
"""页面不是文章、图片下载失败等可向调用方报告的错误。"""
@dataclass
class ArticleResult:
title: str
source_url: str
directory: str
markdown: str
images: list[str]
warnings: list[str]
def to_dict(self) -> dict:
return asdict(self)
def safe_name(title: str) -> str:
"""跨平台目录/文件名;目录名与其中 Markdown 的文件名保持一致。"""
name = re.sub(r'[<>:"/\\|?*\x00-\x1f\x7f]', '_', title)
name = re.sub(r'\s+', ' ', name).strip(' .')
# 同时限制 UTF-8 字节数,给 .md 和重名后缀保留空间。
name = name.encode('utf-8')[:180].decode('utf-8', errors='ignore').rstrip(' .')
if not name:
name = '未命名文章'
if re.fullmatch(r'(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?', name):
name = '_' + name
return name
def _session() -> requests.Session:
session = requests.Session()
session.headers.update({
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
})
retry = Retry(total=3, backoff_factor=0.6,
status_forcelist=[429, 500, 502, 503, 504], allowed_methods=['GET'])
session.mount('https://', HTTPAdapter(max_retries=retry))
session.mount('http://', HTTPAdapter(max_retries=retry))
return session
def _get(session: requests.Session, url: str, timeout: float, limit: int,
referer: str | None = None) -> tuple[bytes, str, str]:
if urlsplit(url).scheme not in ('http', 'https'):
raise ArticleDownloadError(f'不支持的下载地址:{url[:200]}')
headers = {'Referer': referer} if referer else {}
with session.get(url, timeout=(min(timeout, 15), timeout),
headers=headers, stream=True) as response:
response.raise_for_status()
chunks, size = [], 0
for chunk in response.iter_content(65536):
size += len(chunk)
if size > limit:
raise ArticleDownloadError(f'文件超过大小限制({limit // 1024 // 1024} MB):{url[:200]}')
chunks.append(chunk)
return b''.join(chunks), response.headers.get('Content-Type', ''), response.url
def _image_ext(data: bytes, mime: str) -> str:
if data.startswith(b'\xff\xd8\xff'):
return '.jpg'
if data.startswith(b'\x89PNG\r\n\x1a\n'):
return '.png'
if data.startswith((b'GIF87a', b'GIF89a')):
return '.gif'
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
return '.webp'
if len(data) >= 12 and data[4:8] == b'ftyp' and data[8:12] in (b'avif', b'avis'):
return '.avif'
if b'<svg' in data[:1024].lower() and mime.split(';')[0] == 'image/svg+xml':
return '.svg'
raise ArticleDownloadError(f'返回内容不是支持的图片(Content-Type: {mime or "未知"})')
def _image_source(img) -> str:
for attr in ('data-src', 'data-original', 'data-actualsrc', 'data-lazy-src', 'src'):
value = img.get(attr)
if value and str(value).strip():
return str(value).strip()
srcset = img.get('data-srcset') or img.get('srcset')
if srcset:
return str(srcset).split(',')[-1].strip().split()[0]
return ''
def save_article(url: str, output_root: str | Path = '.', *, timeout: float = 30,
allow_missing_images: bool = False) -> ArticleResult:
"""返回绝对路径。默认任意图片失败即报错,不留下半成品;已有目录不会被覆盖。
allow_missing_images=True 时,失败图片被替换为文字占位,并记入 warnings。
网络/HTTP 错误可能抛出 requests.RequestException,文章错误抛出 ArticleDownloadError。
调用方应只传入可信的公开网页 URL(本函数允许访问调用机器能访问的 HTTP 地址)。
"""
if not isinstance(url, str) or urlsplit(url).scheme not in ('http', 'https') or not urlsplit(url).hostname:
raise ValueError('url 必须是完整的 http:// 或 https:// 地址')
if timeout <= 0:
raise ValueError('timeout 必须大于 0')
if not isinstance(allow_missing_images, bool):
raise ValueError('allow_missing_images 必须是布尔值')
with _session() as session:
raw, content_type, final_url = _get(session, url, timeout, 20 * 1024 * 1024)
if content_type and not any(t in content_type.lower() for t in ('html', 'xhtml', 'text/plain')):
raise ArticleDownloadError('网址返回的不是 HTML 网页')
# 微信为 UTF-8;普通网页由 HTML 的 charset 标记/BeautifulSoup 自动识别。
soup = BeautifulSoup(raw, 'html.parser')
is_wechat = urlsplit(url).hostname == 'mp.weixin.qq.com'
content = soup.select_one('#js_content')
if is_wechat and content is None:
raise ArticleDownloadError('未找到微信文章正文,可能需要验证、登录,或文章已失效。请在浏览器中确认链接可访问。')
if content is None:
content = soup.select_one('article') or soup.select_one('main') or soup.body
if content is None:
raise ArticleDownloadError('网页没有可保存的正文')
title_tag = soup.select_one('#activity-name') or soup.select_one('h1')
title_meta = soup.select_one('meta[property="og:title"]')
title = (title_tag.get_text(' ', strip=True) if title_tag else '')
title = title or (title_meta.get('content', '').strip() if title_meta else '')
title = title or (soup.title.get_text(' ', strip=True) if soup.title else '') or '未命名文章'
for tag in content.select('script, style, noscript, iframe, form, button'):
tag.decompose()
# 部分微信排版把插图放在行内 CSS 背景中,也转为普通图片参与下载。
for tag in list(content.find_all(style=True)):
for match in re.finditer(r'url\(\s*[\'\"]?(.*?)[\'\"]?\s*\)', str(tag['style'])):
source = match.group(1).strip()
if source and not source.startswith('#'):
background = soup.new_tag('img', src=source, alt='')
tag.insert(0, background)
if not content.get_text(strip=True) and not content.find('img'):
raise ArticleDownloadError('文章正文为空,可能需要浏览器执行 JavaScript')
for link in content.find_all('a', href=True):
href = str(link['href']).strip()
if href and not href.startswith('#'):
absolute = urljoin(final_url, href)
if urlsplit(absolute).scheme in ('http', 'https', 'mailto', 'tel'):
link['href'] = absolute
else:
del link['href']
root = Path(output_root).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
stage = Path(tempfile.mkdtemp(prefix='.article-', dir=root))
images, warnings, downloaded = [], [], {}
try:
for img in list(content.find_all('img')):
source = _image_source(img)
if not source:
img.replace_with(soup.new_string('[图片缺少下载地址]'))
if not allow_missing_images:
raise ArticleDownloadError('正文中存在没有下载地址的图片')
warnings.append('正文中存在没有下载地址的图片')
continue
image_url = urljoin(final_url, source)
try:
if image_url not in downloaded:
if source.startswith('data:image/'):
header, payload = source.split(',', 1)
data = base64.b64decode(payload, validate=True) if ';base64' in header else unquote(payload).encode()
mime = header[5:].split(';')[0]
if len(data) > 50 * 1024 * 1024:
raise ArticleDownloadError('内嵌图片超过 50 MB')
else:
data, mime, _ = _get(session, image_url, timeout, 50 * 1024 * 1024, final_url)
ext = _image_ext(data, mime)
filename = f'image{len(images) + 1:03d}{ext}'
(stage / filename).write_bytes(data)
images.append(filename)
downloaded[image_url] = filename
# 清除懒加载/响应式属性,使转换器只引用本地文件。
alt = str(img.get('alt') or '')
img.attrs = {'src': downloaded[image_url], 'alt': alt}
except (requests.RequestException, ArticleDownloadError, ValueError) as exc:
if not allow_missing_images:
raise ArticleDownloadError(f'图片下载失败:{image_url[:200]};{exc}') from exc
warnings.append(f'图片下载失败:{image_url[:200]};{exc}')
img.replace_with(soup.new_string(f'[图片下载失败:{img.get("alt") or "未命名图片"}]'))
body = markdownify(str(content), heading_style='ATX', bullets='-',
strip=['picture', 'source']).strip()
# 微信富文本中各段常没有标准 p 标签;markdownify 仍会保留文字和图片顺序。
author_tag = soup.select_one('#js_name')
date_tag = soup.select_one('#publish_time')
author = author_tag.get_text(' ', strip=True) if author_tag else ''
author = author or '未知作者'
metadata = []
if date_tag and date_tag.get_text(strip=True):
metadata.append(f'发布时间:{date_tag.get_text(" ", strip=True)}')
text = f'# {title}\n\n'
if metadata:
text += '\n\n'.join(metadata) + '\n\n---\n\n'
text += body + f'\n\n来源:{author},如有侵权,请联系删除。\n'
basename = safe_name(title)
# 原子认领新目录,支持多个进程并发保存同名文章。
index = 1
while True:
name = basename if index == 1 else f'{basename} ({index})'
destination = root / name
try:
destination.mkdir()
break
except FileExistsError:
index += 1
try:
(stage / f'{name}.md').write_text(text, encoding='utf-8')
for item in stage.iterdir():
shutil.move(str(item), destination / item.name)
except BaseException:
shutil.rmtree(destination, ignore_errors=True)
raise
return ArticleResult(title, url, str(destination), str(destination / f'{name}.md'),
[str(destination / x) for x in images], warnings)
finally:
shutil.rmtree(stage, ignore_errors=True)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('url', nargs='?', help='文章网址')
parser.add_argument('-o', '--output-root', default='.', help='保存到此目录下,默认当前目录')
parser.add_argument('--timeout', type=float, default=30, help='每次网络请求超时秒数')
parser.add_argument('--allow-missing-images', action='store_true', help='允许图片失败,保留文字与失败提示')
parser.add_argument('--json', action='store_true', help='向标准输出写入 JSON,便于其他任务调用')
parser.add_argument('--stdin-json', action='store_true', help='从标准输入读取一个 JSON 请求,并返回 JSON')
args = parser.parse_args()
json_mode = args.json or args.stdin_json
try:
if args.stdin_json:
payload = json.load(sys.stdin)
if not isinstance(payload, dict):
raise ValueError('JSON 请求必须是对象')
unknown = set(payload) - {'url', 'output_root', 'timeout', 'allow_missing_images'}
if unknown:
raise ValueError(f'未知参数:{", ".join(sorted(unknown))}')
if 'url' not in payload:
raise ValueError('JSON 请求缺少 url')
result = save_article(**payload)
else:
if not args.url:
raise ValueError('请提供网址,或使用 --stdin-json')
result = save_article(args.url, args.output_root, timeout=args.timeout,
allow_missing_images=args.allow_missing_images)
if json_mode:
print(json.dumps({'ok': True, **result.to_dict()}, ensure_ascii=False))
else:
print(result.directory)
for warning in result.warnings:
print(f'警告:{warning}', file=sys.stderr)
return 0
except Exception as exc:
if json_mode:
print(json.dumps({'ok': False, 'error': str(exc), 'error_type': type(exc).__name__}, ensure_ascii=False))
else:
print(f'下载失败:{exc}', file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,3 @@
requests>=2.32,<3
beautifulsoup4>=4.12,<5
markdownify>=1.1,<2
@@ -0,0 +1,73 @@
# 为什么年轻人爱玩手机,背后的真相太无奈!
很多家长和长辈都在抱怨,现在的年轻人怎么整天抱着个手机,不是刷短视频就是打游戏,一点上进心都没有。
但老赵今天想替年轻人说句公道话!他们不是天生就爱玩手机,而是手机成了他们在有限条件下,唯一的消遣!
先算一笔账,现在出门随便玩点什么不要花钱?
一杯普通的奶茶二三十块,一场电影五六十块,吃顿火锅人均一百多,剧本杀,商场里买件衣服几百块。
就算是去公园散散步,来回的交通费、买瓶水也得花个十几块。
而玩手机呢?只要有一部手机和网络,就能看电影、听音乐、玩游戏、看小说,一毛钱不花就能娱乐一整天。
对于很多年轻人来说,这是唯一的放松方式。
刚毕业的大学生,月薪三四千,除去房租水电和吃饭,能剩下的钱寥寥无几。
工作几年的年轻人,要还房贷车贷,要攒钱过日子,每一分钱都得掰成两半花。
他们不是不想去旅游,不是不想培养什么高雅的兴趣爱好,而是这些都太贵了。
打个羽毛球/网球,都得交场地费!还不一定能约到有时间的朋友。
学个乐器、报个健身课,动辄几千上万的学费,对于普通年轻人来说,实在是太奢侈了。
![](image001.jpg)
比没钱更无解的,是他们根本没有完整的时间。
现在的年轻人,早就失去了属于自己的休闲时间,每天写完作业/忙完工作,就已经天黑了。
这个时代的年轻人,学业压力/工作压力,比我们那个时代要大的多,根本没有那么多时间精力,去参加户外活动。
洗完澡躺到床上可能只剩一小时就要睡觉了。这些时间你能做什么?去跑步?换衣服热身都不够。
去看书?刚看两行就该睡觉了。
去约朋友?别人可能也在忙着自己的事情。
只有手机,能完美适配这种被切割得支离破碎的时间。拿起来就能玩,放下就能停,不需要任何准备,不需要任何人配合。
现实生活里没有即时反馈,但手机不一样子你刷一个短视频,3秒就能让你笑。你发一条动态,有人点赞马上就有被关注的感觉。
这种做了就有结果的确定感,是现实中极度稀缺的东西。年轻人不是爱玩手机,是他们太需要这种即时的快乐了。
![](image002.jpg)
很多人说,那可以约朋友出来玩啊。
现在的社交不仅要花钱,还要花精力。约朋友不是说走就走的,你要提前一周约时间,要迁就每个人的时间安排。
要找大家都满意的地方,要照顾每个人的情绪。对于已经被工作榨干了所有精力的年轻人来说,这种社交比上班还累!
而手机上的社交是低压力的,你想聊就聊,不想聊就隔两个小时再回,累了就直接下线,没有人会怪你。
老赵不是在鼓励大家沉迷手机,而是希望大家能看到问题的本质。
![](image003.jpg)
不是年轻人变懒了,是免费的快乐消失了。
以前我们小时候,跳皮筋、踢毽子、捉迷藏,随时能叫来一堆朋友一块玩,而且这些都不需要花钱。
但现在的城市里,到处都是高楼大厦,没有那么多可以自由玩耍的地方,打羽毛球/打网球/游泳,这些场地都是收费的!
所有的快乐都变成了商品,明码标价摆在那里。
与其指责年轻人沉迷手机,不如问问他们为什么没有其他更好的选择。如果他们有足够的钱和时间,有更多元的娱乐选择,谁谁不愿意去体验丰富多彩的生活呢?
所以,下次再看到年轻人玩手机的时候,不要急着去批评他们。多给他们一点理解和包容。毕竟,谁不想活得轻松一点,快乐一点呢?
来源:老赵讲道理,如有侵权,请联系删除。
@@ -0,0 +1,59 @@
# 微信公众号一键导入并排版
脚本版本:1.0.7。脚本文件:`dww11-wechat-oneclick.user.js`。与原有“本地文章目录导入”脚本独立,可按需同时保留。
## 安装与使用
1. 在 Tampermonkey 中新建脚本,把上述文件的全部内容复制进去,保存脚本。
2. 刷新广谈大湾网的发帖页面。编辑器工具栏右侧会出现绿色“一键导入微信公众号并排版”按钮。
3. 点击按钮,粘贴微信文章链接。首次运行如 Tampermonkey 询问是否允许访问 `mp.weixin.qq.com`,允许后才能自动读取标题和作者。
4. 脚本通过网站原有 `.wxcj` 组件打开采集输入框,填写链接并触发采集。完成后请人工检查文章。
安装步骤中的“保存”仅指保存 Tampermonkey 脚本。脚本没有点击网站的保存草稿或发表按钮,也不会自动提交帖子。
## 处理规则
- 标题:从微信页面的标题元素读取纯文本,避免把 `<span class="js_title_inner">` 当成标题。
- 来源名称:只读取蓝色链接 `#js_name` 的公众号名称,不使用灰色 `#js_author_name` 署名作者或作者元数据。缺失时提示手动填写公众号名称。
- 正文:工具栏的 4 档字号,使用原生 HTML `font size="4"`,不再附加18px样式,避免网站转换时字号错档,统一黑色。保留图片、段落、链接、粗体等结构。
- 末尾追加:`来源:<作者>,如有侵权,请联系删除。`,使用原生 `font size="1" color="#c0c0c0"`,即1档字号和截图中的银色。
- 如果当前处于纯文本模式,导入前自动切到可视化模式,完成后保持可视化,交由网站原有提交流程转换正文。
- 采集会按网站原有方式替换当前标题、正文,并处理图片附件;这不是追加导入。
## 验证范围与失败处理
已只读检查 Chrome 中网站的工具栏、采集插件代码、标题框和字号菜单。未在真实网站上运行导入、保存或发表。
本地模拟页验证通过:按钮→URL输入→调用现有采集组件→等待成功响应→修正完整标题→正文4号黑色→来源1号银色;图片、链接和粗体保留。模拟页使用虚构采集响应,并不访问微信或网站采集接口。
已对照用户提供的微信 F12 页面结构和 `wechat_official_acc_article_downloader/download_article.py` 的解析方式。实际结构解析结果为完整标题“为什么年轻人爱玩手机,背后的真相太无奈!”和作者“老赵讲道理”,作者不重复。11项离线解析回归测试和完整模拟导入流程均通过。解析前还会检查 `#js_content`,避免把验证页误认为文章。
本次浏览器安全策略阻止读取微信源页面,因此微信网络请求尚未做真实联网验证。微信验证页、登录要求、网络限制或页面结构变化都可能使自动读取失败。这时脚本会提示手动填写完整标题和作者,取消任一步则停止后续采集。
采集失败、返回格式异常或超时,不继续排版。网站采集器自身可能已改动编辑器;脚本不保证撤销网站采集操作。超过90秒时原采集器仍可能返回,先确认状态再重试,避免重复采集。
微信正文及图片导入能力沿用网站插件,脚本不负责修复网站插件自身的遗漏或失效。标题仍须符合网站长度限制,脚本不自动截断标题。
## 1.0.3 手机端排版字符修复
旧版在正文颜色样式中使用了 `!important`,网站转换为论坛代码时会生成类似 `[color=#000 !important]` 的无效标签,手机版可能将其直接显示。新版清理正文内联字号/颜色样式及其他样式优先级,展开嵌套 FONT,只用 `font size="4" color="#000000"` 设置正文、`font size="1" color="#c0c0c0"` 设置来源,避免污染论坛代码。
更新脚本不能自动修复已经保存的帖子。受影响的文章需要在编辑页面重新导入,再由用户自行检查和保存。没有自动修改、保存或发布现有帖子。
## 1.0.4 图片间距
正文文字与图片相邻时,自动补足一个空白行(两个换行)。识别已有 BR 和段落边界,不重复增加已有空行;文字、图片和段落的顺序保持不变。
## 1.0.5 图片上下空行修正
修正图片容器末尾 BR 会被折叠而使图片下方没有空行的问题。图文间的换行移至下一段内容开头,使用 `<font size="4" color="#000000"><br></font>` 表示纯换行,不插入空格或 NBSP,并清理图文间原有的占位空格。行内图文保留两个 BR,独立块图文补一行;已有较多换行保留。
## 1.0.6 连续4号字体与手机排版
不再使用仅含 BR 的空 FONT。正文、图片及图文间的两个 BR 全部位于同一个连续的 `font size="4" color="#000000"` 内,来源仍单独使用1号银色。将微信段落容器的隐式边界转成真实 BR,避免 SECTION 边界被忽略;不插入空格或 NBSP。图片在编辑器内限制最大宽度并保持比例。
新增桌面820px和手机375px宽度的本地检查,以及 HTML→论坛标签→HTML 的简化模拟转换检查;这不是网站真实提交流程的验证。安装新版后需重新导入,已有文章不会自动改写。
## 1.0.7 来源名称修正
来源标识改为 `#js_name` 蓝色链接的公众号名称。例如公众号为“室内设计师有料儿”、灰色署名作者为“粉条条”时,末尾显示“来源:室内设计师有料儿,如有侵权,请联系删除。”。缺少公众号节点时提示人工填写,不回退到署名作者。1.0.6 的字体、段落和图片间距处理保持原样。
+123
View File
@@ -0,0 +1,123 @@
# 广谈大湾网本地文章导入
主脚本:`dww11-article-importer.user.js`,版本 1.0.0。单文件、无第三方运行依赖,可本地安装,也可自行提交到 Greasy Fork。本次没有替你安装或提交到 Greasy Fork。
## 安装和首次测试
1. 在 Chrome 中准备好 Tampermonkey(油猴)扩展。
2. 打开 Tampermonkey 的管理面板,选择“添加新脚本”,删除默认模板,把 `dww11-article-importer.user.js` 的全部内容粘贴进去并保存。也可以使用扩展自身的文件导入功能导入这个 `.user.js` 文件。
3. 打开或刷新 `https://www.dww11.com/forum.php?mod=post&action=newthread&fid=95`,保持登录状态。右下角应出现“本地文章导入 · 1.0”面板。
4. 点击“选择文章目录”,选择整个 `为什么年轻人爱玩手机,背后的真相太无奈!` 文件夹。这个文件夹里应同时包含 `.md` 文件和三张 JPG,不要只选 Markdown。
5. 面板会显示标题、正文原文预览、三张图片缩略图。这一步只读取本地文件,不上传图片、不填写网页。
6. 点击“上传配图并填写”。这一步才会向广谈大湾网上传被正文引用的图片,随后填写标题、正文,尽可能切回网站富文本模式显示配图。
7. 检查正文三处图片位置和末尾来源说明。脚本不点击“发表帖子”或“保存草稿”。实际发表仍由你操作。
浏览器若提示将所选文件夹的文件提供给网页,这是目录选择器的正常提示。脚本只发送被正文引用且通过检查的图片,不会将整个目录打包上传到网站。
如果看不到面板,先检查扩展是否已启用、是否允许在该网站运行脚本,以及当前页是否为 `action=newthread` 的新发帖页面。本版不在编辑旧帖或快速回复页运行。Tampermonkey/Chrome 若提示启用用户脚本支持,按扩展界面中的指引设置。
## 支持范围和失败处理
- 支持整个目录和子目录;一个目录中有多个 Markdown 时出现下拉选择框。
- 标题取 Markdown 开头第一个 `# 标题`;无标题时取文件名。开头标题不会再插入正文。上限 80 字,超限会停止。
- 支持正文段落、内联本地图片、常见加粗/斜体/删除线、链接、标题、基本列表、引用和代码块。内联代码保留文字;多层列表会简化为一层。表格和原始 HTML 按文字保留并给出提示,复杂公众号视觉样式不复刻。引用式 Markdown 图片/链接暂不支持,会报错。
- 图片必须用 `![](image001.jpg)` 这类相对路径引用;支持子目录、空格及常见括号路径。远程图片、目录外路径、缺图、空文件、达到 20 MB 或网站不支持的后缀会在上传前拦截。当前站点支持 JPG/JPEG/PNG/GIF。
- 只上传被当前文章引用的图片。相同内容按 SHA-256 去重,即使文件名不同也复用附件编号,正文每个引用位置仍然保留。
- 这篇测试文章有三个图片引用,其中 `image001.jpg` 与 `image003.jpg` 的文件内容相同,因此预计只需要上传两个独立文件,但正文应显示三处图片。
- 当前编辑器已有内容时先询问是否替换。上传期间又修改正文时,在填写前再询问。导入期间临时禁用网页提交按钮。
- 图片上传或网站图片列表加载失败时,标题与正文不会被替换;已上传的图片仍在网站上。当前页面内重试会复用已成功返回附件编号的图片。刷新页面后缓存失效;上传超时可能是“服务器成功、客户端未收到结果”,此时先检查网站图片列表,避免重复上传。
- 网站自带的编辑器自动保存可能在填写后继续运行。本脚本不关闭或改动网站自动保存设置,也不承诺页面完全无草稿痕迹。
- “导出诊断”生成本地 JSON,包含阶段、文件路径、标题和附件编号等排错信息,不包含 Cookie、上传凭证或正文。需要反馈时可把这个文件交给维护者。
## 给下载程序的接口
浏览器页面提供 `window.DWW11Importer`,接口版本 1。读取文章、上传适配和面板各自分开,后续可替换输入来源;无需重写 Markdown 与附件插入逻辑。
```javascript
// 暂存浏览器已获准读取的文件。只检查与预览,不上传。
await window.DWW11Importer.stageFiles(fileList);
// 多个 Markdown 时可直接指定其 webkitRelativePath:
await window.DWW11Importer.stageFiles(fileList, {
markdownPath: '文章目录/文章.md'
});
// 暂存文章包,同样不上传。推荐外部程序对接此接口。
await window.DWW11Importer.stagePackage(articlePackage);
// 显式执行“上传配图并填写”;不会发布。
const result = await window.DWW11Importer.importPrepared();
// result: { title, images: { 'image001.jpg': '附件编号' }, completedAt }
// 取消替换时返回 { cancelled: true };失败时 Promise reject。
window.DWW11Importer.getState(); // busy / ready / title / imageCount / warnings / result
window.DWW11Importer.getBackup(); // 最近一次填写前的标题、正文及模式(仅存在内存)
window.DWW11Importer.openPicker(); // 应从用户点击事件调用,打开目录选择器
window.addEventListener('dww11-importer:progress', event => {
console.log(event.detail); // { phase, message, completed?, total? }
});
```
`stageFiles` 返回准备完成的摘要;目录有多个 Markdown 且未指定文件时返回 `{ candidates: [...] }` 并等待选择。`stagePackage` 返回摘要。`getBackup()` 的 `mode: 0` 表示论坛纯文本格式,`mode: 1` 表示编辑器 HTML;不要直接当 Markdown 使用。
没有提供自动监听外部网页消息、后台轮询本地目录、直接读取任意本地路径或自动发表接口。其他本地程序需通过文章包文件、浏览器扩展或自动化工具把文件内容送进上述接口。浏览器普通网页不能凭一个本地路径字符串读取硬盘。
### 文章包格式
```json
{
"schemaVersion": 1,
"markdownPath": "文章.md",
"markdown": "# 标题\n\n正文\n\n![](images/01.jpg)",
"images": [
{
"path": "images/01.jpg",
"mime": "image/jpeg",
"base64": "这里是文件的纯Base64内容,不含data前缀"
}
]
}
```
所有路径相对于所选文章目录,图片相对于 Markdown 文件位置解析。可选 `title` 显式指定标题。浏览器内直接调用时,图片条目也可传 `{ path, blob: File或Blob }` 代替 Base64。
### 本地打包工具
`dww11_make_package.py` 使用 Python 标准库,无需额外安装依赖。
在 `dww11自动化` 目录执行:
```sh
python3 dww11_make_package.py \
'tests/fixtures/article' \
-o '测试文章.dww11.json'
```
再点击油猴面板的“导入文章包”,选择输出的 JSON。此功能是未来程序对接的补充;日常操作仍推荐直接选择整个文章目录。
- 不加 `-o` 时,标准输出就是文章包 JSON,方便其他程序解析。
- 加 `-o` 时,标准输出为 `{ "ok": true, "package": "绝对路径" }`;不覆盖已有文件。
- 多个 Markdown 时,用 `--markdown '子目录/文章.md'` 指定。
- 文件打包失败退出码为 1;参数用法错误为 2。
现有下载程序返回的 `directory` 可以直接传给打包工具:
```python
from dww11_make_package import make_package
# download_result 是原下载器返回的结果对象
article_package = make_package(download_result.directory)
# 后续由调用方保存为 JSON,或把 article_package 交给浏览器 stagePackage。
```
这个工具仅打包本地数据,不会自动连接浏览器或发表文章。包文件含完整正文和图片,保留在你自己的电脑上即可。
## 验证记录与边界
离线测试使用项目自有测试文章,覆盖正文逐字保留、三处图片顺序、去重、缺图与路径检查、文章包接口、上传失败、富文本转换失败回滚,以及“只有图片上传请求、没有发表请求”。运行:
```sh
node --test test-dww11-importer.cjs
```
网站适配依据当前页面和网站公开的 WebUploader、编辑器代码编写,上传参数在运行时从当前页面读取,未写入账号或凭证。本次未安装油猴脚本,也未进行真实网站上传;最终在线兼容性需由你首次测试确认。如果网站以后更换编辑器或上传协议,应更新适配部分。