Skip to content

Instantly share code, notes, and snippets.

@JackDrogon
Last active June 10, 2026 15:49
Show Gist options
  • Select an option

  • Save JackDrogon/c49df513ba6ce5c07fdc6ec76522fd5d to your computer and use it in GitHub Desktop.

Select an option

Save JackDrogon/c49df513ba6ce5c07fdc6ec76522fd5d to your computer and use it in GitHub Desktop.
微信文章 Obsidian Clipper 辅助油猴脚本

微信文章 Obsidian Clipper 辅助油猴脚本

作者:Jack Drogon

这个 Tampermonkey 脚本用于辅助 Obsidian Web Clipper 剪藏微信公众号文章,主要解决两个问题:

  1. 微信图片懒加载导致 Clipper 抓不到 mmbiz.qpic.cn 图片地址。
  2. 微信 / mdnice 代码块在剪藏后被压成一行,或同时出现高亮版本和规范化版本。

功能

  • 将微信公众号正文图片的 data-src 等真实地址补充到 src
  • 清理微信图片 URL 中的 tpwxfromwx_lazy#imgIndex
  • 处理微信公众号 code-snippet__fix 代码块。
  • 处理 mdnice编辑器 生成的 <pre><code><span>...<br>...</code></pre> 代码块。
  • 使用 Shadow DOM 保留页面上的原始高亮显示,同时让普通 DOM 中只留下适合 Obsidian Clipper 提取的标准代码块,避免重复剪藏。

推荐 Obsidian Clipper 模板

---
title: {{title}}
source: {{url}}
created: {{date}}
---

# {{title}}

