publishport-opencli 1.8.5-pp.7 → 1.8.5-pp.8

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.
@@ -36,6 +36,9 @@
36
36
  * throttleMs 每张图之间的间隔毫秒,默认 250
37
37
  */
38
38
 
39
+ import { readFile } from 'node:fs/promises';
40
+ import { fileURLToPath } from 'node:url';
41
+
39
42
  const HTML_IMG_RE = /<img\b[^>]*?\ssrc=("|')(.*?)\1[^>]*>/gi;
40
43
  // Markdown 图片:![alt](url "title")。只取 url 部分,title/尺寸后缀忽略。
41
44
  const MD_IMG_RE = /!\[[^\]]*\]\(\s*<?([^)\s>]+)>?[^)]*\)/g;
@@ -182,6 +185,76 @@ export function buildTransferImagesJs(content, spec, skip) {
182
185
  );
183
186
  }
184
187
 
188
+ const MIME_BY_EXT = {
189
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
190
+ webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon',
191
+ avif: 'image/avif', tif: 'image/tiff', tiff: 'image/tiff',
192
+ };
193
+
194
+ /**
195
+ * 判断一个图片 src 是否指向【本机文件系统的绝对路径】(而非可被浏览器 fetch 的 URL)。
196
+ * 页面内 fetch 只能解析 http(s)/data/blob 等 URL;本机绝对路径(如 /tmp/x.png)会被当成
197
+ * 站点相对 URL → 404。所以这类 src 必须在 Node 侧先读成 data: URI 再注入页面。纯函数。
198
+ */
199
+ export function isLocalImagePath(src) {
200
+ if (typeof src !== 'string' || !src) return false;
201
+ if (src.startsWith('file://')) return true;
202
+ if (/^[a-zA-Z]:[\\/]/.test(src)) return true; // Windows 盘符 C:\ 或 C:/
203
+ if (src.startsWith('//')) return false; // 协议相对 URL
204
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(src)) return false; // 其它 URI scheme(http: data: blob: …)
205
+ if (src.startsWith('/')) return true; // Unix 绝对路径
206
+ return false; // 相对路径视为站点相对,不当本机文件
207
+ }
208
+
209
+ function localPathFromSrc(src) {
210
+ if (src.startsWith('file://')) {
211
+ try { return fileURLToPath(src); } catch { return src.slice('file://'.length); }
212
+ }
213
+ return src;
214
+ }
215
+
216
+ /**
217
+ * Node 侧:把正文里引用的【本机图片路径】读成 base64 data: URI,原地替换回正文。
218
+ * 这样注入页面后,平台的图片转存(binary-multipart / 自定义 uploadFn)就能 fetch 到字节
219
+ * 并转存到平台图床——否则本机路径在页面里会被当成站点相对 URL、直接 404(图全裂)。
220
+ * 只处理本机绝对路径;http(s)/data/相对 URL 一律原样保留,交给页面内转存或平台自处理。
221
+ *
222
+ * @param {string} content 文章正文(Markdown 或 HTML)
223
+ * @param {{ readFile?: (p: string) => Promise<Uint8Array|Buffer> }} [deps] 便于测试注入
224
+ * @returns {Promise<{ content: string, inlined: Array<{src:string,bytes:number,mime:string}>, missing: Array<{src:string,error:string}> }>}
225
+ */
226
+ export async function inlineLocalImages(content, deps = {}) {
227
+ const read = deps.readFile || readFile;
228
+ const inlined = [];
229
+ const missing = [];
230
+ if (typeof content !== 'string' || !content) return { content: content || '', inlined, missing };
231
+ const refs = extractImageRefs(content);
232
+ const cache = {};
233
+ let out = content;
234
+ for (const ref of refs) {
235
+ const src = ref.src;
236
+ if (!isLocalImagePath(src)) continue;
237
+ if (!(src in cache)) {
238
+ try {
239
+ const p = localPathFromSrc(src);
240
+ const buf = await read(p);
241
+ const bytes = buf.byteLength != null ? buf.byteLength : buf.length;
242
+ const ext = (p.split(/[\\/]/).pop().split('.').pop() || '').toLowerCase().split('?')[0];
243
+ const mime = MIME_BY_EXT[ext] || 'image/png';
244
+ const b64 = Buffer.from(buf).toString('base64');
245
+ cache[src] = `data:${mime};base64,${b64}`;
246
+ inlined.push({ src, bytes, mime });
247
+ } catch (e) {
248
+ cache[src] = null;
249
+ missing.push({ src, error: String((e && e.message) || e) });
250
+ }
251
+ }
252
+ const du = cache[src];
253
+ if (du) out = out.split(ref.full).join(ref.full.replace(src, du));
254
+ }
255
+ return { content: out, inlined, missing };
256
+ }
257
+
185
258
  /**
186
259
  * 在平台页面里把正文图片全部转存到本平台图床,返回改写后的正文 + 转存报告。
187
260
  * 调用前 page 必须已经导航到该平台的写作 origin(cookie / Origin 才正确)。
@@ -208,6 +281,8 @@ export const __test__ = {
208
281
  extractImageRefs,
209
282
  pickPath,
210
283
  buildTransferImagesJs,
284
+ isLocalImagePath,
285
+ inlineLocalImages,
211
286
  HTML_IMG_RE,
212
287
  MD_IMG_RE,
213
288
  };
@@ -4,6 +4,8 @@ import {
4
4
  pickPath,
5
5
  buildTransferImagesJs,
6
6
  transferImages,
7
+ isLocalImagePath,
8
+ inlineLocalImages,
7
9
  } from './images.js';
8
10
 
9
11
  /**
@@ -180,3 +182,50 @@ describe('transferImages (无 spec / 无图 短路)', () => {
180
182
  expect(out.content).toBe('纯文本');
181
183
  });
182
184
  });
185
+
186
+ describe('isLocalImagePath', () => {
187
+ it('本机绝对路径 / file:// / Windows 盘符判为本机', () => {
188
+ expect(isLocalImagePath('/tmp/x.png')).toBe(true);
189
+ expect(isLocalImagePath('file:///tmp/x.png')).toBe(true);
190
+ expect(isLocalImagePath('C:\\imgs\\x.png')).toBe(true);
191
+ expect(isLocalImagePath('C:/imgs/x.png')).toBe(true);
192
+ });
193
+ it('URL / data / 协议相对 / 站点相对裸名一律不是本机', () => {
194
+ expect(isLocalImagePath('https://cdn/x.png')).toBe(false);
195
+ expect(isLocalImagePath('http://cdn/x.png')).toBe(false);
196
+ expect(isLocalImagePath('data:image/png;base64,AAAA')).toBe(false);
197
+ expect(isLocalImagePath('blob:https://a/b')).toBe(false);
198
+ expect(isLocalImagePath('//cdn/x.png')).toBe(false);
199
+ expect(isLocalImagePath('x.png')).toBe(false);
200
+ expect(isLocalImagePath('')).toBe(false);
201
+ expect(isLocalImagePath(null)).toBe(false);
202
+ });
203
+ });
204
+
205
+ describe('inlineLocalImages', () => {
206
+ const fakeRead = async (p) => {
207
+ if (p === '/tmp/ok.png') return Buffer.from([0x89, 0x50, 0x4e, 0x47]);
208
+ throw new Error('ENOENT');
209
+ };
210
+ it('本机图片读成 data: URI 并原地替换,URL 保持不动', async () => {
211
+ const md = '![a](/tmp/ok.png) 和 ![b](https://cdn/keep.png)';
212
+ const r = await inlineLocalImages(md, { readFile: fakeRead });
213
+ expect(r.content).toContain('data:image/png;base64,');
214
+ expect(r.content).toContain('https://cdn/keep.png'); // 远程 URL 不动
215
+ expect(r.content).not.toContain('/tmp/ok.png');
216
+ expect(r.inlined).toHaveLength(1);
217
+ expect(r.inlined[0].mime).toBe('image/png');
218
+ expect(r.missing).toHaveLength(0);
219
+ });
220
+ it('读不到的本机图片进 missing,原路径保留', async () => {
221
+ const r = await inlineLocalImages('![x](/tmp/nope.jpg)', { readFile: fakeRead });
222
+ expect(r.missing).toHaveLength(1);
223
+ expect(r.missing[0].src).toBe('/tmp/nope.jpg');
224
+ expect(r.content).toContain('/tmp/nope.jpg'); // 失败则原样保留
225
+ });
226
+ it('HTML <img> 本机 src 同样处理', async () => {
227
+ const r = await inlineLocalImages('<img src="/tmp/ok.png" alt="k">', { readFile: fakeRead });
228
+ expect(r.content).toContain('data:image/png;base64,');
229
+ expect(r.content).toContain('alt="k"'); // 其余属性保留
230
+ });
231
+ });
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { CommandExecutionError } from '@jackwener/opencli/errors';
20
20
  import { normalizeContent } from './format.js';
21
+ import { inlineLocalImages } from './images.js';
21
22
  import { PAGE_RUNTIME } from './page-runtime.js';
22
23
 
23
24
  /**
@@ -146,15 +147,30 @@ export async function publishArticle(page, args) {
146
147
  if (typeof profile.publish !== 'function') throw new Error('publishArticle: profile.publish must be a function');
147
148
 
148
149
  const norm = normalizeContent(body, { format });
149
- const content = profile.outputFormat === 'markdown' ? norm.markdown : norm.html;
150
+
151
+ // Node 侧:正文里引用的【本机图片路径】页面内 fetch 不到(会被当成站点相对 URL → 404),
152
+ // 先读成 data: URI 再注入页面,交给平台的图片转存把它上传到平台图床。
153
+ const mdInlined = await inlineLocalImages(norm.markdown);
154
+ const htmlInlined = await inlineLocalImages(norm.html);
155
+ const markdown = mdInlined.content;
156
+ const html = htmlInlined.content;
157
+ const content = profile.outputFormat === 'markdown' ? markdown : html;
158
+ // 合并两份的「本机图片读取失败」(按 src 去重),供命令层据此把结果判为 partial。
159
+ const localMissing = [];
160
+ const seenMissing = new Set();
161
+ for (const m of [...mdInlined.missing, ...htmlInlined.missing]) {
162
+ if (seenMissing.has(m.src)) continue;
163
+ seenMissing.add(m.src);
164
+ localMissing.push(m);
165
+ }
150
166
 
151
167
  await gotoWritePage(page, profile.home, profile.originRe);
152
168
 
153
169
  const ctx = {
154
170
  title,
155
171
  content,
156
- markdown: norm.markdown,
157
- html: norm.html,
172
+ markdown,
173
+ html,
158
174
  draftOnly: !!draftOnly,
159
175
  outputFormat: profile.outputFormat,
160
176
  preprocessConfig: profile.preprocessConfig || null,
@@ -175,7 +191,11 @@ export async function publishArticle(page, args) {
175
191
  id: result.id,
176
192
  url: result.url,
177
193
  draft: result.draft,
178
- images: { uploaded: result.uploaded || [], failed: result.failed || [] },
194
+ // 图片失败 = 页面内转存失败(result.failed) + Node 侧本机图片读取失败(localMissing)。
195
+ images: {
196
+ uploaded: result.uploaded || [],
197
+ failed: (result.failed || []).concat(localMissing),
198
+ },
179
199
  };
180
200
  }
181
201
 
@@ -34,8 +34,8 @@ async function resolvePayload(kwargs) {
34
34
  return resolved;
35
35
  }
36
36
 
37
- function buildResultRow(message, targetType, target, outcome, extra = {}) {
38
- return [{ status: 'success', outcome, message, target_type: targetType, target, ...extra }];
37
+ function buildResultRow(message, targetType, target, outcome, extra = {}, status = 'success') {
38
+ return [{ status, outcome, message, target_type: targetType, target, ...extra }];
39
39
  }
40
40
 
41
41
  // ── CSDN 平台 profile ─────────────────────────────────────────────────────────
@@ -110,13 +110,20 @@ const csdnProfile = {
110
110
 
111
111
  // 步骤一:下载图片字节
112
112
  var imageResponse = await fetch(src, { credentials: 'omit' });
113
- if (!imageResponse.ok) throw new Error('图片下载失败: ' + src);
113
+ if (!imageResponse.ok) throw new Error('图片下载失败: ' + src.slice(0, 80));
114
114
  var imageBlob = await imageResponse.blob();
115
115
 
116
- // 步骤二:获取文件扩展名
117
- var ext = (src.split('.').pop() || 'jpg').toLowerCase().split('?')[0];
116
+ // 步骤二:确定扩展名。优先从 URL 后缀猜;本机图片是以 data: URI 传进来的(无文件名),
117
+ // 后缀猜不出时退回 blob.type(如 image/png → png)。
118
118
  var validExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
119
- var validExt = validExts.indexOf(ext) !== -1 ? ext : 'jpg';
119
+ var ext = (src.split('?')[0].split('.').pop() || '').toLowerCase();
120
+ if (validExts.indexOf(ext) === -1) {
121
+ var mt = (imageBlob.type || '').toLowerCase(); // e.g. image/png
122
+ var sub = mt.indexOf('/') !== -1 ? mt.split('/')[1] : '';
123
+ if (sub === 'jpeg') sub = 'jpg';
124
+ ext = validExts.indexOf(sub) !== -1 ? sub : '';
125
+ }
126
+ var validExt = ext || 'jpg';
120
127
 
121
128
  // 步骤三:向 CSDN 请求上传签名
122
129
  var signApiPath = '/resource-api/v1/image/direct/upload/signature';
@@ -372,12 +379,25 @@ cli({
372
379
  if (upN || failN) {
373
380
  message += `·图片:${upN} 张转存成功${failN ? `,${failN} 张失败` : ''}`;
374
381
  }
382
+ // 有图片没转存成功 = 部分成功:正文已发/存草稿,但对应图片会裂。醒目提示 + status=partial,
383
+ // 避免云端 AI 把它当成功而漏掉图(正文与图片是一体的,缺图不算完整发布)。
384
+ if (failN > 0) {
385
+ const detail = result.images.failed
386
+ .map((f) => (f && f.src) || '').filter(Boolean).slice(0, 5).join(';');
387
+ message += `。⚠️ 有图片未成功转存(正文里这些图会裂),请检查图片来源后重发:${detail}`;
388
+ }
375
389
  return buildResultRow(
376
390
  message,
377
391
  'article',
378
392
  '',
379
393
  result.draft ? 'draft' : 'created',
380
- { created_target: 'article:' + result.id, created_url: result.url },
394
+ {
395
+ created_target: 'article:' + result.id,
396
+ created_url: result.url,
397
+ images_uploaded: upN,
398
+ images_failed: failN,
399
+ },
400
+ failN > 0 ? 'partial' : 'success',
381
401
  );
382
402
  },
383
403
  });
@@ -6,13 +6,10 @@ async function hasZhihuAuthCookie(page) {
6
6
  return cookies.some(c => c.name === 'z_c0' && c.value);
7
7
  }
8
8
 
9
- async function verifyZhihuIdentity(page) {
10
- if (!await hasZhihuAuthCookie(page)) {
11
- throw new AuthRequiredError('www.zhihu.com', 'Zhihu z_c0 cookie missing — anonymous');
12
- }
9
+ async function probeZhihuMe(page) {
13
10
  await page.goto('https://www.zhihu.com/');
14
11
  await page.wait(2);
15
- const data = await page.evaluate(`
12
+ return page.evaluate(`
16
13
  (async () => {
17
14
  try {
18
15
  const r = await fetch('https://www.zhihu.com/api/v4/me?include=url_token', { credentials: 'include' });
@@ -23,6 +20,19 @@ async function verifyZhihuIdentity(page) {
23
20
  }
24
21
  })()
25
22
  `);
23
+ }
24
+
25
+ async function verifyZhihuIdentity(page) {
26
+ if (!await hasZhihuAuthCookie(page)) {
27
+ throw new AuthRequiredError('www.zhihu.com', 'Zhihu z_c0 cookie missing — anonymous');
28
+ }
29
+ let data = await probeZhihuMe(page);
30
+ // 冷启动现象:z_c0 cookie 明明在,首次 /api/v4/me 却偶发 401/403(浏览器会话未热身)。
31
+ // 既然本地已有有效登录 cookie,就不该据此判定为匿名——短暂等待后重试一次再下结论。
32
+ if (data && (data.__httpError === 401 || data.__httpError === 403)) {
33
+ await page.wait(2);
34
+ data = await probeZhihuMe(page);
35
+ }
26
36
  if (data?.__exception) {
27
37
  throw new CommandExecutionError(`Zhihu whoami failed: ${data.__exception}`);
28
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "publishport-opencli",
3
- "version": "1.8.5-pp.7",
3
+ "version": "1.8.5-pp.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },