@sns-parse/core 0.3.0-alpha.1 → 0.4.0-alpha.10

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.
Files changed (47) hide show
  1. package/lib/config.d.ts +7 -0
  2. package/lib/config.js +28 -0
  3. package/lib/engine/config-io.d.ts +44 -0
  4. package/lib/engine/config-io.js +155 -0
  5. package/lib/engine/config.d.ts +9 -0
  6. package/lib/engine/config.js +265 -0
  7. package/lib/engine/fetcher.d.ts +21 -0
  8. package/lib/engine/fetcher.js +228 -0
  9. package/lib/engine/gateway.d.ts +21 -0
  10. package/lib/engine/gateway.js +19 -0
  11. package/lib/engine/parser.d.ts +2 -0
  12. package/lib/engine/parser.js +162 -0
  13. package/lib/engine/platform-config.d.ts +24 -0
  14. package/lib/engine/platform-config.js +76 -0
  15. package/lib/engine/runtime.d.ts +49 -0
  16. package/lib/engine/runtime.js +90 -0
  17. package/lib/engine/translate.d.ts +15 -0
  18. package/lib/engine/translate.js +107 -0
  19. package/lib/engine/twitter-user.d.ts +67 -0
  20. package/lib/engine/twitter-user.js +185 -0
  21. package/lib/engine/twitter.d.ts +102 -0
  22. package/lib/engine/twitter.js +791 -0
  23. package/lib/index.d.ts +21 -0
  24. package/lib/index.js +21 -0
  25. package/lib/registry.d.ts +20 -0
  26. package/lib/registry.js +77 -0
  27. package/lib/utils/cache.d.ts +9 -0
  28. package/lib/utils/cache.js +34 -0
  29. package/lib/utils/common.d.ts +7 -0
  30. package/lib/utils/common.js +58 -0
  31. package/lib/utils/concurrency.d.ts +8 -0
  32. package/lib/utils/concurrency.js +29 -0
  33. package/lib/utils/field-mapping.d.ts +2 -0
  34. package/lib/utils/field-mapping.js +29 -0
  35. package/lib/utils/format.d.ts +4 -0
  36. package/lib/utils/format.js +84 -0
  37. package/lib/utils/gif.d.ts +10 -0
  38. package/lib/utils/gif.js +104 -0
  39. package/lib/utils/image-size.d.ts +10 -0
  40. package/lib/utils/image-size.js +69 -0
  41. package/lib/utils/merge.d.ts +64 -0
  42. package/lib/utils/merge.js +402 -0
  43. package/lib/utils/tls-client.d.ts +30 -0
  44. package/lib/utils/tls-client.js +120 -0
  45. package/lib/utils/url.d.ts +10 -0
  46. package/lib/utils/url.js +89 -0
  47. package/package.json +16 -4
@@ -0,0 +1,64 @@
1
+ import type { ParserRuntime } from '../engine/runtime';
2
+ import { type ImageSize } from './image-size';
3
+ export type MergeLayout = {
4
+ kind: 'grid';
5
+ cols: number;
6
+ rows: number;
7
+ } | {
8
+ kind: 'v';
9
+ } | {
10
+ kind: 'h';
11
+ };
12
+ /** 仅凭数量与尺寸给出全部可行布局(内容验证前) */
13
+ export declare function candidateLayouts(n: number, sizes: ImageSize[]): MergeLayout[];
14
+ export interface SeamVerdict {
15
+ /** false = 接缝不可验证(边界带平坦,无纹理可判)或不连续 */
16
+ ok: boolean;
17
+ score: number;
18
+ /** 证据明细(供 debug 日志) */
19
+ dir: 'V' | 'H';
20
+ from: number;
21
+ to: number;
22
+ /** 边界带高频纹理能量(两侧最大值;低于 TEXTURE_MIN 即不可验证) */
23
+ texture: number;
24
+ /** 平滑后接缝差 */
25
+ trendMAD: number;
26
+ /** 片内自然步进基线 */
27
+ baseline: number;
28
+ /** 结论文案 */
29
+ reason: string;
30
+ }
31
+ /** 内容验证:所有接缝(趋势+纹理)均通过才成立;接缝总数 ≥3 时允许至多一条
32
+ * 「边界容差缝」(比值 ∈ [3,5)——平台独立重压缩的典型错位幅度;多条缝相互
33
+ * 印证才可信,2 缝场景证据不足不放行;平坦不可验证缝不参与容差)。
34
+ * 返回最大接缝比值与逐缝证据。 */
35
+ export declare function verifyLayout(layout: MergeLayout, grays: Buffer[]): {
36
+ pass: boolean;
37
+ score: number;
38
+ seams: SeamVerdict[];
39
+ };
40
+ /** 内容识别:候选布局逐一经接缝连续性验证,取通过者中比值最小的一个 */
41
+ export declare function detectMergeLayout(sizes: ImageSize[], grays: Buffer[]): MergeLayout | null;
42
+ /** 布局名(日志用) */
43
+ export declare function layoutName(layout: MergeLayout): string;
44
+ /**
45
+ * 候选布局逐一验证并产出证据链;返回通过者中证据最强(worst 接缝比值最小)的一个。
46
+ * 每次调用都会把逐候选、逐接缝的判定依据写入 debug 日志(含尺寸与灰度行数)。
47
+ */
48
+ export declare function pickMergeLayout(sizes: ImageSize[], grays: Buffer[]): {
49
+ layout: MergeLayout;
50
+ score: number;
51
+ seams: SeamVerdict[];
52
+ } | null;
53
+ /** xstack 均匀网格布局串(各片已统一缩放为 w×h,行优先) */
54
+ export declare function xstackLayout(cols: number, rows: number, w: number, h: number): string;
55
+ /** 生成 ffmpeg filter_complex(输出标签恒为 [out],导出供测试) */
56
+ export declare function buildMergeFilter(n: number, layout: MergeLayout, sizes: ImageSize[]): string;
57
+ /**
58
+ * 下载 → 灰度解码 → 内容识别接缝 → 合并。
59
+ * 不构成母图切片(接缝不连续)或任一环节失败 → null。
60
+ */
61
+ export declare function mergeImages(rt: ParserRuntime, urls: string[]): Promise<{
62
+ buffer: Buffer;
63
+ layout: MergeLayout;
64
+ } | null>;
@@ -0,0 +1,402 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.candidateLayouts = candidateLayouts;
4
+ exports.verifyLayout = verifyLayout;
5
+ exports.detectMergeLayout = detectMergeLayout;
6
+ exports.layoutName = layoutName;
7
+ exports.pickMergeLayout = pickMergeLayout;
8
+ exports.xstackLayout = xstackLayout;
9
+ exports.buildMergeFilter = buildMergeFilter;
10
+ exports.mergeImages = mergeImages;
11
+ /**
12
+ * 同源切图识别与合并(纯内容识别,不依赖 URL/文件名等外部信息):
13
+ * - 候选布局:n∈{4,9,16} 且各片等尺寸 → n×n 宫格;各片同宽 → 垂直堆叠;各片同高 → 水平拼接
14
+ * - 内容验证:ffmpeg 解码为 256 宽灰度图,比较接缝两侧边界行/列的平均绝对差(MAD),
15
+ * 与图片内部相邻行/列的自然差异比较——母图切片在接缝处连续(比值≈1),无关图片差异显著(比值≫1)
16
+ * - 合并:宫格 xstack / 垂直 vstack / 水平 hstack
17
+ *
18
+ * 识别/解码/ffmpeg 任一环节失败均返回 null,调用方回退逐张发送。
19
+ */
20
+ const child_process_1 = require("child_process");
21
+ const crypto_1 = require("crypto");
22
+ const promises_1 = require("fs/promises");
23
+ const os_1 = require("os");
24
+ const path_1 = require("path");
25
+ const image_size_1 = require("./image-size");
26
+ const gif_1 = require("./gif");
27
+ const logger_1 = require("../logger");
28
+ const MAX_PIECES = 12;
29
+ /** 同一母图切出的分片尺寸精确相等,仅留极小容差防探测/编解码舍入 */
30
+ const DIM_TOLERANCE = 2;
31
+ const MAX_OUTPUT_SIDE = 4096;
32
+ const MAX_TOTAL_BYTES = 150 * 1048576;
33
+ /** 灰度归一化宽度(边界行/列向量长度基准) */
34
+ const GRAY_W = 256;
35
+ /** 列向量统一重采样长度 */
36
+ const COL_LEN = 64;
37
+ /** 接缝差异 / 内部自然差异 的判定阈值(母图切片 ≈1,无关图片 ≫1;下限 3 防平坦图除零) */
38
+ const BOUNDARY_THRESH = 3.0;
39
+ const MAD_FLOOR = 3.0;
40
+ /** 单缝边界容差:平台(如 X)对各分片独立重压缩会让个别接缝低频轻微错位。
41
+ * 仅当接缝总数 ≥3(≥4 图,多条缝相互印证)且恰好一条落在
42
+ * [BOUNDARY_THRESH, BORDERLINE_THRESH)、其余全部通过时放行;平坦不可验证缝不参与容差 */
43
+ const BORDERLINE_THRESH = 5.0;
44
+ const BORDERLINE_MAX = 1;
45
+ const BORDERLINE_MIN_SEAMS = 3;
46
+ /** 边界带纹理能量下限:低于此值视为「不可验证接缝」(相似背景/平边照片会骗过亮度对比) */
47
+ const TEXTURE_MIN = 4.0;
48
+ /* ---------- 尺寸候选 ---------- */
49
+ function dimsEqual(a, b, tol) {
50
+ return Math.abs(a.width - b.width) <= tol && Math.abs(a.height - b.height) <= tol;
51
+ }
52
+ /** 仅凭数量与尺寸给出全部可行布局(内容验证前) */
53
+ function candidateLayouts(n, sizes) {
54
+ if (n < 2 || n > MAX_PIECES)
55
+ return [];
56
+ if (sizes.some((s) => !s || s.width < 8 || s.height < 8))
57
+ return [];
58
+ const out = [];
59
+ const sqrt = Math.sqrt(n);
60
+ if (Number.isInteger(sqrt) && sizes.every((s) => dimsEqual(s, sizes[0], DIM_TOLERANCE))) {
61
+ out.push({ kind: 'grid', cols: sqrt, rows: sqrt });
62
+ }
63
+ if (sizes.every((s) => Math.abs(s.width - sizes[0].width) <= DIM_TOLERANCE))
64
+ out.push({ kind: 'v' });
65
+ if (sizes.every((s) => Math.abs(s.height - sizes[0].height) <= DIM_TOLERANCE))
66
+ out.push({ kind: 'h' });
67
+ return out;
68
+ }
69
+ /* ---------- 灰度边界向量 ---------- */
70
+ /** 解码为固定宽度灰度原始流(buf = rows × GRAY_W 字节) */
71
+ function toGray(file) {
72
+ return new Promise((resolve, reject) => {
73
+ const child = (0, child_process_1.spawn)((0, gif_1.resolveFfmpeg)(), [
74
+ '-hide_banner', '-loglevel', 'error', '-i', file,
75
+ '-vf', `scale=${GRAY_W}:-2:flags=area,format=gray`, '-f', 'rawvideo', 'pipe:1',
76
+ ]);
77
+ const chunks = [];
78
+ child.stdout.on('data', (d) => chunks.push(d));
79
+ child.stderr.on('data', (d) => reject(new Error(d.toString().slice(0, 120))));
80
+ child.on('error', (e) => reject(e));
81
+ child.on('close', (code) => {
82
+ const buf = Buffer.concat(chunks);
83
+ if (code === 0 && buf.length >= GRAY_W * 2)
84
+ resolve(buf);
85
+ else
86
+ reject(new Error(`gray decode exit ${code}`));
87
+ });
88
+ });
89
+ }
90
+ function grayRows(g) {
91
+ return Math.floor(g.length / GRAY_W);
92
+ }
93
+ /** 取一行(256 长) */
94
+ function rowOf(g, r) {
95
+ const rows = grayRows(g);
96
+ const rr = Math.min(Math.max(r, 0), rows - 1);
97
+ const out = new Float32Array(GRAY_W);
98
+ for (let x = 0; x < GRAY_W; x++)
99
+ out[x] = g[rr * GRAY_W + x];
100
+ return out;
101
+ }
102
+ /** 取一列(重采样到 COL_LEN,适配各片灰度高度不一) */
103
+ function colOf(g, c) {
104
+ const rows = grayRows(g);
105
+ const cc = Math.min(Math.max(c, 0), GRAY_W - 1);
106
+ const out = new Float32Array(COL_LEN);
107
+ for (let i = 0; i < COL_LEN; i++) {
108
+ const y = Math.min(rows - 1, Math.round((i / (COL_LEN - 1)) * (rows - 1)));
109
+ out[i] = g[y * GRAY_W + cc];
110
+ }
111
+ return out;
112
+ }
113
+ function mad(a, b) {
114
+ let sum = 0;
115
+ for (let i = 0; i < a.length; i++)
116
+ sum += Math.abs(a[i] - b[i]);
117
+ return sum / a.length;
118
+ }
119
+ /** 滑动平均(提取平滑趋势) */
120
+ function smooth(v) {
121
+ const win = Math.max(3, Math.floor(v.length / 16) | 1);
122
+ const out = new Float32Array(v.length);
123
+ let sum = 0;
124
+ for (let i = 0; i < v.length; i++) {
125
+ sum += v[i];
126
+ if (i >= win)
127
+ sum -= v[i - win];
128
+ out[i] = sum / Math.min(i + 1, win);
129
+ }
130
+ return out;
131
+ }
132
+ /** 去趋势残差(高频纹理成分) */
133
+ function residual(v) {
134
+ const s = smooth(v);
135
+ const out = new Float32Array(v.length);
136
+ for (let i = 0; i < v.length; i++)
137
+ out[i] = v[i] - s[i];
138
+ return out;
139
+ }
140
+ function std(v) {
141
+ let mean = 0;
142
+ for (let i = 0; i < v.length; i++)
143
+ mean += v[i];
144
+ mean /= v.length;
145
+ let acc = 0;
146
+ for (let i = 0; i < v.length; i++)
147
+ acc += (v[i] - mean) ** 2;
148
+ return Math.sqrt(acc / v.length);
149
+ }
150
+ /**
151
+ * 单条接缝裁决:
152
+ * - 可验证性:任一侧边界带高频纹理能量不足(TEXTURE_MIN)→ 拒绝(平坦边缘无法证明连续)
153
+ * - 连续性:低频成分(平滑后)的边界差 vs 片内相邻行/列的自然步进,比值 < 阈值才通过。
154
+ * 高频纹理逐行天然去相关、不能作匹配信号,先平滑剔除;基线取各自片内相邻对
155
+ * (不可跨片比较,否则无关片间差异会稀释接缝信号)。
156
+ */
157
+ function judgeSeam(edgeA, edgeB, dir, from, to, innerPairs) {
158
+ const texture = Math.max(std(residual(edgeA)), std(residual(edgeB)));
159
+ const mk = (ok, score, reason, trendMAD = 0, baseline = 0) => ({ ok, score, dir, from, to, texture, trendMAD, baseline, reason });
160
+ if (texture < TEXTURE_MIN)
161
+ return mk(false, Infinity, `纹理能量 ${texture.toFixed(1)} < ${TEXTURE_MIN},平坦边缘不可验证`);
162
+ const sA = smooth(edgeA);
163
+ const sB = smooth(edgeB);
164
+ const pairs = innerPairs();
165
+ const innerTrend = pairs.map(([p, q]) => mad(smooth(p), smooth(q)));
166
+ const baseline = innerTrend.reduce((t, s) => t + s, 0) / innerTrend.length;
167
+ const trendRef = Math.max(baseline, MAD_FLOOR);
168
+ const trendMAD = mad(sA, sB);
169
+ const score = trendMAD / trendRef;
170
+ return mk(score < BOUNDARY_THRESH, score, score < BOUNDARY_THRESH
171
+ ? `连续(趋势差 ${trendMAD.toFixed(1)} / 基线 ${trendRef.toFixed(1)} = ${score.toFixed(2)} < ${BOUNDARY_THRESH},纹理 ${texture.toFixed(1)})`
172
+ : `不连续(趋势差 ${trendMAD.toFixed(1)} / 基线 ${trendRef.toFixed(1)} = ${score.toFixed(2)} ≥ ${BOUNDARY_THRESH},纹理 ${texture.toFixed(1)})`, trendMAD, baseline);
173
+ }
174
+ /** 垂直接缝:A 末行 vs B 首行(基线取各自片内相邻行对) */
175
+ function seamVerdictV(a, b, from, to) {
176
+ const r = grayRows(a);
177
+ return judgeSeam(rowOf(a, r - 1), rowOf(b, 0), 'V', from, to, () => [
178
+ [rowOf(a, r - 2), rowOf(a, r - 1)],
179
+ [rowOf(a, r - 3), rowOf(a, r - 2)],
180
+ [rowOf(b, 1), rowOf(b, 0)],
181
+ [rowOf(b, 2), rowOf(b, 1)],
182
+ ]);
183
+ }
184
+ /** 水平接缝:A 末列 vs B 首列(基线取各自片内相邻列对) */
185
+ function seamVerdictH(a, b, from, to) {
186
+ return judgeSeam(colOf(a, GRAY_W - 1), colOf(b, 0), 'H', from, to, () => [
187
+ [colOf(a, GRAY_W - 2), colOf(a, GRAY_W - 1)],
188
+ [colOf(a, GRAY_W - 3), colOf(a, GRAY_W - 2)],
189
+ [colOf(b, 1), colOf(b, 0)],
190
+ [colOf(b, 2), colOf(b, 1)],
191
+ ]);
192
+ }
193
+ /** 内容验证:所有接缝(趋势+纹理)均通过才成立;接缝总数 ≥3 时允许至多一条
194
+ * 「边界容差缝」(比值 ∈ [3,5)——平台独立重压缩的典型错位幅度;多条缝相互
195
+ * 印证才可信,2 缝场景证据不足不放行;平坦不可验证缝不参与容差)。
196
+ * 返回最大接缝比值与逐缝证据。 */
197
+ function verifyLayout(layout, grays) {
198
+ const seams = [];
199
+ const n = grays.length;
200
+ if (layout.kind === 'v') {
201
+ for (let i = 0; i + 1 < n; i++)
202
+ seams.push(seamVerdictV(grays[i], grays[i + 1], i, i + 1));
203
+ }
204
+ else if (layout.kind === 'h') {
205
+ for (let i = 0; i + 1 < n; i++)
206
+ seams.push(seamVerdictH(grays[i], grays[i + 1], i, i + 1));
207
+ }
208
+ else {
209
+ const { cols } = layout;
210
+ for (let i = 0; i < n; i++) {
211
+ if (i % cols < cols - 1 && i + 1 < n)
212
+ seams.push(seamVerdictH(grays[i], grays[i + 1], i, i + 1));
213
+ if (i + cols < n)
214
+ seams.push(seamVerdictV(grays[i], grays[i + cols], i, i + cols));
215
+ }
216
+ }
217
+ if (!seams.length)
218
+ return { pass: false, score: Infinity, seams };
219
+ const worst = Math.max(...seams.map((v) => v.score));
220
+ const failed = seams.filter((v) => !v.ok);
221
+ const borderline = failed.filter((v) => v.score < BORDERLINE_THRESH);
222
+ const pass = seams.every((v) => v.ok)
223
+ || (seams.length >= BORDERLINE_MIN_SEAMS && borderline.length <= BORDERLINE_MAX && failed.length === borderline.length);
224
+ return { pass, score: worst, seams };
225
+ }
226
+ /** 内容识别:候选布局逐一经接缝连续性验证,取通过者中比值最小的一个 */
227
+ function detectMergeLayout(sizes, grays) {
228
+ return pickMergeLayout(sizes, grays)?.layout ?? null;
229
+ }
230
+ /** 布局名(日志用) */
231
+ function layoutName(layout) {
232
+ return layout.kind === 'grid' ? `网格 ${layout.cols}x${layout.rows}` : layout.kind === 'v' ? '竖堆(水平切分)' : '横拼(垂直切分)';
233
+ }
234
+ /**
235
+ * 候选布局逐一验证并产出证据链;返回通过者中证据最强(worst 接缝比值最小)的一个。
236
+ * 每次调用都会把逐候选、逐接缝的判定依据写入 debug 日志(含尺寸与灰度行数)。
237
+ */
238
+ function pickMergeLayout(sizes, grays) {
239
+ const candidates = candidateLayouts(sizes.length, sizes);
240
+ (0, logger_1.debugLog)(`同源合并候选:${sizes.length} 张,尺寸 ${sizes.map((s) => `${s.width}x${s.height}`).join(' / ')},灰度行数 ${grays.map((g) => grayRows(g)).join('/')}`);
241
+ if (!candidates.length) {
242
+ (0, logger_1.debugLog)('同源合并候选:无(数量/尺寸组合不构成网格/竖堆/横拼任何可能)');
243
+ return null;
244
+ }
245
+ let best = null;
246
+ for (const c of candidates) {
247
+ const { pass, score, seams } = verifyLayout(c, grays);
248
+ const tolerated = seams.filter((v) => !v.ok).length;
249
+ const detail = seams.map((v) => `[${v.dir === 'V' ? '竖' : '横'}缝 ${v.from}→${v.to}] ${v.reason}`).join(';');
250
+ (0, logger_1.debugLog)(`同源合并证据·${layoutName(c)}:${pass ? '通过' : '拒绝'}${pass && tolerated ? `(含 ${tolerated} 条边界容差缝)` : ''},worst=${score === Infinity ? '∞' : score.toFixed(2)} ⟶ ${detail}`);
251
+ if (pass && (!best || score < best.score))
252
+ best = { layout: c, score, seams };
253
+ }
254
+ if (best)
255
+ (0, logger_1.debugLog)(`同源合并裁决:${layoutName(best.layout)}(证据最强,worst 接缝比值 ${best.score.toFixed(2)})`);
256
+ else
257
+ (0, logger_1.debugLog)('同源合并裁决:全部候选未通过内容验证 → 逐张发送');
258
+ return best;
259
+ }
260
+ /* ---------- ffmpeg 合并 ---------- */
261
+ /** xstack 均匀网格布局串(各片已统一缩放为 w×h,行优先) */
262
+ function xstackLayout(cols, rows, w, h) {
263
+ const parts = [];
264
+ for (let i = 0; i < cols * rows; i++) {
265
+ const x = (i % cols) * w;
266
+ const y = Math.floor(i / cols) * h;
267
+ parts.push(`${x}_${y}`);
268
+ }
269
+ return parts.join('|');
270
+ }
271
+ /** 生成 ffmpeg filter_complex(输出标签恒为 [out],导出供测试) */
272
+ function buildMergeFilter(n, layout, sizes) {
273
+ const chains = [];
274
+ const stacks = [];
275
+ const chainOf = (stack, outW, outH) => {
276
+ if (outW > MAX_OUTPUT_SIDE || outH > MAX_OUTPUT_SIDE) {
277
+ const scale = Math.min(1, MAX_OUTPUT_SIDE / Math.max(outW, outH));
278
+ return `${stack}[tmp];[tmp]scale=${Math.round(outW * scale) / 2 * 2}:${Math.round(outH * scale) / 2 * 2}:flags=lanczos[out]`;
279
+ }
280
+ return `${stack}[out]`;
281
+ };
282
+ if (layout.kind === 'grid') {
283
+ // 宫格拼图:各片等比缩放进统一格子(不足处黑边补齐——切片场景无补边,杂图集保持各自比例),
284
+ // 逐片 concat 成帧序列后 tile 出宫格(支持非满宫格,如 7 图 3x3)
285
+ const cellW = Math.min(Math.max(...sizes.map((s) => s.width)), 1920);
286
+ const cellH = Math.min(Math.max(...sizes.map((s) => s.height)), 1920);
287
+ const cw = Math.ceil(cellW / 2) * 2;
288
+ const ch = Math.ceil(cellH / 2) * 2;
289
+ for (let i = 0; i < n; i++) {
290
+ chains.push(`[${i}:v]scale=${cw}:${ch}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${cw}:${ch}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1[c${i}]`);
291
+ stacks.push(`[c${i}]`);
292
+ }
293
+ chains.push(`${stacks.join('')}concat=n=${n}:v=1:a=0[frames]`);
294
+ chains.push(chainOf(`[frames]tile=${layout.cols}x${layout.rows}`, layout.cols * cw, layout.rows * ch));
295
+ }
296
+ else if (layout.kind === 'v') {
297
+ const w = Math.max(...sizes.map((s) => s.width));
298
+ for (let i = 0; i < n; i++)
299
+ chains.push(`[${i}:v]scale=${w}:-2:flags=lanczos[s${i}]`);
300
+ for (let i = 0; i < n; i++)
301
+ stacks.push(`[s${i}]`);
302
+ chains.push(chainOf(`${stacks.join('')}vstack=inputs=${n}`, w, sizes.reduce((t, s) => t + s.height, 0)));
303
+ }
304
+ else {
305
+ const h = Math.max(...sizes.map((s) => s.height));
306
+ for (let i = 0; i < n; i++)
307
+ chains.push(`[${i}:v]scale=-2:${h}:flags=lanczos[s${i}]`);
308
+ for (let i = 0; i < n; i++)
309
+ stacks.push(`[s${i}]`);
310
+ chains.push(chainOf(`${stacks.join('')}hstack=inputs=${n}`, sizes.reduce((t, s) => t + s.width, 0), h));
311
+ }
312
+ return chains.join(';');
313
+ }
314
+ /** 按魔数给临时文件配扩展名(ffmpeg 也可自行探测,显式更稳) */
315
+ function extOf(buf) {
316
+ if (buf[0] === 0x89 && buf[1] === 0x50)
317
+ return 'png';
318
+ if (buf[0] === 0xff && buf[1] === 0xd8)
319
+ return 'jpg';
320
+ if (buf.toString('ascii', 0, 4) === 'RIFF')
321
+ return 'webp';
322
+ if (buf.toString('ascii', 0, 4) === 'GIF8')
323
+ return 'gif';
324
+ return 'bin';
325
+ }
326
+ const MERGE_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
327
+ function downloadImage(rt, url) {
328
+ const headers = { 'User-Agent': MERGE_UA };
329
+ headers['Referer'] = /twimg\.com/.test(url) ? 'https://twitter.com/' : 'https://www.baidu.com/';
330
+ return rt.http.get(url, { responseType: 'arraybuffer', timeout: 30000, headers })
331
+ .then((res) => Buffer.from(res.data));
332
+ }
333
+ /**
334
+ * 下载 → 灰度解码 → 内容识别接缝 → 合并。
335
+ * 不构成母图切片(接缝不连续)或任一环节失败 → null。
336
+ */
337
+ async function mergeImages(rt, urls) {
338
+ if (urls.length < 2 || urls.length > MAX_PIECES)
339
+ return null;
340
+ let buffers;
341
+ try {
342
+ buffers = await Promise.all(urls.map((u) => downloadImage(rt, u)));
343
+ }
344
+ catch (e) {
345
+ (0, logger_1.debugLog)(`切图合并跳过(分片下载失败):${e?.message || e}`);
346
+ return null;
347
+ }
348
+ const total = buffers.reduce((t, b) => t + b.length, 0);
349
+ if (total > MAX_TOTAL_BYTES || buffers.some((b) => !b.length))
350
+ return null;
351
+ const sizes = buffers.map((b) => (0, image_size_1.probeImageSize)(b));
352
+ if (sizes.some((s) => !s))
353
+ return null;
354
+ const dir = await (0, promises_1.mkdtemp)((0, path_1.join)((0, os_1.tmpdir)(), `vpa-merge-${(0, crypto_1.randomBytes)(4).toString('hex')}-`));
355
+ try {
356
+ const files = [];
357
+ for (let i = 0; i < buffers.length; i++) {
358
+ const f = (0, path_1.join)(dir, `${i}.${extOf(buffers[i])}`);
359
+ await (0, promises_1.writeFile)(f, buffers[i]);
360
+ files.push(f);
361
+ }
362
+ // 布局决策(完全由像素内容裁决,不依赖数量规则、链接/文件名与色调风格):
363
+ // 网格/竖堆/横拼三种布局候选(按尺寸可能性给出),逐一经接缝内容验证
364
+ // (亮度趋势 + 去趋势纹理双通道、平坦边缘不可验证即拒绝),
365
+ // 取通过者中证据最强(接缝比值最小)的一种——真 2x2/3x3 切片网格缝连续、
366
+ // 长图条带单向连续、无关图全部不过;合并方式由内容区分,均不过则逐张发送
367
+ let grays;
368
+ try {
369
+ grays = await Promise.all(files.map(toGray));
370
+ }
371
+ catch (e) {
372
+ (0, logger_1.debugLog)(`切图合并跳过(灰度解码失败):${e?.message || e}`);
373
+ return null;
374
+ }
375
+ const picked = pickMergeLayout(sizes, grays);
376
+ if (!picked)
377
+ return null;
378
+ const layout = picked.layout;
379
+ const filter = buildMergeFilter(files.length, layout, sizes);
380
+ const args = ['-hide_banner', '-loglevel', 'error', ...files.flatMap((f) => ['-i', f]),
381
+ '-filter_complex', filter, '-map', '[out]', '-frames:v', '1', '-q:v', '2',
382
+ '-f', 'image2pipe', '-c:v', 'mjpeg', 'pipe:1'];
383
+ const out = await new Promise((resolve) => {
384
+ const child = (0, child_process_1.spawn)((0, gif_1.resolveFfmpeg)(), args);
385
+ const chunks = [];
386
+ child.stdout.on('data', (d) => chunks.push(d));
387
+ child.stderr.on('data', () => { });
388
+ child.on('error', () => resolve(null));
389
+ child.on('close', (code) => resolve(code === 0 && chunks.length ? Buffer.concat(chunks) : null));
390
+ });
391
+ if (!out) {
392
+ (0, logger_1.debugLog)(`切图合并失败(ffmpeg 退出非 0),回退逐张发送`);
393
+ return null;
394
+ }
395
+ const desc = layout.kind === 'grid' ? `${layout.cols}x${layout.rows} 宫格` : layout.kind === 'v' ? '垂直堆叠' : '水平拼接';
396
+ logger_1.logger.info(`同源切图已合并为一张(内容识别:${urls.length} 张 → ${desc},${Math.round(out.length / 1024)}KB)`);
397
+ return { buffer: out, layout };
398
+ }
399
+ finally {
400
+ (0, promises_1.rm)(dir, { recursive: true, force: true }).catch(() => { });
401
+ }
402
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * TLS 指纹模拟 HTTP 客户端(一次性 CLI 模式)。
3
+ *
4
+ * X/Twitter 的 GraphQL 受 Cloudflare TLS 指纹校验保护:普通 Node/axios 的
5
+ * TLS 握手(JA3/JA4)与 Chrome 不同,会被 CF 直接 403。此模块经
6
+ * @char46/tlsget-rs(wreq/BoringSSL 内核,Chrome147 指纹)发起请求。
7
+ *
8
+ * 每次请求 spawn 一个静态二进制:stdin 进 JSON、stdout 出 JSON。
9
+ * 无常驻服务、无端口管理、无生命周期问题;musl 环境自动使用静态 musl 构建。
10
+ * tlsget-rs 为 optionalDependency,未安装时抛出明确错误,调用方据此降级。
11
+ */
12
+ export interface TlsGetOptions {
13
+ headers?: Record<string, string>;
14
+ cookies?: Record<string, string>;
15
+ timeout?: number;
16
+ /** HTTP(S) 代理地址(如 http://127.0.0.1:7890),透传给 tlsget-rs 二进制 */
17
+ proxy?: string;
18
+ }
19
+ export interface TlsResponse {
20
+ status: number;
21
+ data: any;
22
+ }
23
+ export declare function tlsGet(url: string, opts?: TlsGetOptions): Promise<TlsResponse>;
24
+ /** 一次性二进制无需常驻进程;保留接口兼容旧调用方与测试 */
25
+ export declare function shutdownTlsClient(): Promise<void>;
26
+ /**
27
+ * 环境诊断:二进制定位 → 自检 → TLS 指纹回显 → X 边缘探测。
28
+ * 失败环节即根因,全部结果同时输出到日志。
29
+ */
30
+ export declare function diagnoseTls(): Promise<string[]>;
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ /**
3
+ * TLS 指纹模拟 HTTP 客户端(一次性 CLI 模式)。
4
+ *
5
+ * X/Twitter 的 GraphQL 受 Cloudflare TLS 指纹校验保护:普通 Node/axios 的
6
+ * TLS 握手(JA3/JA4)与 Chrome 不同,会被 CF 直接 403。此模块经
7
+ * @char46/tlsget-rs(wreq/BoringSSL 内核,Chrome147 指纹)发起请求。
8
+ *
9
+ * 每次请求 spawn 一个静态二进制:stdin 进 JSON、stdout 出 JSON。
10
+ * 无常驻服务、无端口管理、无生命周期问题;musl 环境自动使用静态 musl 构建。
11
+ * tlsget-rs 为 optionalDependency,未安装时抛出明确错误,调用方据此降级。
12
+ */
13
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ var desc = Object.getOwnPropertyDescriptor(m, k);
16
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
17
+ desc = { enumerable: true, get: function() { return m[k]; } };
18
+ }
19
+ Object.defineProperty(o, k2, desc);
20
+ }) : (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ o[k2] = m[k];
23
+ }));
24
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
25
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
26
+ }) : function(o, v) {
27
+ o["default"] = v;
28
+ });
29
+ var __importStar = (this && this.__importStar) || (function () {
30
+ var ownKeys = function(o) {
31
+ ownKeys = Object.getOwnPropertyNames || function (o) {
32
+ var ar = [];
33
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
34
+ return ar;
35
+ };
36
+ return ownKeys(o);
37
+ };
38
+ return function (mod) {
39
+ if (mod && mod.__esModule) return mod;
40
+ var result = {};
41
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
42
+ __setModuleDefault(result, mod);
43
+ return result;
44
+ };
45
+ })();
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.tlsGet = tlsGet;
48
+ exports.shutdownTlsClient = shutdownTlsClient;
49
+ exports.diagnoseTls = diagnoseTls;
50
+ let modPromise = null;
51
+ /** 惰性加载 npm 包装(未安装时不缓存失败,装好后无需重启即可生效) */
52
+ async function getMod() {
53
+ if (!modPromise) {
54
+ modPromise = Promise.resolve().then(() => __importStar(require('@char46/tlsget-rs'))).then((m) => m.default || m)
55
+ .catch(() => {
56
+ modPromise = null;
57
+ throw new Error('TLS 指纹模拟工具 @char46/tlsget-rs 未安装(可选依赖)。解析需登录的 X 推文需要它:npm i @char46/tlsget-rs');
58
+ });
59
+ }
60
+ return modPromise;
61
+ }
62
+ async function tlsGet(url, opts = {}) {
63
+ const mod = await getMod();
64
+ const r = await mod.tlsGet({
65
+ url,
66
+ headers: opts.headers,
67
+ cookies: opts.cookies,
68
+ timeoutMs: Math.max(1000, opts.timeout || 30000),
69
+ proxy: opts.proxy,
70
+ });
71
+ return { status: r.status, data: r.body };
72
+ }
73
+ /** 一次性二进制无需常驻进程;保留接口兼容旧调用方与测试 */
74
+ async function shutdownTlsClient() {
75
+ modPromise = null;
76
+ }
77
+ /**
78
+ * 环境诊断:二进制定位 → 自检 → TLS 指纹回显 → X 边缘探测。
79
+ * 失败环节即根因,全部结果同时输出到日志。
80
+ */
81
+ async function diagnoseTls() {
82
+ const lines = [];
83
+ try {
84
+ const mod = await getMod();
85
+ const bin = mod.binaryPath?.();
86
+ if (bin)
87
+ lines.push(`[1] ✓ 二进制:${bin}`);
88
+ else
89
+ lines.push(`[1] ✗ 当前平台 ${process.platform}/${process.arch} 无二进制子包(npm i @char46/tlsget-rs --force 重装)`);
90
+ try {
91
+ const st = await mod.selftest();
92
+ lines.push(`[2] ✓ 自检:v${st.version},指纹 ${st.emulation}`);
93
+ }
94
+ catch (e) {
95
+ lines.push(`[2] ✗ 二进制无法执行:${e?.message || e}(可能被杀软/安全策略拦截)`);
96
+ }
97
+ try {
98
+ const r = await mod.tlsGet({ url: 'https://tls.peet.ws/api/all', timeoutMs: 20000 });
99
+ const ja4 = r.body?.tls?.ja4 || '?';
100
+ const chromeLike = ja4.startsWith('t13d');
101
+ lines.push(`[3] ${chromeLike ? '✓' : '✗'} TLS 回显:JA4=${ja4}${chromeLike ? '(Chrome 形态)' : '(异常:非浏览器形态)'}`);
102
+ }
103
+ catch (e) {
104
+ lines.push(`[3] ✗ 回显请求失败:${e?.message || e}`);
105
+ }
106
+ try {
107
+ const x = await mod.tlsGet({ url: 'https://x.com/robots.txt', timeoutMs: 20000 });
108
+ lines.push(`[4] ${x.status === 200 ? '✓' : '✗'} X 边缘探测:HTTP ${x.status}`);
109
+ }
110
+ catch (e) {
111
+ lines.push(`[4] ✗ X 边缘探测失败:${e?.message || e}`);
112
+ }
113
+ if (!lines.some(l => l.includes('✗')))
114
+ lines.push('结论:环境正常。');
115
+ }
116
+ catch (e) {
117
+ lines.push(`✗ 诊断失败:${e?.message || e}`);
118
+ }
119
+ return lines;
120
+ }
@@ -0,0 +1,10 @@
1
+ import type { LinkMatch } from '../types';
2
+ export declare function cleanUrl(url: string): string;
3
+ export declare function linkTypeParser(content: string, rules: {
4
+ pattern: RegExp;
5
+ type: string;
6
+ }[]): LinkMatch[];
7
+ export declare function extractAllUrlsFromMessage(session: any, rules: {
8
+ pattern: RegExp;
9
+ type: string;
10
+ }[]): LinkMatch[];