commit 30739db7ced24e07000fc200d9b239eaa28acfc1 Author: Longtail Date: Sat Oct 31 14:47:15 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95f6fa9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +__pycache__/ +*.py[cod] +.venv/ +*.dww11.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f49cc1 --- /dev/null +++ b/README.md @@ -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。 + diff --git a/dww11-article-importer.user.js b/dww11-article-importer.user.js new file mode 100644 index 0000000..d0cb909 --- /dev/null +++ b/dww11-article-importer.user.js @@ -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 = `
本地文章导入 · 1.0
+ 选择整个文章文件夹,检查后上传配图并填写。发布由你操作。网站自带的自动保存仍可能运行。 +

+ + +

尚未选择目录

+ + +
`; + 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() || / { 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 }; +}); diff --git a/dww11-wechat-oneclick.user.js b/dww11-wechat-oneclick.user.js new file mode 100644 index 0000000..afc0684 --- /dev/null +++ b/dww11-wechat-oneclick.user.js @@ -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 }); +})(); diff --git a/dww11_make_package.py b/dww11_make_package.py new file mode 100644 index 0000000..13afdc2 --- /dev/null +++ b/dww11_make_package.py @@ -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() diff --git a/wechat_official_acc_article_downloader/README.md b/wechat_official_acc_article_downloader/README.md new file mode 100644 index 0000000..9bba3a9 --- /dev/null +++ b/wechat_official_acc_article_downloader/README.md @@ -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 地址。 diff --git a/wechat_official_acc_article_downloader/download_article.py b/wechat_official_acc_article_downloader/download_article.py new file mode 100644 index 0000000..df35f3b --- /dev/null +++ b/wechat_official_acc_article_downloader/download_article.py @@ -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' 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()) diff --git a/wechat_official_acc_article_downloader/requirements.txt b/wechat_official_acc_article_downloader/requirements.txt new file mode 100644 index 0000000..4d42bf4 --- /dev/null +++ b/wechat_official_acc_article_downloader/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.32,<3 +beautifulsoup4>=4.12,<5 +markdownify>=1.1,<2 diff --git a/wechat_official_acc_article_downloader/为什么年轻人爱玩手机,背后的真相太无奈!/image001.jpg b/wechat_official_acc_article_downloader/为什么年轻人爱玩手机,背后的真相太无奈!/image001.jpg new file mode 100644 index 0000000..8cc11db Binary files /dev/null and b/wechat_official_acc_article_downloader/为什么年轻人爱玩手机,背后的真相太无奈!/image001.jpg differ diff --git a/wechat_official_acc_article_downloader/为什么年轻人爱玩手机,背后的真相太无奈!/image002.jpg b/wechat_official_acc_article_downloader/为什么年轻人爱玩手机,背后的真相太无奈!/image002.jpg new file mode 100644 index 0000000..2db3a9e Binary files /dev/null and b/wechat_official_acc_article_downloader/为什么年轻人爱玩手机,背后的真相太无奈!/image002.jpg differ diff --git a/wechat_official_acc_article_downloader/为什么年轻人爱玩手机,背后的真相太无奈!/image003.jpg b/wechat_official_acc_article_downloader/为什么年轻人爱玩手机,背后的真相太无奈!/image003.jpg new file mode 100644 index 0000000..8cc11db Binary files /dev/null and b/wechat_official_acc_article_downloader/为什么年轻人爱玩手机,背后的真相太无奈!/image003.jpg differ diff --git a/wechat_official_acc_article_downloader/为什么年轻人爱玩手机,背后的真相太无奈!/为什么年轻人爱玩手机,背后的真相太无奈!.md b/wechat_official_acc_article_downloader/为什么年轻人爱玩手机,背后的真相太无奈!/为什么年轻人爱玩手机,背后的真相太无奈!.md new file mode 100644 index 0000000..6932ca2 --- /dev/null +++ b/wechat_official_acc_article_downloader/为什么年轻人爱玩手机,背后的真相太无奈!/为什么年轻人爱玩手机,背后的真相太无奈!.md @@ -0,0 +1,73 @@ +# 为什么年轻人爱玩手机,背后的真相太无奈! + +很多家长和长辈都在抱怨,现在的年轻人怎么整天抱着个手机,不是刷短视频就是打游戏,一点上进心都没有。 + +但老赵今天想替年轻人说句公道话!他们不是天生就爱玩手机,而是手机成了他们在有限条件下,唯一的消遣! + +先算一笔账,现在出门随便玩点什么不要花钱? + +一杯普通的奶茶二三十块,一场电影五六十块,吃顿火锅人均一百多,剧本杀,商场里买件衣服几百块。 + +就算是去公园散散步,来回的交通费、买瓶水也得花个十几块。 + +而玩手机呢?只要有一部手机和网络,就能看电影、听音乐、玩游戏、看小说,一毛钱不花就能娱乐一整天。 + +对于很多年轻人来说,这是唯一的放松方式。 + +刚毕业的大学生,月薪三四千,除去房租水电和吃饭,能剩下的钱寥寥无几。 + +工作几年的年轻人,要还房贷车贷,要攒钱过日子,每一分钱都得掰成两半花。 + +他们不是不想去旅游,不是不想培养什么高雅的兴趣爱好,而是这些都太贵了。 + +打个羽毛球/网球,都得交场地费!还不一定能约到有时间的朋友。 + +学个乐器、报个健身课,动辄几千上万的学费,对于普通年轻人来说,实在是太奢侈了。 + +![](image001.jpg) + +比没钱更无解的,是他们根本没有完整的时间。 + +现在的年轻人,早就失去了属于自己的休闲时间,每天写完作业/忙完工作,就已经天黑了。 + +这个时代的年轻人,学业压力/工作压力,比我们那个时代要大的多,根本没有那么多时间精力,去参加户外活动。 + +洗完澡躺到床上可能只剩一小时就要睡觉了。这些时间你能做什么?去跑步?换衣服热身都不够。 + +去看书?刚看两行就该睡觉了。 + +去约朋友?别人可能也在忙着自己的事情。 + +只有手机,能完美适配这种被切割得支离破碎的时间。拿起来就能玩,放下就能停,不需要任何准备,不需要任何人配合。 + +现实生活里没有即时反馈,但手机不一样子你刷一个短视频,3秒就能让你笑。你发一条动态,有人点赞马上就有被关注的感觉。 + +这种做了就有结果的确定感,是现实中极度稀缺的东西。年轻人不是爱玩手机,是他们太需要这种即时的快乐了。 + +![](image002.jpg) + +很多人说,那可以约朋友出来玩啊。 + +现在的社交不仅要花钱,还要花精力。约朋友不是说走就走的,你要提前一周约时间,要迁就每个人的时间安排。 + +要找大家都满意的地方,要照顾每个人的情绪。对于已经被工作榨干了所有精力的年轻人来说,这种社交比上班还累! + +而手机上的社交是低压力的,你想聊就聊,不想聊就隔两个小时再回,累了就直接下线,没有人会怪你。 + +老赵不是在鼓励大家沉迷手机,而是希望大家能看到问题的本质。 + +![](image003.jpg) + +不是年轻人变懒了,是免费的快乐消失了。 + +以前我们小时候,跳皮筋、踢毽子、捉迷藏,随时能叫来一堆朋友一块玩,而且这些都不需要花钱。 + +但现在的城市里,到处都是高楼大厦,没有那么多可以自由玩耍的地方,打羽毛球/打网球/游泳,这些场地都是收费的! + +所有的快乐都变成了商品,明码标价摆在那里。 + +与其指责年轻人沉迷手机,不如问问他们为什么没有其他更好的选择。如果他们有足够的钱和时间,有更多元的娱乐选择,谁谁不愿意去体验丰富多彩的生活呢? + +所以,下次再看到年轻人玩手机的时候,不要急着去批评他们。多给他们一点理解和包容。毕竟,谁不想活得轻松一点,快乐一点呢? + +来源:老赵讲道理,如有侵权,请联系删除。 diff --git a/wechat_official_acc_article_downloader/微信一键导入脚本说明.md b/wechat_official_acc_article_downloader/微信一键导入脚本说明.md new file mode 100644 index 0000000..16864d5 --- /dev/null +++ b/wechat_official_acc_article_downloader/微信一键导入脚本说明.md @@ -0,0 +1,59 @@ +# 微信公众号一键导入并排版 + +脚本版本:1.0.7。脚本文件:`dww11-wechat-oneclick.user.js`。与原有“本地文章目录导入”脚本独立,可按需同时保留。 + +## 安装与使用 + +1. 在 Tampermonkey 中新建脚本,把上述文件的全部内容复制进去,保存脚本。 +2. 刷新广谈大湾网的发帖页面。编辑器工具栏右侧会出现绿色“一键导入微信公众号并排版”按钮。 +3. 点击按钮,粘贴微信文章链接。首次运行如 Tampermonkey 询问是否允许访问 `mp.weixin.qq.com`,允许后才能自动读取标题和作者。 +4. 脚本通过网站原有 `.wxcj` 组件打开采集输入框,填写链接并触发采集。完成后请人工检查文章。 + +安装步骤中的“保存”仅指保存 Tampermonkey 脚本。脚本没有点击网站的保存草稿或发表按钮,也不会自动提交帖子。 + +## 处理规则 + +- 标题:从微信页面的标题元素读取纯文本,避免把 `` 当成标题。 +- 来源名称:只读取蓝色链接 `#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 会被折叠而使图片下方没有空行的问题。图文间的换行移至下一段内容开头,使用 `
` 表示纯换行,不插入空格或 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 的字体、段落和图片间距处理保持原样。 diff --git a/本地文章导入脚本使用说明.md b/本地文章导入脚本使用说明.md new file mode 100644 index 0000000..22292c2 --- /dev/null +++ b/本地文章导入脚本使用说明.md @@ -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、编辑器代码编写,上传参数在运行时从当前页面读取,未写入账号或凭证。本次未安装油猴脚本,也未进行真实网站上传;最终在线兼容性需由你首次测试确认。如果网站以后更换编辑器或上传协议,应更新适配部分。