md2any 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -0
- package/SKILL.md +80 -0
- package/bin/md2any.js +227 -0
- package/lib/actions.js +262 -0
- package/lib/converter.js +864 -0
- package/lib/extract.js +195 -0
- package/lib/llm.js +281 -0
- package/lib/social.js +264 -0
- package/lib/store.js +70 -0
- package/lib/themes.js +588 -0
- package/lib/zhihu.js +666 -0
- package/package.json +65 -0
- package/scripts/social_worker.js +774 -0
- package/scripts/xhs_screenshot.js +260 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* xhs_screenshot.js — 用 Playwright (Node.js) 将 HTML 截图为多张小红书图片
|
|
5
|
+
*
|
|
6
|
+
* 输出协议(stdout 每行一个):
|
|
7
|
+
* INFO:<message> — 进度信息
|
|
8
|
+
* SAVED:<filepath> — 已保存的图片路径
|
|
9
|
+
* DONE:<count> — 完成,共 count 张
|
|
10
|
+
* NEED_INSTALL — 未找到 Chromium,需要下载
|
|
11
|
+
* ERROR:<message> — 致命错误
|
|
12
|
+
*
|
|
13
|
+
* 用法:
|
|
14
|
+
* node xhs_screenshot.js <html_file> <output_dir> \
|
|
15
|
+
* [--width 1080] [--height 1440] [--padding 40] [--bg '#ffffff']
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
|
|
22
|
+
// ── 解析 CLI 参数 ────────────────────────────────────────────────────────────
|
|
23
|
+
const argv = process.argv.slice(2);
|
|
24
|
+
const htmlFile = argv[0];
|
|
25
|
+
const outputDir = argv[1];
|
|
26
|
+
|
|
27
|
+
function flag(name, def) {
|
|
28
|
+
const i = argv.indexOf(name);
|
|
29
|
+
return (i >= 0 && argv[i + 1]) ? argv[i + 1] : def;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const imgW = parseInt(flag('--width', '1080'), 10);
|
|
33
|
+
const imgH = parseInt(flag('--height', '1440'), 10);
|
|
34
|
+
const padding = parseInt(flag('--padding', '40'), 10);
|
|
35
|
+
const bg = flag('--bg', '#ffffff');
|
|
36
|
+
|
|
37
|
+
if (!htmlFile || !outputDir) {
|
|
38
|
+
process.stderr.write('Usage: node xhs_screenshot.js <html_file> <output_dir> [--width N] [--height N] [--padding N] [--bg COLOR]\n');
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── 查找 Chromium 可执行文件 ─────────────────────────────────────────────────
|
|
43
|
+
function findChromium() {
|
|
44
|
+
const home = os.homedir();
|
|
45
|
+
|
|
46
|
+
// 1. Playwright 管理的 Chromium(python playwright / node playwright 共用缓存)
|
|
47
|
+
const cacheDir = path.join(home, '.cache', 'ms-playwright');
|
|
48
|
+
if (fs.existsSync(cacheDir)) {
|
|
49
|
+
const entries = fs.readdirSync(cacheDir).filter(e => e.startsWith('chromium'));
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const candidates = {
|
|
52
|
+
darwin: path.join(cacheDir, entry, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
|
|
53
|
+
linux: path.join(cacheDir, entry, 'chrome-linux', 'chrome'),
|
|
54
|
+
win32: path.join(cacheDir, entry, 'chrome-win', 'chrome.exe'),
|
|
55
|
+
};
|
|
56
|
+
const p = candidates[process.platform];
|
|
57
|
+
if (p && fs.existsSync(p)) return p;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2. 系统已安装的浏览器
|
|
62
|
+
const system = {
|
|
63
|
+
darwin: [
|
|
64
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
65
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
66
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
67
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
68
|
+
],
|
|
69
|
+
linux: [
|
|
70
|
+
'/usr/bin/google-chrome', '/usr/bin/google-chrome-stable',
|
|
71
|
+
'/usr/bin/chromium-browser', '/usr/bin/chromium',
|
|
72
|
+
'/snap/bin/chromium',
|
|
73
|
+
],
|
|
74
|
+
win32: [
|
|
75
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
76
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
77
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
for (const p of (system[process.platform] || [])) {
|
|
81
|
+
if (fs.existsSync(p)) return p;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── 解析颜色 ─────────────────────────────────────────────────────────────────
|
|
88
|
+
function parseColor(hex) {
|
|
89
|
+
let c = hex.replace('#', '');
|
|
90
|
+
if (c.length === 3) c = c.split('').map(x => x + x).join('');
|
|
91
|
+
return [parseInt(c.slice(0, 2), 16), parseInt(c.slice(2, 4), 16), parseInt(c.slice(4, 6), 16)];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── 主逻辑(完全复刻 Python 版) ────────────────────────────────────────────
|
|
95
|
+
async function main() {
|
|
96
|
+
const { PNG } = require('pngjs');
|
|
97
|
+
|
|
98
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
99
|
+
|
|
100
|
+
const executablePath = findChromium();
|
|
101
|
+
if (!executablePath) {
|
|
102
|
+
console.log('NEED_INSTALL');
|
|
103
|
+
process.exit(2);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log(`INFO:使用浏览器: ${path.basename(path.dirname(executablePath))}`);
|
|
107
|
+
|
|
108
|
+
const { chromium } = require('playwright-core');
|
|
109
|
+
const browser = await chromium.launch({
|
|
110
|
+
executablePath,
|
|
111
|
+
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-web-security'],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
// ── 1. Playwright 渲染,用滚动拼接替代 fullPage 截图 ─────────────────────
|
|
116
|
+
// Chromium canvas 高度上限约 16384px,fullPage: true 会在超高页面上静默截断。
|
|
117
|
+
// 改用分段滚动截图后拼接,突破该限制。
|
|
118
|
+
const TILE_H = 4096; // 每段视口高度(px)
|
|
119
|
+
const SCALE = 2; // deviceScaleFactor:输出图片为逻辑尺寸的 2 倍,提升分辨率
|
|
120
|
+
const page = await browser.newPage({ viewport: { width: imgW, height: TILE_H }, deviceScaleFactor: SCALE });
|
|
121
|
+
const fileUrl = 'file://' + path.resolve(htmlFile);
|
|
122
|
+
await page.goto(fileUrl, { waitUntil: 'networkidle', timeout: 30000 });
|
|
123
|
+
await page.waitForTimeout(800);
|
|
124
|
+
|
|
125
|
+
// 取页面实际总高度
|
|
126
|
+
const totalPageH = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
127
|
+
console.log(`INFO:页面总高度 ${totalPageH}px,开始分段截图…`);
|
|
128
|
+
|
|
129
|
+
// 逐段截图
|
|
130
|
+
// 关键:浏览器限制滚动位置不超过 (scrollHeight - viewportHeight),所以最后一段
|
|
131
|
+
// 的实际 scrollY 可能比期望值小,需要读取实际值再决定取哪些行。
|
|
132
|
+
const tiles = [];
|
|
133
|
+
let targetY = 0;
|
|
134
|
+
while (targetY < totalPageH) {
|
|
135
|
+
await page.evaluate(y => window.scrollTo(0, y), targetY);
|
|
136
|
+
await page.waitForTimeout(80);
|
|
137
|
+
const actualY = await page.evaluate(() => window.scrollY);
|
|
138
|
+
const tileBuf = await page.screenshot({ type: 'png' });
|
|
139
|
+
const tile = PNG.sync.read(tileBuf);
|
|
140
|
+
// 本段截图对应页面 [actualY, actualY + TILE_H),取其中属于 [targetY, totalPageH) 的行
|
|
141
|
+
const rowStart = targetY - actualY; // 本段截图中,targetY 对应的行号
|
|
142
|
+
const rowEnd = Math.min(TILE_H, totalPageH - actualY); // 本段截图中,页面末尾对应的行号
|
|
143
|
+
const validH = rowEnd - rowStart;
|
|
144
|
+
// rowStart/validH 是 CSS 逻辑像素,乘以 SCALE 得到物理像素行号
|
|
145
|
+
tiles.push({ png: tile, rowStart: rowStart * SCALE, validH: validH * SCALE });
|
|
146
|
+
console.log(`INFO:已截 targetY=${targetY} actualY=${actualY} rowStart=${rowStart} validH=${validH}`);
|
|
147
|
+
targetY += TILE_H;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 拼接所有 tile 为一张完整图
|
|
151
|
+
const W = tiles[0].png.width;
|
|
152
|
+
const H = tiles.reduce((s, t) => s + t.validH, 0);
|
|
153
|
+
console.log(`INFO:截图完成,拼接尺寸 ${W}x${H},开始分割`);
|
|
154
|
+
const combined = new PNG({ width: W, height: H });
|
|
155
|
+
let dstY = 0;
|
|
156
|
+
for (const { png, rowStart, validH } of tiles) {
|
|
157
|
+
for (let row = 0; row < validH; row++) {
|
|
158
|
+
const srcOff = (rowStart + row) * W * 4;
|
|
159
|
+
const dstOff = (dstY + row) * W * 4;
|
|
160
|
+
png.data.copy(combined.data, dstOff, srcOff, srcOff + W * 4);
|
|
161
|
+
}
|
|
162
|
+
dstY += validH;
|
|
163
|
+
}
|
|
164
|
+
const data = combined.data;
|
|
165
|
+
|
|
166
|
+
// ── 3. 添加上下 padding(与 Python 版 Image.new + paste 一致)──────────
|
|
167
|
+
// padding/imgH 是 CLI 逻辑像素,乘以 SCALE 换算为物理像素
|
|
168
|
+
const physPadding = padding * SCALE;
|
|
169
|
+
const physImgH = imgH * SCALE;
|
|
170
|
+
const padH = H + 2 * physPadding;
|
|
171
|
+
const padded = new PNG({ width: W, height: padH });
|
|
172
|
+
// 填充背景色
|
|
173
|
+
const bgRgb = parseColor(bg);
|
|
174
|
+
for (let y = 0; y < padH; y++) {
|
|
175
|
+
for (let x = 0; x < W; x++) {
|
|
176
|
+
const i = (y * W + x) * 4;
|
|
177
|
+
padded.data[i] = bgRgb[0];
|
|
178
|
+
padded.data[i + 1] = bgRgb[1];
|
|
179
|
+
padded.data[i + 2] = bgRgb[2];
|
|
180
|
+
padded.data[i + 3] = 255;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// 把原图贴到 (0, physPadding) 位置
|
|
184
|
+
for (let y = 0; y < H; y++) {
|
|
185
|
+
const srcOff = y * W * 4;
|
|
186
|
+
const dstOff = (y + physPadding) * W * 4;
|
|
187
|
+
data.copy(padded.data, dstOff, srcOff, srcOff + W * 4);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const totalW = W;
|
|
191
|
+
const totalH = padH;
|
|
192
|
+
const pixels = padded.data;
|
|
193
|
+
|
|
194
|
+
// ── 4. 智能分片:尽量在空白行处切割(与 Python 版 is_blank_row 一致)──
|
|
195
|
+
function isBlankRow(y, tolerance) {
|
|
196
|
+
for (let x = 0; x < totalW; x++) {
|
|
197
|
+
const i = (y * totalW + x) * 4;
|
|
198
|
+
if (Math.abs(pixels[i] - bgRgb[0]) > tolerance ||
|
|
199
|
+
Math.abs(pixels[i + 1] - bgRgb[1]) > tolerance ||
|
|
200
|
+
Math.abs(pixels[i + 2] - bgRgb[2]) > tolerance) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const slices = []; // [[startY, endY], ...]
|
|
208
|
+
let startY = 0;
|
|
209
|
+
while (startY < totalH) {
|
|
210
|
+
let endY = Math.min(startY + physImgH, totalH);
|
|
211
|
+
if (endY < totalH) {
|
|
212
|
+
// 从切割点往上找最近的空白行(搜索范围:下半段的 50%)
|
|
213
|
+
const minCut = startY + Math.floor(physImgH / 2);
|
|
214
|
+
let cutY = endY;
|
|
215
|
+
while (cutY > minCut) {
|
|
216
|
+
if (isBlankRow(cutY - 1, 10)) {
|
|
217
|
+
endY = cutY;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
cutY--;
|
|
221
|
+
}
|
|
222
|
+
if (cutY <= minCut) {
|
|
223
|
+
console.log(`INFO:第 ${slices.length + 1} 张未找到空白行,使用默认切割点 y=${endY}`);
|
|
224
|
+
} else {
|
|
225
|
+
console.log(`INFO:第 ${slices.length + 1} 张找到空白行 y=${endY}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
slices.push([startY, endY]);
|
|
229
|
+
startY = endY;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
console.log(`INFO:总高度 ${totalH}px → ${slices.length} 张`);
|
|
233
|
+
|
|
234
|
+
// ── 5. 用 pngjs 切片并保存(与 Python 版 img.crop 一致) ────────────────
|
|
235
|
+
const saved = [];
|
|
236
|
+
for (let i = 0; i < slices.length; i++) {
|
|
237
|
+
const [y0, y1] = slices[i];
|
|
238
|
+
const sliceH = y1 - y0;
|
|
239
|
+
const slice = new PNG({ width: totalW, height: sliceH });
|
|
240
|
+
for (let y = 0; y < sliceH; y++) {
|
|
241
|
+
const srcOff = (y0 + y) * totalW * 4;
|
|
242
|
+
const dstOff = y * totalW * 4;
|
|
243
|
+
pixels.copy(slice.data, dstOff, srcOff, srcOff + totalW * 4);
|
|
244
|
+
}
|
|
245
|
+
const outPath = path.join(outputDir, `xhs_${String(i + 1).padStart(2, '0')}.png`);
|
|
246
|
+
fs.writeFileSync(outPath, PNG.sync.write(slice));
|
|
247
|
+
console.log(`SAVED:${outPath}`);
|
|
248
|
+
saved.push(outPath);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
console.log(`DONE:${saved.length}`);
|
|
252
|
+
} finally {
|
|
253
|
+
await browser.close();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
main().catch(err => {
|
|
258
|
+
console.log(`ERROR:${err.message}`);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
});
|