{{selectorHtml:#js_content|markdown}}

使用方法

  1. 安装 Tampermonkey。
  2. 新建脚本,复制 wechat-obsidian-clipper-helper.user.js 的内容。
  3. 打开微信公众号文章页面。
  4. 等页面加载完成后使用 Obsidian Web Clipper 剪藏。

说明

这个脚本只在 mp.weixin.qq.com 页面运行。它不会下载图片到本地;如果需要图片本地化,可以再配合 Obsidian 的附件下载功能或相关插件。

// ==UserScript==
// @name 微信文章 Obsidian Clipper 辅助
// @namespace http://tampermonkey.net/
// @version 1.3
// @description 优化微信公众号文章图片和代码块,方便 Obsidian Clipper 剪藏
// @author Jack Drogon
// @match *://mp.weixin.qq.com/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(function() {
'use strict';
function normalizeImageUrl(value) {
if (!value) return '';
const rawUrl = value.trim();
if (
rawUrl.startsWith('data:') ||
rawUrl.startsWith('blob:') ||
rawUrl.startsWith('about:')
) {
return '';
}
try {
const url = new URL(rawUrl, window.location.href);
if (url.hostname === 'mmbiz.qpic.cn') {
url.searchParams.delete('tp');
url.searchParams.delete('wxfrom');
url.searchParams.delete('wx_lazy');
if (/^#imgIndex=\d+$/i.test(url.hash)) {
url.hash = '';
}
}
return url.href;
} catch (error) {
return rawUrl;
}
}
function isPlaceholderSrc(value) {
const src = (value || '').trim().toLowerCase();
return (
!src ||
src === 'about:blank' ||
src.startsWith('data:image/') ||
src.includes('pic_blank') ||
src.includes('placeholder') ||
src.includes('transparent')
);
}
function getRealImageSrc(img) {
return (
img.getAttribute('data-src') ||
img.getAttribute('data-original-src') ||
img.getAttribute('data-original') ||
img.getAttribute('data-lazy-src') ||
img.getAttribute('data-actualsrc') ||
img.getAttribute('data-url') ||
img.getAttribute('data-image') ||
img.getAttribute('data-img-src') ||
''
);
}
function fixImage(img, index) {
const realSrc = normalizeImageUrl(getRealImageSrc(img));
const currentSrc = normalizeImageUrl(img.getAttribute('src'));
if (!realSrc) return false;
const shouldReplace =
isPlaceholderSrc(img.getAttribute('src')) ||
currentSrc !== realSrc;
if (shouldReplace) {
img.setAttribute('src', realSrc);
}
img.setAttribute('data-obsidian-clipper-src', realSrc);
if (!img.getAttribute('alt')) {
img.setAttribute('alt', `wechat-image-${index}`);
}
return true;
}
function fixArticleImages() {
document.querySelectorAll('#js_content img').forEach((img, index) => {
fixImage(img, index);
});
}
function normalizeWeChatSnippetCodeBlocks() {
const blocks = document.querySelectorAll('#js_content .code-snippet__fix');
blocks.forEach(block => {
if (block.getAttribute('data-obsidian-code-host') === 'true') return;
if (block.closest('[data-obsidian-code-host="true"]')) return;
const sourcePre = block.querySelector('pre');
if (!sourcePre) return;
const language = normalizeCodeLanguage(
sourcePre.getAttribute('data-lang') ||
sourcePre.getAttribute('data-language') ||
detectLanguageFromClass(sourcePre) ||
''
);
const codeText = extractSnippetCodeText(sourcePre);
if (!codeText.trim()) return;
replaceWithShadowCodeHost(block, codeText, language);
});
}
function normalizeMdniceCodeBlocks() {
const blocks = Array.from(document.querySelectorAll('#js_content pre')).filter(pre => {
if (pre.getAttribute('data-obsidian-code-host') === 'true') return false;
if (pre.closest('[data-obsidian-code-host="true"]')) return false;
const code = pre.querySelector('code');
const tool = pre.getAttribute('data-tool') || '';
return (
code &&
(
tool.includes('mdnice') ||
tool.includes('mdnice编辑器') ||
code.querySelector('br')
)
);
});
blocks.forEach(pre => {
const code = pre.querySelector('code');
if (!code) return;
const language = normalizeCodeLanguage(
pre.getAttribute('data-lang') ||
code.getAttribute('data-lang') ||
code.getAttribute('data-language') ||
detectLanguageFromClass(pre) ||
detectLanguageFromClass(code) ||
detectLanguageFromCode(code.innerText || code.textContent || '')
);
const codeText = normalizeCodeText(extractTextWithBreaks(code));
if (!codeText.trim()) return;
replaceWithShadowCodeHost(pre, codeText, language);
});
}
function replaceWithShadowCodeHost(originalBlock, codeText, language) {
const host = document.createElement('section');
const shadow = host.attachShadow({ mode: 'open' });
const displayClone = originalBlock.cloneNode(true);
const clipperBlock = createClipperCodeBlock(codeText, language);
host.setAttribute('data-obsidian-code-host', 'true');
host.style.display = 'block';
displayClone.removeAttribute('data-obsidian-code-host');
displayClone.setAttribute('data-obsidian-code-display', 'true');
shadow.appendChild(createShadowStyle());
shadow.appendChild(displayClone);
// 普通 DOM 中只保留给 Clipper 读取的标准代码块。
// Shadow DOM 没有 slot,所以这个节点不会显示在页面上。
host.appendChild(clipperBlock);
originalBlock.replaceWith(host);
}
function createShadowStyle() {
const style = document.createElement('style');
style.textContent = `
:host {
display: block;
}
pre {
max-width: 100%;
box-sizing: border-box;
}
.code-snippet__fix {
word-wrap: break-word !important;
font-size: 14px;
margin: 10px 0;
color: #333;
position: relative;
background-color: rgba(0, 0, 0, 0.03);
border: 1px solid #f0f0f0;
border-radius: 2px;
display: flex !important;
line-height: 26px;
overflow: hidden;
}
.code-snippet__fix .code-snippet__line-index {
counter-reset: line;
flex-shrink: 0;
height: 100%;
margin: 0;
padding: 1em 0.75em;
list-style-type: none;
background: rgba(0, 0, 0, 0.02);
}
.code-snippet__fix .code-snippet__line-index li {
list-style-type: none;
text-align: right;
min-width: 1.5em;
height: 26px;
line-height: 26px;
}
.code-snippet__fix .code-snippet__line-index li::before {
counter-increment: line;
content: counter(line);
color: rgba(0, 0, 0, 0.35);
font-size: 12px;
}
.code-snippet__fix pre {
flex: 1;
margin: 0;
padding: 1em;
overflow-x: auto;
white-space: normal;
-webkit-overflow-scrolling: touch;
}
.code-snippet__fix code {
display: flex !important;
position: relative;
text-align: left;
white-space: pre !important;
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
line-height: 26px;
min-height: 26px;
}
.code-snippet__fix code span {
white-space: pre !important;
}
.code-snippet_outer {
white-space: pre !important;
}
.code-snippet__keyword {
color: #a626a4;
}
.code-snippet__string {
color: #50a14f;
}
.code-snippet__number {
color: #986801;
}
.code-snippet__comment {
color: #a0a1a7;
font-style: italic;
}
.code-snippet__built_in {
color: #4078f2;
}
.code-snippet__function {
color: #4078f2;
}
.code-snippet__literal {
color: #0184bc;
}
.code-snippet__params {
color: #383a42;
}
`;
return style;
}
function createClipperCodeBlock(codeText, language) {
const pre = document.createElement('pre');
const code = document.createElement('code');
pre.setAttribute('data-obsidian-code-for-clipper', 'true');
pre.style.whiteSpace = 'pre';
code.style.whiteSpace = 'pre';
if (language) {
code.className = `language-${language}`;
code.setAttribute('data-lang', language);
}
code.textContent = normalizeCodeText(codeText);
pre.appendChild(code);
return pre;
}
function extractSnippetCodeText(sourcePre) {
const codeNodes = Array.from(sourcePre.querySelectorAll('code'));
if (codeNodes.length > 1) {
return codeNodes
.map(code => {
if (code.querySelector('br') && !code.textContent.trim()) {
return '';
}
return normalizeCodeLine(code.textContent || '');
})
.join('\n')
.replace(/\n+$/g, '');
}
const sourceCode = sourcePre.querySelector('code') || sourcePre;
return extractTextWithBreaks(sourceCode);
}
function extractTextWithBreaks(root) {
function walk(node) {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || '';
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return '';
}
const element = node;
if (element.tagName === 'BR') {
return '\n';
}
if (
element.tagName === 'SCRIPT' ||
element.tagName === 'STYLE' ||
element.tagName === 'BUTTON'
) {
return '';
}
return Array.from(element.childNodes).map(walk).join('');
}
return walk(root);
}
function normalizeCodeText(text) {
return (text || '')
.replace(/\u00a0/g, ' ')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/[ \t]+\n/g, '\n')
.replace(/^\n+|\n+$/g, '');
}
function normalizeCodeLine(line) {
return (line || '')
.replace(/\u00a0/g, ' ')
.replace(/\r/g, '')
.replace(/\n/g, '');
}
function normalizeCodeLanguage(value) {
return (value || '')
.trim()
.toLowerCase()
.replace(/^language-/, '')
.replace(/^lang-/, '')
.replace(/^brush-/, '')
.replace(/[^a-z0-9_+-]/g, '');
}
function detectLanguageFromClass(element) {
if (!element) return '';
const classText = [
element.className || '',
element.closest('.code-snippet__fix')?.className || ''
].join(' ');
const languageMatch = classText.match(/(?:language|lang|brush)-([a-z0-9_+-]+)/i);
if (languageMatch) return languageMatch[1];
const wechatMatch = classText.match(/code-snippet__([a-z0-9_+-]+)/i);
if (wechatMatch && wechatMatch[1] !== 'fix') return wechatMatch[1];
return '';
}
function detectLanguageFromCode(text) {
const source = text || '';
if (
source.includes('import torch') ||
source.includes('import numpy') ||
source.includes('def ') ||
source.includes('class ') ||
source.includes('self.')
) {
return 'python';
}
if (
source.includes('const ') ||
source.includes('let ') ||
source.includes('function ') ||
source.includes('=>')
) {
return 'javascript';
}
if (
source.includes('interface ') ||
source.includes(': string') ||
source.includes(': number')
) {
return 'typescript';
}
if (
source.includes('package main') ||
source.includes('func ')
) {
return 'go';
}
return '';
}
function normalizeCodeBlocks() {
normalizeWeChatSnippetCodeBlocks();
normalizeMdniceCodeBlocks();
}
function run() {
fixArticleImages();
normalizeCodeBlocks();
}
run();
setTimeout(run, 1000);
setTimeout(run, 3000);
setTimeout(run, 5000);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment