first commit
This commit is contained in:
14 files changed
+1627
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
*.dww11.json
|
||||||
@@ -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。
|
||||||
|
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
// ==UserScript==
|
||||||
|
// @name 广谈大湾网 · 微信文章一键导入并排版
|
||||||
|
// @namespace local.dww11.wechat-oneclick
|
||||||
|
// @version 1.0.9
|
||||||
|
// @description 使用网站现有微信采集器,修正标题,统一正文4号黑色,追加1号银色来源;不点击保存或发布。
|
||||||
|
// @match https://www.dww11.com/forum.php*
|
||||||
|
// @match https://dww11.com/forum.php*
|
||||||
|
// @grant GM_xmlhttpRequest
|
||||||
|
// @grant unsafeWindow
|
||||||
|
// @connect mp.weixin.qq.com
|
||||||
|
// @run-at document-idle
|
||||||
|
// @noframes
|
||||||
|
// ==/UserScript==
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
const ID = 'dww11-wx-oneclick';
|
||||||
|
const FORMAT_ID = 'dww11-format-oneclick';
|
||||||
|
let busy = false;
|
||||||
|
function setBusy(value) {
|
||||||
|
busy = value;
|
||||||
|
for (const id of [ID, FORMAT_ID]) {
|
||||||
|
const button = document.getElementById(id);
|
||||||
|
if (button) button.disabled = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
const visible = el => el && el.getClientRects().length && getComputedStyle(el).display !== 'none';
|
||||||
|
async function waitFor(fn, timeout = 10000) {
|
||||||
|
const deadline = Date.now() + timeout;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const result = fn();
|
||||||
|
if (result) return result;
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
throw new Error('等待编辑器或采集窗口超时,请关闭遮挡窗口后重试。');
|
||||||
|
}
|
||||||
|
function normalizeURL(raw) {
|
||||||
|
const url = new URL(raw.trim().replace(/[\u200b-\u200d\ufeff]/g, ''));
|
||||||
|
if (url.protocol !== 'https:' || url.hostname !== 'mp.weixin.qq.com' || url.port || url.username || url.password || !/^\/(s(?:\/|$)|s$)/.test(url.pathname)) {
|
||||||
|
throw new Error('请输入 https://mp.weixin.qq.com/s/… 或 /s?… 微信文章地址。');
|
||||||
|
}
|
||||||
|
url.hash = '';
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
function cleanText(value) {
|
||||||
|
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
function metadata(html) {
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const text = selector => cleanText(doc.querySelector(selector)?.textContent);
|
||||||
|
const meta = selector => cleanText(doc.querySelector(selector)?.getAttribute('content'));
|
||||||
|
// 与下载组件一致,先确认是真正的文章,避免把验证页标题当成文章。
|
||||||
|
if (!doc.querySelector('#js_content')) throw new Error('未找到微信文章正文,可能需要验证、登录,或文章已失效。');
|
||||||
|
const title = text('#activity-name .js_title_inner') || text('#activity-name') || meta('meta[property="og:title"]') || meta('meta[name="twitter:title"]') || text('h1') || text('title');
|
||||||
|
// 不读取 rich_media_meta_text 容器:它可能包含重复的隐藏作者,或实际上是日期/IP。
|
||||||
|
// 来源使用蓝色链接 #js_name 的公众号名称,不使用灰色署名作者。
|
||||||
|
const author = text('#js_name');
|
||||||
|
if (!title || !author) throw new Error('微信未返回完整标题和公众号名称,可能需要验证或登录。');
|
||||||
|
return { title, author };
|
||||||
|
}
|
||||||
|
function fetchMetadata(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
GM_xmlhttpRequest({
|
||||||
|
method: 'GET', url, timeout: 25000, anonymous: true,
|
||||||
|
onload(response) {
|
||||||
|
try {
|
||||||
|
if (response.status !== 200) throw new Error('微信返回状态 ' + response.status);
|
||||||
|
if (response.finalUrl && new URL(response.finalUrl).hostname !== 'mp.weixin.qq.com') throw new Error('微信地址发生了非预期跳转。');
|
||||||
|
resolve(metadata(response.responseText));
|
||||||
|
} catch (error) { reject(error); }
|
||||||
|
},
|
||||||
|
onerror: () => reject(new Error('无法读取微信文章。')),
|
||||||
|
ontimeout: () => reject(new Error('读取微信文章超时。'))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function getMetadata(url) {
|
||||||
|
try { return await fetchMetadata(url); }
|
||||||
|
catch (error) {
|
||||||
|
const title = prompt(error.message + '\n请从源文章复制完整标题(取消则停止导入):');
|
||||||
|
if (title === null) return null;
|
||||||
|
const author = prompt('请填写源文章蓝色链接处的公众号名称(取消则停止导入):');
|
||||||
|
if (author === null) return null;
|
||||||
|
if (!cleanText(title) || !cleanText(author)) throw new Error('标题和作者不能为空。');
|
||||||
|
return { title: cleanText(title), author: cleanText(author) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function editorBody() {
|
||||||
|
return document.querySelector('#e_iframe')?.contentDocument?.body;
|
||||||
|
}
|
||||||
|
async function ensureVisualEditor() {
|
||||||
|
const checkbox = document.querySelector('#e_switchercheck');
|
||||||
|
if (checkbox?.checked) checkbox.click();
|
||||||
|
await waitFor(() => visible(document.querySelector('#e_iframe')) && editorBody());
|
||||||
|
}
|
||||||
|
// 只监听本次现有采集器的响应,不重写其请求或图片附件处理。
|
||||||
|
function watchImport(jq, url) {
|
||||||
|
let timer, resolveResult, rejectResult;
|
||||||
|
const result = new Promise((resolve, reject) => { resolveResult = resolve; rejectResult = reject; });
|
||||||
|
const events = jq(document);
|
||||||
|
const cleanup = () => { clearTimeout(timer); events.off('.dww11wx'); };
|
||||||
|
const finish = error => { cleanup(); error ? rejectResult(error) : resolveResult(); };
|
||||||
|
events.on('ajaxComplete.dww11wx', (_event, xhr, settings) => {
|
||||||
|
let requestURL, sentURL;
|
||||||
|
try {
|
||||||
|
requestURL = new URL(settings.url, location.href);
|
||||||
|
sentURL = typeof settings.data === 'string' ? new URLSearchParams(settings.data).get('url') : settings.data?.url;
|
||||||
|
} catch { return; }
|
||||||
|
if (requestURL.origin !== location.origin || requestURL.searchParams.get('id') !== 'wxcj:cj' || sentURL !== url) return;
|
||||||
|
const parts = String(xhr.responseText || '').split('----wxcj----');
|
||||||
|
if (xhr.status < 200 || xhr.status >= 300 || parts.length < 4 || !parts[2]?.trim()) {
|
||||||
|
finish(new Error('网站采集失败或返回内容不完整。未执行排版,请检查采集提示。'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
});
|
||||||
|
timer = setTimeout(() => finish(new Error('网站采集超过90秒,未执行排版。采集器仍可能在后台返回,请勿连续重复导入。')), 90000);
|
||||||
|
return { result, cleanup };
|
||||||
|
}
|
||||||
|
// 将微信 P/SECTION 的隐式换行改成真实 BR,避免论坛忽略 SECTION 边界。
|
||||||
|
function flattenParagraphs(body) {
|
||||||
|
const selector = 'p,div,section,article,figure,figcaption,h1,h2,h3,h4,h5,h6';
|
||||||
|
const marker = () => {
|
||||||
|
const br = body.ownerDocument.createElement('br');
|
||||||
|
br.dataset.dww11Boundary = '1'; return br;
|
||||||
|
};
|
||||||
|
for (const block of Array.from(body.querySelectorAll(selector)).reverse()) {
|
||||||
|
if (block.closest('table,ul,ol,blockquote')) continue;
|
||||||
|
block.replaceWith(marker(), ...block.childNodes, marker());
|
||||||
|
}
|
||||||
|
let previous = false, pending = [];
|
||||||
|
function finish() {
|
||||||
|
const actual = pending.filter(br => !br.hasAttribute('data-dww11-boundary'));
|
||||||
|
let retained = false;
|
||||||
|
for (const br of pending) {
|
||||||
|
if (!br.hasAttribute('data-dww11-boundary')) continue;
|
||||||
|
if (previous && !actual.length && !retained) { br.removeAttribute('data-dww11-boundary'); retained = true; }
|
||||||
|
else br.remove();
|
||||||
|
}
|
||||||
|
pending = [];
|
||||||
|
}
|
||||||
|
const walker = body.ownerDocument.createTreeWalker(body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
|
||||||
|
const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.nodeType === 1 && node.tagName === 'BR') pending.push(node);
|
||||||
|
else if ((node.nodeType === 3 && node.textContent.trim()) || (node.nodeType === 1 && ['IMG', 'VIDEO', 'HR', 'IFRAME'].includes(node.tagName))) {
|
||||||
|
finish(); previous = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 末尾不保留由容器边界制造的额外换行。
|
||||||
|
for (const br of pending) if (br.hasAttribute('data-dww11-boundary')) br.remove();
|
||||||
|
}
|
||||||
|
function spaceImages(body) {
|
||||||
|
let previous = null, gapNodes = [], blankLines = 0;
|
||||||
|
const changes = [];
|
||||||
|
function content(kind, node) {
|
||||||
|
if (previous && previous.kind !== kind) {
|
||||||
|
let anchor = node;
|
||||||
|
while (anchor.parentElement && anchor.parentElement !== body &&
|
||||||
|
!['TD', 'TH', 'LI', 'BLOCKQUOTE'].includes(anchor.parentElement.tagName) && !anchor.parentElement.contains(previous.node)) anchor = anchor.parentElement;
|
||||||
|
changes.push({ anchor, nodes: gapNodes.slice(), count: Math.max(2, blankLines) });
|
||||||
|
}
|
||||||
|
previous = { kind, node }; gapNodes = []; blankLines = 0;
|
||||||
|
}
|
||||||
|
function visit(node) {
|
||||||
|
if (node.nodeType === 3) {
|
||||||
|
if (node.textContent.trim()) content('text', node); else gapNodes.push(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== 1 || ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(node.tagName)) return;
|
||||||
|
if (node.tagName === 'BR') { gapNodes.push(node); blankLines++; return; }
|
||||||
|
if (node.tagName === 'IMG') { content('image', node); return; }
|
||||||
|
for (const child of node.childNodes) visit(child);
|
||||||
|
}
|
||||||
|
for (const child of body.childNodes) visit(child);
|
||||||
|
for (const { anchor, nodes, count } of changes) {
|
||||||
|
for (const node of nodes) node.remove();
|
||||||
|
const fragment = body.ownerDocument.createDocumentFragment();
|
||||||
|
for (let i = 0; i < count; i++) fragment.append(body.ownerDocument.createElement('br'));
|
||||||
|
anchor.parentNode.insertBefore(fragment, anchor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function formatBody(body, author, sourceText = null) {
|
||||||
|
const doc = body.ownerDocument;
|
||||||
|
body.querySelectorAll('[data-dww11-wx-source="1"]').forEach(el => el.remove());
|
||||||
|
// Discuz 会把内联 color 值直接转为 [color=…]。
|
||||||
|
// !important 不能出现在该值中;颜色和字号只用原生 FONT 属性。
|
||||||
|
for (const el of [body, ...body.querySelectorAll('*')]) {
|
||||||
|
if (['SCRIPT', 'STYLE'].includes(el.tagName)) continue;
|
||||||
|
for (const property of ['font', 'font-size', 'color', '-webkit-text-fill-color']) {
|
||||||
|
el.style.removeProperty(property);
|
||||||
|
}
|
||||||
|
// 其他排版属性也不保留 CSS 优先级,避免它泄漏进论坛标签。
|
||||||
|
for (const property of Array.from(el.style)) {
|
||||||
|
if (el.style.getPropertyPriority(property)) {
|
||||||
|
el.style.setProperty(property, el.style.getPropertyValue(property));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!el.style.cssText) el.removeAttribute('style');
|
||||||
|
}
|
||||||
|
// 去掉采集器或旧版脚本生成的嵌套 FONT,保留其中的内容和图片。
|
||||||
|
for (const font of Array.from(body.querySelectorAll('font'))) {
|
||||||
|
font.replaceWith(...font.childNodes);
|
||||||
|
}
|
||||||
|
flattenParagraphs(body);
|
||||||
|
spaceImages(body);
|
||||||
|
// 所有正文内容和换行放在同一 FONT 中。不能使用只有 BR 的空 FONT:
|
||||||
|
// 论坛转换器可能清除空格式标签,导致空行退回默认1号或直接丢失。
|
||||||
|
const articleFont = doc.createElement('font');
|
||||||
|
articleFont.size = '4'; articleFont.color = '#000000';
|
||||||
|
articleFont.dataset.dww11WxBody = '1';
|
||||||
|
articleFont.append(...body.childNodes);
|
||||||
|
body.append(articleFont);
|
||||||
|
// A 自身的浏览器默认蓝色会覆盖外层继承色,链接文字另设黑色。
|
||||||
|
for (const link of articleFont.querySelectorAll('a')) {
|
||||||
|
const linkFont = doc.createElement('font');
|
||||||
|
linkFont.size = '4'; linkFont.color = '#000000';
|
||||||
|
linkFont.append(...link.childNodes); link.append(linkFont);
|
||||||
|
}
|
||||||
|
// 只用于编辑器图片适配;帖子展示仍交给网站原有图片/附件规则。
|
||||||
|
for (const img of articleFont.querySelectorAll('img')) {
|
||||||
|
img.style.maxWidth = '100%'; img.style.height = 'auto';
|
||||||
|
}
|
||||||
|
const source = doc.createElement('p');
|
||||||
|
source.dataset.dww11WxSource = '1';
|
||||||
|
const font = doc.createElement('font');
|
||||||
|
font.size = '1'; font.color = '#c0c0c0';
|
||||||
|
const lines = (sourceText ?? `来源:${author},如有侵权,请联系删除。`).split('\n');
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (index) font.append(doc.createElement('br'));
|
||||||
|
font.append(doc.createTextNode(line));
|
||||||
|
});
|
||||||
|
source.append(font); body.append(source);
|
||||||
|
}
|
||||||
|
function existingSource(body) {
|
||||||
|
// 按段落和 BR 读取末尾逻辑行,兼容嵌套 FONT、纯文本及末尾空段落。
|
||||||
|
const lines = [];
|
||||||
|
let parts = [];
|
||||||
|
function flush() {
|
||||||
|
const text = cleanText(parts.map(part => part.node.textContent.slice(part.start, part.end)).join(''));
|
||||||
|
if (text) lines.push({ text, parts });
|
||||||
|
parts = [];
|
||||||
|
}
|
||||||
|
function visit(node) {
|
||||||
|
if (node.nodeType === 3) {
|
||||||
|
// 保留文本中的显式换行,并记录精确位置,避免删除同容器中的正文。
|
||||||
|
const value = node.textContent;
|
||||||
|
let start = 0;
|
||||||
|
for (let i = 0; i <= value.length; i++) {
|
||||||
|
if (i !== value.length && value[i] !== '\n') continue;
|
||||||
|
// HTML 缩进换行通常只是空白;只有后续是来源或版权行才分行。
|
||||||
|
if (i < value.length && !/^\s*(?:(?:来源|文章来源|本文来源|图文来源|文字来源|作者|作者信息|来源作者|转自|转载自|出处)\s*[::]|如有侵权|若有侵权|侵权请|转载请|版权归)/.test(value.slice(i + 1))) continue;
|
||||||
|
if (i > start) parts.push({ node, start, end: i });
|
||||||
|
if (i < value.length) flush();
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== 1 || ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(node.tagName)) return;
|
||||||
|
if (node.tagName === 'BR') { flush(); return; }
|
||||||
|
if (['IMG', 'VIDEO', 'AUDIO', 'IFRAME', 'HR'].includes(node.tagName)) {
|
||||||
|
flush(); lines.push({ text: '\ufffc', parts: [] }); return;
|
||||||
|
}
|
||||||
|
const block = /^(P|DIV|SECTION|ARTICLE|FIGURE|FIGCAPTION|H[1-6]|LI|TR|BLOCKQUOTE|PRE)$/.test(node.tagName);
|
||||||
|
if (block) flush();
|
||||||
|
for (const child of node.childNodes) visit(child);
|
||||||
|
if (block) flush();
|
||||||
|
}
|
||||||
|
for (const child of body.childNodes) visit(child);
|
||||||
|
flush();
|
||||||
|
let first = -1;
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
if (/^(?:来源|文章来源|本文来源|图文来源|文字来源|作者|作者信息|来源作者|转自|转载自|出处)\s*[::]\s*\S/.test(lines[i].text)) {
|
||||||
|
first = i;
|
||||||
|
} else if (!/^(?:如有侵权|若有侵权|侵权请|转载请|版权归)/.test(lines[i].text)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (first < 0) return null;
|
||||||
|
const start = lines[first].parts[0];
|
||||||
|
const range = body.ownerDocument.createRange();
|
||||||
|
range.setStart(start.node, start.start);
|
||||||
|
range.setEnd(body, body.childNodes.length);
|
||||||
|
return {
|
||||||
|
text: lines.slice(first).map(line => line.text).join('\n'),
|
||||||
|
remove: () => range.deleteContents()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function runFormat(button) {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await ensureVisualEditor();
|
||||||
|
const body = editorBody();
|
||||||
|
const source = existingSource(body);
|
||||||
|
const hasText = cleanText(body.textContent).replace(source ? cleanText(source.text) : '', '').trim();
|
||||||
|
if (!hasText && !body.querySelector('img,video,audio,iframe,hr,table')) {
|
||||||
|
throw new Error('正文为空,请先输入或粘贴文章。');
|
||||||
|
}
|
||||||
|
const raw = source ? '' : prompt('未在文末检测到来源/作者信息,请补充来源公众号名称(取消则停止排版):');
|
||||||
|
if (raw === null) return;
|
||||||
|
const author = cleanText(raw);
|
||||||
|
if (!source && !author) throw new Error('来源公众号名称不能为空。');
|
||||||
|
button.textContent = '正在排版…';
|
||||||
|
// 已有来源保留原文,仅调整字号颜色;没有时才生成来源行。
|
||||||
|
source?.remove();
|
||||||
|
formatBody(body, author, source?.text ?? null);
|
||||||
|
alert(`排版完成。\n正文:4号黑色;来源:1号银色。\n${source ? source.text : `来源:${author}`}\n请人工检查后自行决定是否保存或发布。`);
|
||||||
|
} catch (error) { alert('排版未完成:' + error.message); }
|
||||||
|
finally {
|
||||||
|
setBusy(false);
|
||||||
|
button.textContent = '一键排版';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function run(button) {
|
||||||
|
if (busy) return;
|
||||||
|
const raw = prompt('粘贴微信公众号文章 URL:');
|
||||||
|
if (raw === null || !raw.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
let watcher;
|
||||||
|
try {
|
||||||
|
const url = normalizeURL(raw);
|
||||||
|
const jq = unsafeWindow.myJQ;
|
||||||
|
if (typeof jq !== 'function' || !jq.fn?.on) throw new Error('没有找到网站微信采集器的 myJQ,请刷新页面后重试。');
|
||||||
|
const plugin = document.querySelector('#e_controls .wxcj');
|
||||||
|
if (!plugin) throw new Error('当前页面没有微信采集组件。');
|
||||||
|
button.textContent = '读取标题和作者…';
|
||||||
|
const info = await getMetadata(url);
|
||||||
|
if (!info) return;
|
||||||
|
const subject = document.querySelector('#subject');
|
||||||
|
if (!subject) throw new Error('没有找到标题框。');
|
||||||
|
if (subject.maxLength > 0 && info.title.length > subject.maxLength) throw new Error('完整标题超过标题框长度限制,请先处理标题长度。');
|
||||||
|
await ensureVisualEditor();
|
||||||
|
button.textContent = '采集文章中…';
|
||||||
|
plugin.click();
|
||||||
|
const input = await waitFor(() => Array.from(document.querySelectorAll('.wxjc-dialog input')).find(visible));
|
||||||
|
const submit = input.closest('.wxjc-dialog').querySelector('.wxjc-subimt');
|
||||||
|
if (!submit) throw new Error('没有找到采集按钮。');
|
||||||
|
input.value = url;
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
watcher = watchImport(jq, url);
|
||||||
|
// 先登记失败处理,避免点击失败时留下未处理的 Promise。
|
||||||
|
watcher.result.catch(() => {});
|
||||||
|
submit.click();
|
||||||
|
await watcher.result;
|
||||||
|
button.textContent = '正在排版…';
|
||||||
|
const body = await waitFor(() => editorBody()?.innerHTML.trim() && editorBody());
|
||||||
|
formatBody(body, info.author);
|
||||||
|
subject.value = info.title;
|
||||||
|
subject.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
subject.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
subject.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
|
||||||
|
// 正文保持在可视化编辑器,由网站正常提交流程转成 BBCode。
|
||||||
|
// 不把 HTML 写入纯文本 message,也不调用任何保存/发布入口。
|
||||||
|
alert(`导入并排版完成。\n标题:${info.title}\n作者:${info.author}\n正文:4号黑色;来源:1号银色。\n请人工检查后自行决定是否保存或发布。`);
|
||||||
|
} catch (error) { alert('导入未完成:' + error.message); }
|
||||||
|
finally {
|
||||||
|
watcher?.cleanup();
|
||||||
|
setBusy(false);
|
||||||
|
button.textContent = '一键导入微信公众号并排版';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function mount() {
|
||||||
|
const toolbar = document.querySelector('#e_controls');
|
||||||
|
if (!toolbar || !document.querySelector('#subject')) return;
|
||||||
|
const style = 'position:relative;z-index:2;padding:0 12px;height:32px;line-height:30px;border:1px solid #159447;border-radius:4px;background:#159447;color:white;font-size:13px;cursor:pointer;';
|
||||||
|
// 右浮动的同一组内使用 flex,保证排版按钮始终位于导入按钮左侧。
|
||||||
|
let group = document.getElementById('dww11-wx-actions');
|
||||||
|
if (!group) {
|
||||||
|
group = document.createElement('span');
|
||||||
|
group.id = 'dww11-wx-actions';
|
||||||
|
group.style.cssText = 'float:right;display:inline-flex;align-items:center;gap:8px;margin:6px 8px;';
|
||||||
|
toolbar.append(group);
|
||||||
|
}
|
||||||
|
if (!document.getElementById(FORMAT_ID)) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.id = FORMAT_ID; button.type = 'button';
|
||||||
|
button.textContent = '一键排版'; button.style.cssText = style;
|
||||||
|
button.disabled = busy;
|
||||||
|
button.addEventListener('click', () => runFormat(button));
|
||||||
|
group.prepend(button);
|
||||||
|
}
|
||||||
|
if (toolbar.querySelector('.wxcj') && !document.getElementById(ID)) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.id = ID; button.type = 'button';
|
||||||
|
button.textContent = '一键导入微信公众号并排版'; button.style.cssText = style;
|
||||||
|
button.disabled = busy;
|
||||||
|
button.addEventListener('click', () => run(button));
|
||||||
|
group.append(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mount();
|
||||||
|
const observer = new MutationObserver(mount);
|
||||||
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
})();
|
||||||
@@ -0,0 +1,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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,73 @@
|
|||||||
|
# 为什么年轻人爱玩手机,背后的真相太无奈!
|
||||||
|
|
||||||
|
很多家长和长辈都在抱怨,现在的年轻人怎么整天抱着个手机,不是刷短视频就是打游戏,一点上进心都没有。
|
||||||
|
|
||||||
|
但老赵今天想替年轻人说句公道话!他们不是天生就爱玩手机,而是手机成了他们在有限条件下,唯一的消遣!
|
||||||
|
|
||||||
|
先算一笔账,现在出门随便玩点什么不要花钱?
|
||||||
|
|
||||||
|
一杯普通的奶茶二三十块,一场电影五六十块,吃顿火锅人均一百多,剧本杀,商场里买件衣服几百块。
|
||||||
|
|
||||||
|
就算是去公园散散步,来回的交通费、买瓶水也得花个十几块。
|
||||||
|
|
||||||
|
而玩手机呢?只要有一部手机和网络,就能看电影、听音乐、玩游戏、看小说,一毛钱不花就能娱乐一整天。
|
||||||
|
|
||||||
|
对于很多年轻人来说,这是唯一的放松方式。
|
||||||
|
|
||||||
|
刚毕业的大学生,月薪三四千,除去房租水电和吃饭,能剩下的钱寥寥无几。
|
||||||
|
|
||||||
|
工作几年的年轻人,要还房贷车贷,要攒钱过日子,每一分钱都得掰成两半花。
|
||||||
|
|
||||||
|
他们不是不想去旅游,不是不想培养什么高雅的兴趣爱好,而是这些都太贵了。
|
||||||
|
|
||||||
|
打个羽毛球/网球,都得交场地费!还不一定能约到有时间的朋友。
|
||||||
|
|
||||||
|
学个乐器、报个健身课,动辄几千上万的学费,对于普通年轻人来说,实在是太奢侈了。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
比没钱更无解的,是他们根本没有完整的时间。
|
||||||
|
|
||||||
|
现在的年轻人,早就失去了属于自己的休闲时间,每天写完作业/忙完工作,就已经天黑了。
|
||||||
|
|
||||||
|
这个时代的年轻人,学业压力/工作压力,比我们那个时代要大的多,根本没有那么多时间精力,去参加户外活动。
|
||||||
|
|
||||||
|
洗完澡躺到床上可能只剩一小时就要睡觉了。这些时间你能做什么?去跑步?换衣服热身都不够。
|
||||||
|
|
||||||
|
去看书?刚看两行就该睡觉了。
|
||||||
|
|
||||||
|
去约朋友?别人可能也在忙着自己的事情。
|
||||||
|
|
||||||
|
只有手机,能完美适配这种被切割得支离破碎的时间。拿起来就能玩,放下就能停,不需要任何准备,不需要任何人配合。
|
||||||
|
|
||||||
|
现实生活里没有即时反馈,但手机不一样子你刷一个短视频,3秒就能让你笑。你发一条动态,有人点赞马上就有被关注的感觉。
|
||||||
|
|
||||||
|
这种做了就有结果的确定感,是现实中极度稀缺的东西。年轻人不是爱玩手机,是他们太需要这种即时的快乐了。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
很多人说,那可以约朋友出来玩啊。
|
||||||
|
|
||||||
|
现在的社交不仅要花钱,还要花精力。约朋友不是说走就走的,你要提前一周约时间,要迁就每个人的时间安排。
|
||||||
|
|
||||||
|
要找大家都满意的地方,要照顾每个人的情绪。对于已经被工作榨干了所有精力的年轻人来说,这种社交比上班还累!
|
||||||
|
|
||||||
|
而手机上的社交是低压力的,你想聊就聊,不想聊就隔两个小时再回,累了就直接下线,没有人会怪你。
|
||||||
|
|
||||||
|
老赵不是在鼓励大家沉迷手机,而是希望大家能看到问题的本质。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
不是年轻人变懒了,是免费的快乐消失了。
|
||||||
|
|
||||||
|
以前我们小时候,跳皮筋、踢毽子、捉迷藏,随时能叫来一堆朋友一块玩,而且这些都不需要花钱。
|
||||||
|
|
||||||
|
但现在的城市里,到处都是高楼大厦,没有那么多可以自由玩耍的地方,打羽毛球/打网球/游泳,这些场地都是收费的!
|
||||||
|
|
||||||
|
所有的快乐都变成了商品,明码标价摆在那里。
|
||||||
|
|
||||||
|
与其指责年轻人沉迷手机,不如问问他们为什么没有其他更好的选择。如果他们有足够的钱和时间,有更多元的娱乐选择,谁谁不愿意去体验丰富多彩的生活呢?
|
||||||
|
|
||||||
|
所以,下次再看到年轻人玩手机的时候,不要急着去批评他们。多给他们一点理解和包容。毕竟,谁不想活得轻松一点,快乐一点呢?
|
||||||
|
|
||||||
|
来源:老赵讲道理,如有侵权,请联系删除。
|
||||||
@@ -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
@@ -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 图片/链接暂不支持,会报错。
|
||||||
|
- 图片必须用 `` 这类相对路径引用;支持子目录、空格及常见括号路径。远程图片、目录外路径、缺图、空文件、达到 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": [
|
||||||
|
{
|
||||||
|
"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、编辑器代码编写,上传参数在运行时从当前页面读取,未写入账号或凭证。本次未安装油猴脚本,也未进行真实网站上传;最终在线兼容性需由你首次测试确认。如果网站以后更换编辑器或上传协议,应更新适配部分。
|
||||||
Reference in new issue
Block a user