@klarkxy/dsh-zhihu 0.2.7 → 0.2.9
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 +13 -4
- package/docs/README.zh-CN.md +13 -4
- package/lib/client.js +357 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.js +2426 -1
- package/lib/index.js.map +1 -1
- package/lib/open-platform.js +2 -2
- package/lib/open-platform.js.map +1 -1
- package/lib/tools.js +188 -0
- package/lib/tools.js.map +1 -1
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +5 -3
package/lib/index.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { C as resolveZhihuCallPolicy, S as zhihuFetchJson, _ as executeZhihuSearch, a as ZHIHU_ASK_DEFAULT_MODEL, b as parseZhihuEnvelope, c as executeZhihuAsk, d as executeZhihuKnowledgeSearch, f as normalizeRecallScopes, i as zhihuOpenPlatformResultCount, l as executeZhihuGlobalSearch, n as executeZhihuOpenPlatform, o as ZHIHU_ASK_MODELS, r as resolveZhihuOpenPlatformOperation, t as ZHIHU_OPEN_PLATFORM_APIS, u as executeZhihuHotList, x as reportExecuted, y as ZhihuSearchError } from "./open-platform.js";
|
|
2
2
|
import { ZHIHU_CREDENTIAL_REF, ZHIHU_RPC_CHANNEL, ZHIHU_SEARCH_EVENT } from "./contracts.js";
|
|
3
3
|
import { createZhihuUsageRecorder, resolveDays, zhihuUsageDomainSpec } from "./usage.js";
|
|
4
|
+
import { createHash, createHmac, randomUUID } from "node:crypto";
|
|
4
5
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
6
|
+
import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
|
|
7
|
+
import { z } from "zod";
|
|
5
8
|
import { registerHostRpcTransport, sendHostRpcResponse } from "@klarkxy/dsh-plugin-kit/host-rpc";
|
|
6
9
|
import { WEB_PROVIDER_AVAILABILITY_EVENT } from "@klarkxy/dsh-plugin-kit/contracts";
|
|
10
|
+
import { lexer } from "marked";
|
|
11
|
+
import { parseFragment } from "parse5";
|
|
7
12
|
/** 设置页 RPC 走 JSON/base64 传输,限额压到 20MB(平台上限 100MB)。 */
|
|
8
13
|
const ZHIHU_KNOWLEDGE_UPLOAD_MAX_BYTES = 20971520;
|
|
9
14
|
const KNOWLEDGE_BASES_PATH = "/api/v1/knowledge/bases";
|
|
@@ -273,6 +278,2401 @@ function bindZhihuWebSearch(ctx, service) {
|
|
|
273
278
|
});
|
|
274
279
|
}
|
|
275
280
|
//#endregion
|
|
281
|
+
//#region src/sync-types.ts
|
|
282
|
+
/** 文章可用的评论权限(publish-article.md 枚举)。 */
|
|
283
|
+
const SYNC_ARTICLE_COMMENT_PERMISSIONS = [
|
|
284
|
+
"all",
|
|
285
|
+
"nobody",
|
|
286
|
+
"followee",
|
|
287
|
+
"censor",
|
|
288
|
+
"follower"
|
|
289
|
+
];
|
|
290
|
+
/** 想法可用的评论权限(publish-pin.md 枚举)。 */
|
|
291
|
+
const SYNC_PIN_COMMENT_PERMISSIONS = [
|
|
292
|
+
"all",
|
|
293
|
+
"nobody",
|
|
294
|
+
"followee",
|
|
295
|
+
"censor",
|
|
296
|
+
"follower_n_days"
|
|
297
|
+
];
|
|
298
|
+
/** 文章创作声明枚举(publish-article.md);pin 只允许 'ai_creation'。 */
|
|
299
|
+
const SYNC_ARTICLE_CREATION_STATEMENTS = [
|
|
300
|
+
"spoiler",
|
|
301
|
+
"medical_advice",
|
|
302
|
+
"fictional_creation",
|
|
303
|
+
"contain_finance",
|
|
304
|
+
"ai_creation"
|
|
305
|
+
];
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/sync-content.ts
|
|
308
|
+
/**
|
|
309
|
+
* 知乎同步内容转换与校验(确定性实现;规则来源见下,未复制上游代码)。
|
|
310
|
+
*
|
|
311
|
+
* 规则来源(只读镜像 H:\Reference\zhihu-sync-full_v0.1.4,MIT / author zhihu):
|
|
312
|
+
* zhihu-sync/zhihu-validate/reference/conversion.md — 标题/正文/媒体/链接卡片/公式/表格
|
|
313
|
+
* zhihu-sync/zhihu-validate/reference/config.md — topics/目录/创作声明/评论权限/圈子
|
|
314
|
+
* zhihu-sync/zhihu-validate/reference/code-languages.md — <pre lang> 语言 ID 表
|
|
315
|
+
* zhihu-sync/zhihu-publish/reference/publish-{article,question,pin}.md — 发布字段
|
|
316
|
+
*
|
|
317
|
+
* 分层:
|
|
318
|
+
* prepareZhihuSyncDraft(input) 规范化 SyncDraft → 知乎适配 html + issues + counts
|
|
319
|
+
* buildZhihuPublishRequest(prepared) SyncPrepared → publish OpenAPI request envelope
|
|
320
|
+
*
|
|
321
|
+
* 必须遵守的硬约束(不静默丢内容):
|
|
322
|
+
* - 标题/正文超限 → error issue,不截断、不自动精简(conversion.md §1.3)。
|
|
323
|
+
* - question 标题结尾无问号自动补「?」且计入长度(§2.1)。
|
|
324
|
+
* - 图片必须已上传:SyncImage.url 是发布 URL;originalUrl 仅用于按出现顺序匹配
|
|
325
|
+
* 正文图片引用与预览层展示,永不写入发布 body。正文图片无法解析 → error。
|
|
326
|
+
* - 公式 LaTeX 移除 $/$$ 定界符后【不 trim】;alt 与 src?tex= 来自同一串(§4.11)。
|
|
327
|
+
* - 想法正文仅纯文本段落、text-link、hash_tag(#话题名#,≤10);富文本降级+warning。
|
|
328
|
+
* - 想法独立图片(media 区)与 linkCard 互斥;两者并存 → error(§5.3/5.4)。
|
|
329
|
+
* - HTML 输入经 parse5 白名单消毒:script/iframe/object/embed/svg/style、on*、
|
|
330
|
+
* javascript:|vbscript:|file:|data: URL → error,危险节点不进入输出。
|
|
331
|
+
* - JSON 序列化只发生一次(发布请求体),body 不预转义。
|
|
332
|
+
*/
|
|
333
|
+
const SYNC_LIMITS = {
|
|
334
|
+
questionTitleMin: 4,
|
|
335
|
+
questionTitleMax: 51,
|
|
336
|
+
pinTitleMax: 50,
|
|
337
|
+
articleTitleMax: 100,
|
|
338
|
+
articleBodyMin: 9,
|
|
339
|
+
articleBodyMax: 1e5,
|
|
340
|
+
pinBodyMax: 2e3,
|
|
341
|
+
pinImagesMax: 18,
|
|
342
|
+
pinTopicsMax: 10,
|
|
343
|
+
articleTopicsMax: 3,
|
|
344
|
+
questionTopicsMax: 5,
|
|
345
|
+
captionMax: 140
|
|
346
|
+
};
|
|
347
|
+
const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
348
|
+
const escapeHtml = (v) => v.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
349
|
+
const escapeAttr = escapeHtml;
|
|
350
|
+
function isAbsoluteHttpUrl(value) {
|
|
351
|
+
try {
|
|
352
|
+
const u = new URL(value);
|
|
353
|
+
return u.protocol === "http:" || u.protocol === "https:";
|
|
354
|
+
} catch {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* 纯文本计数(conversion.md §3「按纯文本计,不含 HTML 标签」):
|
|
360
|
+
* 用 parse5 把 html 解析为文本节点再拼接 —— 解码 & / < 等实体,
|
|
361
|
+
* 不去除用户正文中的正常空白(空格/换行仍算一个字符,按码点计)。
|
|
362
|
+
* 内部占位符(公式/图片)先剔除再计数,不计入用户正文。
|
|
363
|
+
*/
|
|
364
|
+
function plainTextLength(html) {
|
|
365
|
+
const cleaned = html.replace(/ZHIHUIMG\d+PLACEHOLDER/g, "").replace(/ZHIHUFORMULA\d+PLACEHOLDER/g, "");
|
|
366
|
+
const frag = parseFragment(cleaned);
|
|
367
|
+
let text = "";
|
|
368
|
+
const walk = (nodes) => {
|
|
369
|
+
for (const n of nodes) if (n.nodeName === "#text") text += n.value;
|
|
370
|
+
else walk(n.childNodes ?? []);
|
|
371
|
+
};
|
|
372
|
+
walk(frag.childNodes);
|
|
373
|
+
return [...text].length;
|
|
374
|
+
}
|
|
375
|
+
const SUPPORTED_LANGS = /* @__PURE__ */ new Set([
|
|
376
|
+
"abap",
|
|
377
|
+
"abnf",
|
|
378
|
+
"actionscript",
|
|
379
|
+
"ada",
|
|
380
|
+
"adl",
|
|
381
|
+
"agda",
|
|
382
|
+
"ahk",
|
|
383
|
+
"alloy",
|
|
384
|
+
"antlr",
|
|
385
|
+
"apacheconf",
|
|
386
|
+
"apl",
|
|
387
|
+
"applescript",
|
|
388
|
+
"arduino",
|
|
389
|
+
"as",
|
|
390
|
+
"as3",
|
|
391
|
+
"asciidoc",
|
|
392
|
+
"aspectj",
|
|
393
|
+
"asy",
|
|
394
|
+
"at",
|
|
395
|
+
"autoit",
|
|
396
|
+
"awk",
|
|
397
|
+
"basemake",
|
|
398
|
+
"bash",
|
|
399
|
+
"bat",
|
|
400
|
+
"bbcode",
|
|
401
|
+
"bc",
|
|
402
|
+
"befunge",
|
|
403
|
+
"bib",
|
|
404
|
+
"blitzbasic",
|
|
405
|
+
"blitzmax",
|
|
406
|
+
"bnf",
|
|
407
|
+
"boo",
|
|
408
|
+
"boogie",
|
|
409
|
+
"brainfuck",
|
|
410
|
+
"bro",
|
|
411
|
+
"bugs",
|
|
412
|
+
"c",
|
|
413
|
+
"c++",
|
|
414
|
+
"c#",
|
|
415
|
+
"ca65",
|
|
416
|
+
"cadl",
|
|
417
|
+
"camkes",
|
|
418
|
+
"cbmbas",
|
|
419
|
+
"ceylon",
|
|
420
|
+
"cfengine3",
|
|
421
|
+
"cfm",
|
|
422
|
+
"cfs",
|
|
423
|
+
"chai",
|
|
424
|
+
"chapel",
|
|
425
|
+
"cheetah",
|
|
426
|
+
"cirru",
|
|
427
|
+
"clay",
|
|
428
|
+
"clojure",
|
|
429
|
+
"clojurescript",
|
|
430
|
+
"cmake",
|
|
431
|
+
"cobol",
|
|
432
|
+
"cobolfree",
|
|
433
|
+
"coffee-script",
|
|
434
|
+
"common-lisp",
|
|
435
|
+
"componentpascal",
|
|
436
|
+
"console",
|
|
437
|
+
"control",
|
|
438
|
+
"coq",
|
|
439
|
+
"cpp",
|
|
440
|
+
"cpp-objdump",
|
|
441
|
+
"cpsa",
|
|
442
|
+
"crmsh",
|
|
443
|
+
"croc",
|
|
444
|
+
"cryptol",
|
|
445
|
+
"csharp",
|
|
446
|
+
"csound",
|
|
447
|
+
"csound-document",
|
|
448
|
+
"csound-score",
|
|
449
|
+
"css",
|
|
450
|
+
"cuda",
|
|
451
|
+
"cython",
|
|
452
|
+
"d",
|
|
453
|
+
"d-objdump",
|
|
454
|
+
"darcs",
|
|
455
|
+
"dart",
|
|
456
|
+
"debcontrol",
|
|
457
|
+
"debian",
|
|
458
|
+
"delphi",
|
|
459
|
+
"dg",
|
|
460
|
+
"diff",
|
|
461
|
+
"django",
|
|
462
|
+
"docker",
|
|
463
|
+
"dpatch",
|
|
464
|
+
"dtd",
|
|
465
|
+
"duel",
|
|
466
|
+
"dylan",
|
|
467
|
+
"dylan-console",
|
|
468
|
+
"dylanlid",
|
|
469
|
+
"ebnf",
|
|
470
|
+
"ecl",
|
|
471
|
+
"eiffel",
|
|
472
|
+
"elixir",
|
|
473
|
+
"elm",
|
|
474
|
+
"emacslisp",
|
|
475
|
+
"erb",
|
|
476
|
+
"erl",
|
|
477
|
+
"erlang",
|
|
478
|
+
"evoque",
|
|
479
|
+
"factor",
|
|
480
|
+
"fancy",
|
|
481
|
+
"fantom",
|
|
482
|
+
"felix",
|
|
483
|
+
"fortran",
|
|
484
|
+
"foxpro",
|
|
485
|
+
"fsharp",
|
|
486
|
+
"gas",
|
|
487
|
+
"genshi",
|
|
488
|
+
"glsl",
|
|
489
|
+
"gnuplot",
|
|
490
|
+
"go",
|
|
491
|
+
"golo",
|
|
492
|
+
"gooddata-cl",
|
|
493
|
+
"gosu",
|
|
494
|
+
"groff",
|
|
495
|
+
"groovy",
|
|
496
|
+
"gst",
|
|
497
|
+
"haml",
|
|
498
|
+
"handlebars",
|
|
499
|
+
"haskell",
|
|
500
|
+
"haxeml",
|
|
501
|
+
"html",
|
|
502
|
+
"httpd",
|
|
503
|
+
"hy",
|
|
504
|
+
"idl",
|
|
505
|
+
"idris",
|
|
506
|
+
"igor",
|
|
507
|
+
"inform6",
|
|
508
|
+
"inform7",
|
|
509
|
+
"ini",
|
|
510
|
+
"io",
|
|
511
|
+
"ioke",
|
|
512
|
+
"irb",
|
|
513
|
+
"jade",
|
|
514
|
+
"java",
|
|
515
|
+
"js",
|
|
516
|
+
"json",
|
|
517
|
+
"julia",
|
|
518
|
+
"jvm",
|
|
519
|
+
"kal",
|
|
520
|
+
"kconfig",
|
|
521
|
+
"koka",
|
|
522
|
+
"kotlin",
|
|
523
|
+
"lagda",
|
|
524
|
+
"lasso",
|
|
525
|
+
"lhs",
|
|
526
|
+
"lidr",
|
|
527
|
+
"lighty",
|
|
528
|
+
"lisp",
|
|
529
|
+
"live-script",
|
|
530
|
+
"llvm",
|
|
531
|
+
"logos",
|
|
532
|
+
"logtalk",
|
|
533
|
+
"lua",
|
|
534
|
+
"make",
|
|
535
|
+
"mako",
|
|
536
|
+
"mason",
|
|
537
|
+
"mask",
|
|
538
|
+
"matlab",
|
|
539
|
+
"md",
|
|
540
|
+
"minid",
|
|
541
|
+
"modelica",
|
|
542
|
+
"modula2",
|
|
543
|
+
"monkey",
|
|
544
|
+
"moocode",
|
|
545
|
+
"moon",
|
|
546
|
+
"mscgen",
|
|
547
|
+
"mupad",
|
|
548
|
+
"mxml",
|
|
549
|
+
"mysql",
|
|
550
|
+
"nasm",
|
|
551
|
+
"newlisp",
|
|
552
|
+
"nginx",
|
|
553
|
+
"nim",
|
|
554
|
+
"nit",
|
|
555
|
+
"nixos",
|
|
556
|
+
"nsis",
|
|
557
|
+
"numpy",
|
|
558
|
+
"objdump-c",
|
|
559
|
+
"objdump-cpp",
|
|
560
|
+
"objdump-d",
|
|
561
|
+
"objdump-nasm",
|
|
562
|
+
"objective-c",
|
|
563
|
+
"objective-j",
|
|
564
|
+
"ocaml",
|
|
565
|
+
"octave",
|
|
566
|
+
"odin",
|
|
567
|
+
"ooc",
|
|
568
|
+
"opa",
|
|
569
|
+
"openedge",
|
|
570
|
+
"pacmanconf",
|
|
571
|
+
"pan",
|
|
572
|
+
"pawn",
|
|
573
|
+
"perl",
|
|
574
|
+
"php",
|
|
575
|
+
"pig",
|
|
576
|
+
"pike",
|
|
577
|
+
"pkgconfig",
|
|
578
|
+
"plpgsql",
|
|
579
|
+
"postscript",
|
|
580
|
+
"powershell",
|
|
581
|
+
"prolog",
|
|
582
|
+
"properties",
|
|
583
|
+
"protobuf",
|
|
584
|
+
"psql",
|
|
585
|
+
"puppet",
|
|
586
|
+
"py",
|
|
587
|
+
"pycon",
|
|
588
|
+
"pypylog",
|
|
589
|
+
"pytb",
|
|
590
|
+
"python",
|
|
591
|
+
"python3",
|
|
592
|
+
"qbasic",
|
|
593
|
+
"r",
|
|
594
|
+
"rake",
|
|
595
|
+
"rb",
|
|
596
|
+
"rbcon",
|
|
597
|
+
"rebol",
|
|
598
|
+
"red",
|
|
599
|
+
"redcode",
|
|
600
|
+
"registry",
|
|
601
|
+
"resources",
|
|
602
|
+
"robotframework",
|
|
603
|
+
"rq",
|
|
604
|
+
"rql",
|
|
605
|
+
"rst",
|
|
606
|
+
"rts",
|
|
607
|
+
"rust",
|
|
608
|
+
"sass",
|
|
609
|
+
"scala",
|
|
610
|
+
"scaml",
|
|
611
|
+
"scheme",
|
|
612
|
+
"scilab",
|
|
613
|
+
"scss",
|
|
614
|
+
"sh",
|
|
615
|
+
"shell",
|
|
616
|
+
"smarty",
|
|
617
|
+
"snobol",
|
|
618
|
+
"sourceslist",
|
|
619
|
+
"sp",
|
|
620
|
+
"sparql",
|
|
621
|
+
"sql",
|
|
622
|
+
"squidconf",
|
|
623
|
+
"sre",
|
|
624
|
+
"stan",
|
|
625
|
+
"swift",
|
|
626
|
+
"swig",
|
|
627
|
+
"systemverilog",
|
|
628
|
+
"tads3",
|
|
629
|
+
"tap",
|
|
630
|
+
"tcl",
|
|
631
|
+
"tcsh",
|
|
632
|
+
"tea",
|
|
633
|
+
"termcap",
|
|
634
|
+
"terminfo",
|
|
635
|
+
"terraform",
|
|
636
|
+
"text",
|
|
637
|
+
"textile",
|
|
638
|
+
"thrift",
|
|
639
|
+
"todotxt",
|
|
640
|
+
"trac-wiki",
|
|
641
|
+
"treetop",
|
|
642
|
+
"ts",
|
|
643
|
+
"turtle",
|
|
644
|
+
"twig",
|
|
645
|
+
"urbiscript",
|
|
646
|
+
"v",
|
|
647
|
+
"vala",
|
|
648
|
+
"vb.net",
|
|
649
|
+
"vctreestatus",
|
|
650
|
+
"velocity",
|
|
651
|
+
"verilog",
|
|
652
|
+
"vgl",
|
|
653
|
+
"vhdl",
|
|
654
|
+
"vim",
|
|
655
|
+
"x10",
|
|
656
|
+
"xml",
|
|
657
|
+
"xquery",
|
|
658
|
+
"xslt",
|
|
659
|
+
"xtend",
|
|
660
|
+
"xul+mozpreproc",
|
|
661
|
+
"yaml",
|
|
662
|
+
"yaml+jinja",
|
|
663
|
+
"zephir"
|
|
664
|
+
]);
|
|
665
|
+
/** lang ID 归一化:不在支持表 → ''。 */
|
|
666
|
+
function normalizeCodeLang(raw) {
|
|
667
|
+
if (!raw) return "";
|
|
668
|
+
const id = raw.trim().toLowerCase();
|
|
669
|
+
return SUPPORTED_LANGS.has(id) ? id : "";
|
|
670
|
+
}
|
|
671
|
+
function latexToEquationImg(latex, block) {
|
|
672
|
+
return `<img eeimg="${block ? 2 : 1}" src="//www.zhihu.com/equation?tex=${encodeURIComponent(latex)}" alt="${escapeAttr(latex)}" />`;
|
|
673
|
+
}
|
|
674
|
+
const error = (ctx, code, message) => ctx.issues.push({
|
|
675
|
+
severity: "error",
|
|
676
|
+
code,
|
|
677
|
+
message
|
|
678
|
+
});
|
|
679
|
+
const warning = (ctx, code, message) => ctx.issues.push({
|
|
680
|
+
severity: "warning",
|
|
681
|
+
code,
|
|
682
|
+
message
|
|
683
|
+
});
|
|
684
|
+
/** 每次 prepare 生成唯一占位前缀,避免与用户正文碰撞。 */
|
|
685
|
+
function makePlaceholders() {
|
|
686
|
+
const prefix = `ZHIHU_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}_`;
|
|
687
|
+
return {
|
|
688
|
+
img: (i) => `${prefix}IMG${i}`,
|
|
689
|
+
formula: (i) => `${prefix}FORMULA${i}`
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
const VOID_RAW_TAGS = /* @__PURE__ */ new Set([
|
|
693
|
+
"br",
|
|
694
|
+
"hr",
|
|
695
|
+
"img",
|
|
696
|
+
"input",
|
|
697
|
+
"meta",
|
|
698
|
+
"link",
|
|
699
|
+
"area",
|
|
700
|
+
"base",
|
|
701
|
+
"col",
|
|
702
|
+
"embed",
|
|
703
|
+
"source",
|
|
704
|
+
"track",
|
|
705
|
+
"wbr"
|
|
706
|
+
]);
|
|
707
|
+
/** 行内 raw HTML 开标签 → 在 token 流中找匹配 </tag>,把整段 raw 交给 parse5 消毒。
|
|
708
|
+
* marked 会把 <b>x</b> 拆成独立 html token,单独 sanitize 会得到空壳 <b></b>。
|
|
709
|
+
* 返回整段 raw;找不到闭合标签或自闭合/void 时只返回当前 token raw。 */
|
|
710
|
+
function rawHtmlRun(tokens, start) {
|
|
711
|
+
const first = tokens[start].raw ?? "";
|
|
712
|
+
const m = first.match(/^<([a-zA-Z][a-zA-Z0-9-]*)\b/);
|
|
713
|
+
if (!m || /\/>$/.test(first) || VOID_RAW_TAGS.has(m[1].toLowerCase())) return {
|
|
714
|
+
raw: first,
|
|
715
|
+
next: start + 1
|
|
716
|
+
};
|
|
717
|
+
const tag = m[1].toLowerCase();
|
|
718
|
+
const closeRe = new RegExp(`^</${tag}\\s*>`, "i");
|
|
719
|
+
for (let i = start + 1; i < tokens.length; i++) {
|
|
720
|
+
const t = tokens[i];
|
|
721
|
+
if (t.type === "html" && closeRe.test(t.raw ?? "")) return {
|
|
722
|
+
raw: tokens.slice(start, i + 1).map((x) => x.raw ?? "").join(""),
|
|
723
|
+
next: i + 1
|
|
724
|
+
};
|
|
725
|
+
if (t.type !== "html" && t.type !== "text" && t.type !== "escape") break;
|
|
726
|
+
}
|
|
727
|
+
return {
|
|
728
|
+
raw: first,
|
|
729
|
+
next: start + 1
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
function inlineToHtml(tokens, ctx) {
|
|
733
|
+
if (!tokens?.length) return "";
|
|
734
|
+
let out = "";
|
|
735
|
+
for (let ti = 0; ti < tokens.length; ti++) {
|
|
736
|
+
const t = tokens[ti];
|
|
737
|
+
switch (t.type) {
|
|
738
|
+
case "text": {
|
|
739
|
+
const tok = t;
|
|
740
|
+
if (tok.tokens?.length) out += inlineToHtml(tok.tokens, ctx);
|
|
741
|
+
else {
|
|
742
|
+
const base = ctx.formulas.length;
|
|
743
|
+
const extracted = extractFormulas(decodeEntities(tok.text), {
|
|
744
|
+
...ctx.ph,
|
|
745
|
+
formula: (index) => ctx.ph.formula(base + index)
|
|
746
|
+
});
|
|
747
|
+
ctx.formulas.push(...extracted.formulas);
|
|
748
|
+
out += escapeHtml(extracted.text);
|
|
749
|
+
}
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
case "escape":
|
|
753
|
+
out += escapeHtml(t.text);
|
|
754
|
+
break;
|
|
755
|
+
case "strong":
|
|
756
|
+
if (ctx.pinMode) {
|
|
757
|
+
warning(ctx, "pin.unsupported-rich-text", "想法正文不支持加粗,已降级为纯文本。");
|
|
758
|
+
out += inlineToHtml(t.tokens, ctx);
|
|
759
|
+
} else out += `<b>${inlineToHtml(t.tokens, ctx)}</b>`;
|
|
760
|
+
break;
|
|
761
|
+
case "em":
|
|
762
|
+
if (ctx.pinMode) {
|
|
763
|
+
warning(ctx, "pin.unsupported-rich-text", "想法正文不支持斜体,已降级为纯文本。");
|
|
764
|
+
out += inlineToHtml(t.tokens, ctx);
|
|
765
|
+
} else out += `<i>${inlineToHtml(t.tokens, ctx)}</i>`;
|
|
766
|
+
break;
|
|
767
|
+
case "codespan":
|
|
768
|
+
if (ctx.pinMode) {
|
|
769
|
+
warning(ctx, "pin.unsupported-rich-text", "想法正文不支持行内代码,已降级为纯文本。");
|
|
770
|
+
out += escapeHtml(t.text);
|
|
771
|
+
} else out += `<code>${escapeHtml(t.text)}</code>`;
|
|
772
|
+
break;
|
|
773
|
+
case "del":
|
|
774
|
+
warning(ctx, "unsupported.del", "删除线不受支持,已降级为纯文本。");
|
|
775
|
+
out += inlineToHtml(t.tokens, ctx);
|
|
776
|
+
break;
|
|
777
|
+
case "link": {
|
|
778
|
+
const link = t;
|
|
779
|
+
const inner = inlineToHtml(link.tokens, ctx);
|
|
780
|
+
const innerText = inner.replace(/<[^>]*>/g, "");
|
|
781
|
+
const href = (link.href ?? "").trim();
|
|
782
|
+
if (href.startsWith("#")) {
|
|
783
|
+
out += inner;
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
if (!isAbsoluteHttpUrl(href)) {
|
|
787
|
+
warning(ctx, "link.unsafe-url", `链接地址不受支持,已保留为纯文本:${href || "(空)"}。`);
|
|
788
|
+
out += inner;
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
if (ctx.pinMode) out += `<a href="${escapeAttr(href)}" data-insert-way="force" data-draft-node="inline" data-draft-type="text-link" data-draft-title="${escapeAttr(innerText || href)}">${inner}</a>`;
|
|
792
|
+
else out += `<a href="${escapeAttr(href)}" data-draft-title="${escapeAttr(innerText || href)}">${inner}</a>`;
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
case "image": {
|
|
796
|
+
const img = t;
|
|
797
|
+
const idx = ctx.bodyImages.length;
|
|
798
|
+
ctx.bodyImages.push({
|
|
799
|
+
src: (img.href ?? "").trim(),
|
|
800
|
+
caption: img.text ?? ""
|
|
801
|
+
});
|
|
802
|
+
out += ctx.ph.img(idx);
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
case "br":
|
|
806
|
+
out += "<br>";
|
|
807
|
+
break;
|
|
808
|
+
case "html": {
|
|
809
|
+
const run = rawHtmlRun(tokens, ti);
|
|
810
|
+
ti = run.next - 1;
|
|
811
|
+
const raw = run.raw.trim();
|
|
812
|
+
if (raw) out += sanitizeInlineHtml(raw, ctx);
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
default: {
|
|
816
|
+
const anyT = t;
|
|
817
|
+
if (anyT.tokens?.length) out += inlineToHtml(anyT.tokens, ctx);
|
|
818
|
+
else if (typeof anyT.text === "string") out += escapeHtml(anyT.text);
|
|
819
|
+
else if (typeof anyT.raw === "string") out += escapeHtml(anyT.raw);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return out;
|
|
824
|
+
}
|
|
825
|
+
function blockToHtml(tokens, ctx) {
|
|
826
|
+
if (!tokens?.length) return "";
|
|
827
|
+
let out = "";
|
|
828
|
+
for (const t of tokens) switch (t.type) {
|
|
829
|
+
case "space": break;
|
|
830
|
+
case "paragraph": {
|
|
831
|
+
const inner = inlineToHtml(t.tokens, ctx);
|
|
832
|
+
out += inner.trim() ? `<p>${inner}</p>` : "<p><br></p>";
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
case "heading": {
|
|
836
|
+
const h = t;
|
|
837
|
+
const inner = inlineToHtml(h.tokens, ctx);
|
|
838
|
+
if (ctx.pinMode) {
|
|
839
|
+
warning(ctx, "pin.unsupported-rich-text", "想法正文不支持标题,已降级为段落文本。");
|
|
840
|
+
if (inner.trim()) out += `<p>${inner}</p>`;
|
|
841
|
+
} else {
|
|
842
|
+
const level = h.depth <= 2 ? 2 : 3;
|
|
843
|
+
out += `<h${level}>${inner}</h${level}>`;
|
|
844
|
+
}
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
847
|
+
case "hr":
|
|
848
|
+
out += "<hr />";
|
|
849
|
+
break;
|
|
850
|
+
case "code": {
|
|
851
|
+
const code = t;
|
|
852
|
+
if (ctx.pinMode) {
|
|
853
|
+
warning(ctx, "pin.unsupported-rich-text", "想法正文不支持代码块,已降级为文本。");
|
|
854
|
+
out += `<p>${escapeHtml(code.text.trimEnd())}</p>`;
|
|
855
|
+
} else out += `<pre lang="${normalizeCodeLang(code.lang)}">${escapeHtml(code.text.replace(/\n$/, ""))}</pre>`;
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
case "blockquote": {
|
|
859
|
+
const inner = blockToHtml(t.tokens, ctx);
|
|
860
|
+
if (ctx.pinMode) {
|
|
861
|
+
warning(ctx, "pin.unsupported-rich-text", "想法正文不支持引用块,已降级为文本。");
|
|
862
|
+
const text = inner.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
863
|
+
if (text) out += `<p>${escapeHtml(text)}</p>`;
|
|
864
|
+
} else {
|
|
865
|
+
const flattened = inner.replace(/<\/p>\s*<p>/g, "<br>").replace(/^<p>/, "").replace(/<\/p>$/, "");
|
|
866
|
+
out += `<blockquote>${flattened}</blockquote>`;
|
|
867
|
+
}
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
case "list": {
|
|
871
|
+
const list = t;
|
|
872
|
+
if (ctx.pinMode) {
|
|
873
|
+
warning(ctx, "pin.unsupported-rich-text", "想法正文不支持列表,已降级为段落文本。");
|
|
874
|
+
for (const item of list.items) {
|
|
875
|
+
const inner = inlineToHtml(item.tokens, ctx);
|
|
876
|
+
if (inner.trim()) out += `<p>${inner}</p>`;
|
|
877
|
+
}
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
const tag = list.ordered ? "ol" : "ul";
|
|
881
|
+
const items = list.items.map((item) => `<li>${inlineToHtml(item.tokens, ctx)}</li>`).join("");
|
|
882
|
+
out += `<${tag}>${items}</${tag}>`;
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
case "table": {
|
|
886
|
+
const table = t;
|
|
887
|
+
if (ctx.pinMode) {
|
|
888
|
+
warning(ctx, "pin.unsupported-rich-text", "想法正文不支持表格,已降级为文本。");
|
|
889
|
+
const text = table.raw.replace(/<[^>]+>/g, " ").replace(/[|:-]+/g, " ").replace(/\s+/g, " ").trim();
|
|
890
|
+
if (text) out += `<p>${escapeHtml(text)}</p>`;
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
const head = table.header.map((c) => `<th>${inlineToHtml(c.tokens, ctx)}</th>`).join("");
|
|
894
|
+
const rows = table.rows.map((r) => `<tr>${r.map((c) => `<td>${inlineToHtml(c.tokens, ctx)}</td>`).join("")}</tr>`).join("");
|
|
895
|
+
out += `<table data-draft-node="block" data-draft-type="table" data-size="normal" data-row-style="normal"><tbody><tr>${head}</tr>${rows}</tbody></table>`;
|
|
896
|
+
break;
|
|
897
|
+
}
|
|
898
|
+
case "html": {
|
|
899
|
+
const raw = t.text ?? t.raw ?? "";
|
|
900
|
+
if (raw.trim()) out += sanitizeInputHtml(raw, ctx);
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
default: {
|
|
904
|
+
const anyT = t;
|
|
905
|
+
if (anyT.tokens?.length) {
|
|
906
|
+
const inner = blockToHtml(anyT.tokens, ctx);
|
|
907
|
+
if (inner.trim()) out += inner;
|
|
908
|
+
} else if (typeof anyT.raw === "string" && anyT.raw.trim()) out += `<p>${escapeHtml(anyT.raw.trim())}</p>`;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return out;
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* 提取 $...$ / $$...$$ 公式(marked 无内建数学扩展,预扫描后在输出中恢复)。
|
|
915
|
+
* 移除定界符后保留内侧全部字符(含首尾空白)——不 trim。
|
|
916
|
+
* 货币场景保真:`$5`、`$10` 这类 $ 紧跟数字的当普通文字,不当公式定界。
|
|
917
|
+
* $$...$$ 始终以 $$ 开头,不受单 $ 货币规则影响。
|
|
918
|
+
*/
|
|
919
|
+
function extractFormulas(text, ph) {
|
|
920
|
+
const formulas = [];
|
|
921
|
+
let out = "";
|
|
922
|
+
let i = 0;
|
|
923
|
+
while (i < text.length) {
|
|
924
|
+
const dollar = text.indexOf("$", i);
|
|
925
|
+
if (dollar < 0) {
|
|
926
|
+
out += text.slice(i);
|
|
927
|
+
break;
|
|
928
|
+
}
|
|
929
|
+
if (dollar > 0 && text[dollar - 1] === "\\") {
|
|
930
|
+
out += text.slice(i, dollar + 1);
|
|
931
|
+
i = dollar + 1;
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
const isDouble = text[dollar + 1] === "$";
|
|
935
|
+
const openLen = isDouble ? 2 : 1;
|
|
936
|
+
const afterOpen = text.slice(dollar + openLen, dollar + openLen + 2);
|
|
937
|
+
if (!isDouble && (/\d/.test(afterOpen[0] ?? "") || /^[A-Z][A-Z]/.test(afterOpen))) {
|
|
938
|
+
out += text.slice(i, dollar + 1);
|
|
939
|
+
i = dollar + 1;
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
const close = text.indexOf(isDouble ? "$$" : "$", dollar + openLen);
|
|
943
|
+
if (close < 0) {
|
|
944
|
+
out += text.slice(i);
|
|
945
|
+
break;
|
|
946
|
+
}
|
|
947
|
+
const latex = text.slice(dollar + openLen, close);
|
|
948
|
+
if (!latex) {
|
|
949
|
+
out += text.slice(i, close + openLen);
|
|
950
|
+
i = close + openLen;
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
if (!isDouble && /\d/.test(text[close + openLen] ?? "")) {
|
|
954
|
+
out += text.slice(i, close + openLen);
|
|
955
|
+
i = close + openLen;
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
formulas.push({
|
|
959
|
+
block: isDouble,
|
|
960
|
+
latex
|
|
961
|
+
});
|
|
962
|
+
out += text.slice(i, dollar) + ph.formula(formulas.length - 1);
|
|
963
|
+
i = close + openLen;
|
|
964
|
+
}
|
|
965
|
+
return {
|
|
966
|
+
text: out,
|
|
967
|
+
formulas
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* 扫描纯文本里的 #话题名#,转成 hash_tag <a>。
|
|
972
|
+
* 用 parse5 遍历 DOM 树,只在文本节点内做替换——不触碰属性值,
|
|
973
|
+
* 不会在已有 <a> 内嵌套 <a>,不会双转义。
|
|
974
|
+
*/
|
|
975
|
+
function injectPinHashTags(html) {
|
|
976
|
+
const frag = parseFragment(html);
|
|
977
|
+
let count = 0;
|
|
978
|
+
const walk = (parent) => {
|
|
979
|
+
for (let i = 0; i < parent.childNodes.length; i++) {
|
|
980
|
+
const node = parent.childNodes[i];
|
|
981
|
+
if (node.nodeName === "#text") {
|
|
982
|
+
const text = node.value;
|
|
983
|
+
if (!text.includes("#")) continue;
|
|
984
|
+
const parts = [];
|
|
985
|
+
let last = 0;
|
|
986
|
+
text.replace(/#([^#\n<>]{1,200})#/g, (whole, name, offset) => {
|
|
987
|
+
if (offset > last) parts.push({
|
|
988
|
+
nodeName: "#text",
|
|
989
|
+
value: text.slice(last, offset),
|
|
990
|
+
parentNode: null,
|
|
991
|
+
nextNode: null,
|
|
992
|
+
prevNode: null
|
|
993
|
+
});
|
|
994
|
+
const topic = `#${name}#`;
|
|
995
|
+
const anchor = {
|
|
996
|
+
nodeName: "a",
|
|
997
|
+
tagName: "a",
|
|
998
|
+
attrs: [{
|
|
999
|
+
name: "class",
|
|
1000
|
+
value: "hash_tag"
|
|
1001
|
+
}, {
|
|
1002
|
+
name: "data-topic-name",
|
|
1003
|
+
value: topic
|
|
1004
|
+
}],
|
|
1005
|
+
childNodes: [{
|
|
1006
|
+
nodeName: "#text",
|
|
1007
|
+
value: topic
|
|
1008
|
+
}],
|
|
1009
|
+
namespaceURI: "http://www.w3.org/1999/xhtml"
|
|
1010
|
+
};
|
|
1011
|
+
parts.push(anchor);
|
|
1012
|
+
count += 1;
|
|
1013
|
+
last = offset + whole.length;
|
|
1014
|
+
return whole;
|
|
1015
|
+
});
|
|
1016
|
+
if (count > 0 && parts.length) {
|
|
1017
|
+
if (last < text.length) parts.push({
|
|
1018
|
+
nodeName: "#text",
|
|
1019
|
+
value: text.slice(last),
|
|
1020
|
+
parentNode: null,
|
|
1021
|
+
nextNode: null,
|
|
1022
|
+
prevNode: null
|
|
1023
|
+
});
|
|
1024
|
+
parent.childNodes.splice(i, 1, ...parts);
|
|
1025
|
+
i += parts.length - 1;
|
|
1026
|
+
}
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
if (node.tagName?.toLowerCase() === "a") continue;
|
|
1030
|
+
if ("childNodes" in node) walk(node);
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
walk(frag);
|
|
1034
|
+
return {
|
|
1035
|
+
html: frag.childNodes.map((c) => serializeNode(c)).join(""),
|
|
1036
|
+
count
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
function serializeNode(node) {
|
|
1040
|
+
if (node.nodeName === "#text") return escapeHtml(node.value);
|
|
1041
|
+
const el = node;
|
|
1042
|
+
const tag = el.tagName?.toLowerCase();
|
|
1043
|
+
if (!tag) return "";
|
|
1044
|
+
if (VOID_TAGS.has(tag)) {
|
|
1045
|
+
const attrs = (el.attrs ?? []).map((a) => `${a.name}="${escapeAttr(a.value)}"`).join(" ");
|
|
1046
|
+
return `<${tag}${attrs ? " " + attrs : ""} />`;
|
|
1047
|
+
}
|
|
1048
|
+
const attrs = (el.attrs ?? []).map((a) => `${a.name}="${escapeAttr(a.value)}"`).join(" ");
|
|
1049
|
+
const inner = (el.childNodes ?? []).map((c) => serializeNode(c)).join("");
|
|
1050
|
+
return `<${tag}${attrs ? " " + attrs : ""}>${inner}</${tag}>`;
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* 把 draft.body 中的图片引用按出现顺序与 draft.images 中的 SyncImage 匹配。
|
|
1054
|
+
* 匹配键:SyncImage.originalUrl 等于引用 src(允许 file:// 归一化差异);
|
|
1055
|
+
* 若未提供 originalUrl,则按顺序消费。无法匹配到已上传 URL → error。
|
|
1056
|
+
*/
|
|
1057
|
+
function resolveBodyImages(ctx, draft) {
|
|
1058
|
+
const uploaded = draft.images ?? [];
|
|
1059
|
+
const unused = new Set(uploaded.map((_, i) => i));
|
|
1060
|
+
const normalize = (v) => {
|
|
1061
|
+
let s = v.trim().replace(/^file:\/\//i, "").replace(/\\/g, "/");
|
|
1062
|
+
try {
|
|
1063
|
+
if (encodeURIComponent(decodeURIComponent(s)) === s) s = decodeURIComponent(s);
|
|
1064
|
+
} catch {}
|
|
1065
|
+
return s.toLowerCase();
|
|
1066
|
+
};
|
|
1067
|
+
for (const ref of ctx.bodyImages) {
|
|
1068
|
+
let matched = -1;
|
|
1069
|
+
for (const i of unused) {
|
|
1070
|
+
const img = uploaded[i];
|
|
1071
|
+
if (img.originalUrl && normalize(img.originalUrl) === normalize(ref.src)) {
|
|
1072
|
+
matched = i;
|
|
1073
|
+
break;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
if (matched < 0) for (const i of unused) {
|
|
1077
|
+
const img = uploaded[i];
|
|
1078
|
+
if (!img.originalUrl || normalize(img.originalUrl) === normalize(ref.src)) {
|
|
1079
|
+
matched = i;
|
|
1080
|
+
break;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
if (matched < 0) {
|
|
1084
|
+
error(ctx, "image.unresolved", `正文图片未找到对应的上传结果:${ref.src || "(空 src)"}。请先上传图片后再保存/发布。`);
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
unused.delete(matched);
|
|
1088
|
+
ref.resolved = uploaded[matched];
|
|
1089
|
+
}
|
|
1090
|
+
if (!ctx.pinMode) for (const i of unused) warning(ctx, "image.unused", `已上传图片未在正文中引用:${uploaded[i].originalUrl || uploaded[i].url}。`);
|
|
1091
|
+
}
|
|
1092
|
+
/** 知乎域白名单(与媒体 owner 一致):^([a-z0-9-]+\.)*(zhimg\.com|zhihu\.com)$ */
|
|
1093
|
+
const ZHIHU_DOMAIN_RE = /^([a-z0-9-]+\.)*(zhimg\.com|zhihu\.com)$/i;
|
|
1094
|
+
function isZhihuImageUrl(value) {
|
|
1095
|
+
try {
|
|
1096
|
+
const u = new URL(value);
|
|
1097
|
+
return u.protocol === "https:" && !u.username && !u.password && ZHIHU_DOMAIN_RE.test(u.hostname);
|
|
1098
|
+
} catch {
|
|
1099
|
+
return false;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
function renderBodyImage(ref, ctx) {
|
|
1103
|
+
const img = ref.resolved;
|
|
1104
|
+
if (!img?.url) return "";
|
|
1105
|
+
if (!isZhihuImageUrl(img.url)) {
|
|
1106
|
+
error(ctx, "image.unsafe-url", `图片发布 URL 不是 HTTPS 知乎图片域:${img.url}。`);
|
|
1107
|
+
return "";
|
|
1108
|
+
}
|
|
1109
|
+
const caption = ref.caption || "";
|
|
1110
|
+
if (caption.length > SYNC_LIMITS.captionMax) {
|
|
1111
|
+
error(ctx, "image.caption-too-long", `图片注释超过 ${SYNC_LIMITS.captionMax} 字,请缩短后重试。`);
|
|
1112
|
+
return "";
|
|
1113
|
+
}
|
|
1114
|
+
const attrs = [`src="${escapeAttr(img.url)}"`];
|
|
1115
|
+
attrs.push(`data-caption="${escapeAttr(caption)}"`);
|
|
1116
|
+
const w = img.width ?? 0;
|
|
1117
|
+
attrs.push(`data-size="${w > 0 && w < 343 ? "small" : "normal"}"`);
|
|
1118
|
+
if (img.width !== void 0) attrs.push(`data-rawwidth="${img.width}"`);
|
|
1119
|
+
if (img.height !== void 0) attrs.push(`data-rawheight="${img.height}"`);
|
|
1120
|
+
if (img.watermark) attrs.push("data-watermark=\"watermark\"");
|
|
1121
|
+
if (img.originalUrl && isZhihuImageUrl(img.originalUrl)) attrs.push(`data-original-src="${escapeAttr(img.originalUrl)}"`);
|
|
1122
|
+
else attrs.push("data-original-src=\"\"");
|
|
1123
|
+
attrs.push("data-watermark-src=\"\"", "data-private-watermark-src=\"\"");
|
|
1124
|
+
return `<img ${attrs.join(" ")} />`;
|
|
1125
|
+
}
|
|
1126
|
+
const SAFE_TAGS = /* @__PURE__ */ new Set([
|
|
1127
|
+
"p",
|
|
1128
|
+
"br",
|
|
1129
|
+
"h2",
|
|
1130
|
+
"h3",
|
|
1131
|
+
"b",
|
|
1132
|
+
"i",
|
|
1133
|
+
"ol",
|
|
1134
|
+
"ul",
|
|
1135
|
+
"li",
|
|
1136
|
+
"blockquote",
|
|
1137
|
+
"hr",
|
|
1138
|
+
"pre",
|
|
1139
|
+
"code",
|
|
1140
|
+
"sup",
|
|
1141
|
+
"img",
|
|
1142
|
+
"a",
|
|
1143
|
+
"table",
|
|
1144
|
+
"tbody",
|
|
1145
|
+
"tr",
|
|
1146
|
+
"th",
|
|
1147
|
+
"td"
|
|
1148
|
+
]);
|
|
1149
|
+
const DANGEROUS_TAGS = /* @__PURE__ */ new Set([
|
|
1150
|
+
"script",
|
|
1151
|
+
"iframe",
|
|
1152
|
+
"object",
|
|
1153
|
+
"embed",
|
|
1154
|
+
"svg",
|
|
1155
|
+
"style",
|
|
1156
|
+
"link",
|
|
1157
|
+
"meta",
|
|
1158
|
+
"base",
|
|
1159
|
+
"form",
|
|
1160
|
+
"input",
|
|
1161
|
+
"button",
|
|
1162
|
+
"select",
|
|
1163
|
+
"textarea",
|
|
1164
|
+
"option",
|
|
1165
|
+
"video",
|
|
1166
|
+
"audio",
|
|
1167
|
+
"source",
|
|
1168
|
+
"track",
|
|
1169
|
+
"noscript",
|
|
1170
|
+
"template",
|
|
1171
|
+
"frame",
|
|
1172
|
+
"frameset",
|
|
1173
|
+
"applet"
|
|
1174
|
+
]);
|
|
1175
|
+
const VOID_TAGS = /* @__PURE__ */ new Set([
|
|
1176
|
+
"br",
|
|
1177
|
+
"hr",
|
|
1178
|
+
"img"
|
|
1179
|
+
]);
|
|
1180
|
+
const DANGEROUS_URL = /^\s*(javascript|vbscript|file|data):/i;
|
|
1181
|
+
function attr(el, name) {
|
|
1182
|
+
return el.attrs?.find((a) => a.name.toLowerCase() === name)?.value ?? "";
|
|
1183
|
+
}
|
|
1184
|
+
function sanitizeAnchorAttrs(el, ctx) {
|
|
1185
|
+
const dataAttrs = Object.fromEntries((el.attrs ?? []).map((a) => [a.name, a.value]));
|
|
1186
|
+
const isLinkCard = dataAttrs["data-draft-type"] === "link-card" && dataAttrs["data-draft-node"] === "block";
|
|
1187
|
+
const isTextLink = dataAttrs["data-draft-type"] === "text-link";
|
|
1188
|
+
const isHashTag = (dataAttrs["class"] ?? "").split(/\s+/).includes("hash_tag") || (dataAttrs["class"] ?? "").includes("zed-topic");
|
|
1189
|
+
const href = (dataAttrs["href"] ?? "").trim();
|
|
1190
|
+
if (!href || !isAbsoluteHttpUrl(href) || DANGEROUS_URL.test(href)) {
|
|
1191
|
+
error(ctx, "html.unsafe-link-url", `链接地址不受支持,已拒绝:${href || "(空)"}`);
|
|
1192
|
+
return {
|
|
1193
|
+
ok: false,
|
|
1194
|
+
html: ""
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
let attrs = `href="${escapeAttr(href)}"`;
|
|
1198
|
+
if (isHashTag) {
|
|
1199
|
+
const topicName = dataAttrs["data-topic-name"] ?? "";
|
|
1200
|
+
attrs = `class="hash_tag" href="${escapeAttr(href)}" data-topic-name="${escapeAttr(topicName)}"`;
|
|
1201
|
+
const topicId = dataAttrs["data-topic-id"];
|
|
1202
|
+
if (topicId) attrs += ` data-topic-id="${escapeAttr(topicId)}"`;
|
|
1203
|
+
return {
|
|
1204
|
+
ok: true,
|
|
1205
|
+
html: `<a ${attrs}>{INNER}</a>`
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
if (isLinkCard) {
|
|
1209
|
+
attrs += ` data-draft-node="block" data-draft-type="link-card" data-draft-title="${escapeAttr(dataAttrs["data-draft-title"] ?? "")}" data-draft-cover="${escapeAttr(dataAttrs["data-draft-cover"] ?? "")}"`;
|
|
1210
|
+
if (dataAttrs["data-content-type"]) attrs += ` data-content-type="${escapeAttr(dataAttrs["data-content-type"])}"`;
|
|
1211
|
+
if (dataAttrs["data-content-id"]) attrs += ` data-content-id="${escapeAttr(dataAttrs["data-content-id"])}"`;
|
|
1212
|
+
return {
|
|
1213
|
+
ok: true,
|
|
1214
|
+
html: `<a ${attrs}>{INNER}</a>`
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
if (isTextLink || ctx.pinMode) {
|
|
1218
|
+
attrs += ` data-insert-way="force" data-draft-node="inline" data-draft-type="text-link" data-draft-title="${escapeAttr(dataAttrs["data-draft-title"] ?? "")}"`;
|
|
1219
|
+
return {
|
|
1220
|
+
ok: true,
|
|
1221
|
+
html: `<a ${attrs}>{INNER}</a>`
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
const title = dataAttrs["data-draft-title"];
|
|
1225
|
+
if (title) attrs += ` data-draft-title="${escapeAttr(title)}"`;
|
|
1226
|
+
return {
|
|
1227
|
+
ok: true,
|
|
1228
|
+
html: `<a ${attrs}>{INNER}</a>`
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
function sanitizeImg(el, ctx) {
|
|
1232
|
+
const eeimg = attr(el, "eeimg");
|
|
1233
|
+
const src = (attr(el, "src") ?? "").trim();
|
|
1234
|
+
if (DANGEROUS_URL.test(src)) {
|
|
1235
|
+
error(ctx, "html.unsafe-img-url", `图片地址不受支持,已拒绝:${src || "(空)"}`);
|
|
1236
|
+
return "";
|
|
1237
|
+
}
|
|
1238
|
+
if (eeimg === "1" || eeimg === "2") {
|
|
1239
|
+
if (!/^(https?:)?\/\/www\.zhihu\.com\/equation\?/i.test(src) && src) {
|
|
1240
|
+
warning(ctx, "formula.invalid-src", "公式图片地址异常,已重建。");
|
|
1241
|
+
return latexToEquationImg(attr(el, "alt"), eeimg === "2");
|
|
1242
|
+
}
|
|
1243
|
+
const alt = attr(el, "alt");
|
|
1244
|
+
return `<img eeimg="${eeimg}" src="${escapeAttr(src)}" alt="${escapeAttr(alt)}" />`;
|
|
1245
|
+
}
|
|
1246
|
+
const idx = ctx.bodyImages.length;
|
|
1247
|
+
ctx.bodyImages.push({
|
|
1248
|
+
src,
|
|
1249
|
+
caption: attr(el, "data-caption") || attr(el, "alt") || ""
|
|
1250
|
+
});
|
|
1251
|
+
return ctx.ph.img(idx);
|
|
1252
|
+
}
|
|
1253
|
+
function sanitizeSup(el, ctx) {
|
|
1254
|
+
if (attr(el, "data-draft-type") !== "reference") {
|
|
1255
|
+
warning(ctx, "unsupported.sup", "非注释语义的 <sup> 已降级为文本。");
|
|
1256
|
+
return { wrapper: null };
|
|
1257
|
+
}
|
|
1258
|
+
if (ctx.type !== "article") {
|
|
1259
|
+
error(ctx, "reference.unsupported", "提问与想法不支持注释(<sup data-draft-type=\"reference\">)。");
|
|
1260
|
+
return { wrapper: null };
|
|
1261
|
+
}
|
|
1262
|
+
const text = attr(el, "data-text");
|
|
1263
|
+
const url = attr(el, "data-url");
|
|
1264
|
+
if (!text && !url) {
|
|
1265
|
+
error(ctx, "reference.empty", "注释需要 data-text 或 data-url 至少一项非空。");
|
|
1266
|
+
return { wrapper: null };
|
|
1267
|
+
}
|
|
1268
|
+
if (url && !isAbsoluteHttpUrl(url)) {
|
|
1269
|
+
error(ctx, "reference.bad-url", `注释 data-url 不是合法链接:${url}`);
|
|
1270
|
+
return { wrapper: null };
|
|
1271
|
+
}
|
|
1272
|
+
const numero = attr(el, "data-numero");
|
|
1273
|
+
return { wrapper: [`<sup ${`data-text="${escapeAttr(text)}" data-url="${escapeAttr(url)}" data-draft-node="inline" data-draft-type="reference" data-numero="${escapeAttr(numero)}"`}>`, "</sup>"] };
|
|
1274
|
+
}
|
|
1275
|
+
function sanitizeNode(node, ctx) {
|
|
1276
|
+
if (node.nodeName === "#text") return escapeHtml(node.value);
|
|
1277
|
+
if (node.nodeName === "#comment" || node.nodeName === "#documentType") return "";
|
|
1278
|
+
const el = node;
|
|
1279
|
+
const tag = (el.tagName ?? "").toLowerCase();
|
|
1280
|
+
if (!tag) return "";
|
|
1281
|
+
if (DANGEROUS_TAGS.has(tag)) {
|
|
1282
|
+
error(ctx, "html.unsafe-tag", `危险标签已移除且阻止发布:<${tag}>`);
|
|
1283
|
+
return "";
|
|
1284
|
+
}
|
|
1285
|
+
const children = el.childNodes ?? [];
|
|
1286
|
+
const inner = () => children.map((c) => sanitizeNode(c, ctx)).join("");
|
|
1287
|
+
if (!SAFE_TAGS.has(tag)) {
|
|
1288
|
+
warning(ctx, "html.unsupported-tag", `不支持的标签 <${tag}> 已降级为纯文本。`);
|
|
1289
|
+
return inner();
|
|
1290
|
+
}
|
|
1291
|
+
switch (tag) {
|
|
1292
|
+
case "a": {
|
|
1293
|
+
const r = sanitizeAnchorAttrs(el, ctx);
|
|
1294
|
+
if (!r.ok) return "";
|
|
1295
|
+
return r.html.replace("{INNER}", inner());
|
|
1296
|
+
}
|
|
1297
|
+
case "img": return sanitizeImg(el, ctx);
|
|
1298
|
+
case "sup": {
|
|
1299
|
+
const r = sanitizeSup(el, ctx);
|
|
1300
|
+
if (!r.wrapper) return inner();
|
|
1301
|
+
return r.wrapper[0] + inner() + r.wrapper[1];
|
|
1302
|
+
}
|
|
1303
|
+
case "pre": {
|
|
1304
|
+
const lang = normalizeCodeLang(attr(el, "lang"));
|
|
1305
|
+
const collectText = (nodes) => nodes.map((n) => n.nodeName === "#text" ? n.value : collectText(n.childNodes ?? [])).join("");
|
|
1306
|
+
const text = collectText(children);
|
|
1307
|
+
return `<pre lang="${lang}">${escapeHtml(text)}</pre>`;
|
|
1308
|
+
}
|
|
1309
|
+
case "table": return `<table data-draft-node="block" data-draft-type="table" data-size="normal" data-row-style="normal">${inner()}</table>`;
|
|
1310
|
+
case "thead":
|
|
1311
|
+
case "tfoot":
|
|
1312
|
+
warning(ctx, "table.unsupported-section", `<${tag}> 已并入 tbody。`);
|
|
1313
|
+
return inner();
|
|
1314
|
+
case "hr": return "<hr />";
|
|
1315
|
+
case "br": return "<br>";
|
|
1316
|
+
case "code": {
|
|
1317
|
+
const text = inner();
|
|
1318
|
+
if (ctx.pinMode) {
|
|
1319
|
+
warning(ctx, "pin.unsupported-rich-text", "想法正文不支持行内代码,已降级为文本。");
|
|
1320
|
+
return text;
|
|
1321
|
+
}
|
|
1322
|
+
return `<code>${text}</code>`;
|
|
1323
|
+
}
|
|
1324
|
+
case "b":
|
|
1325
|
+
case "i": {
|
|
1326
|
+
const content = inner();
|
|
1327
|
+
if (ctx.pinMode) {
|
|
1328
|
+
warning(ctx, "pin.unsupported-rich-text", `想法正文不支持 <${tag}>,已降级为文本。`);
|
|
1329
|
+
return content;
|
|
1330
|
+
}
|
|
1331
|
+
return `<${tag}>${content}</${tag}>`;
|
|
1332
|
+
}
|
|
1333
|
+
case "h2":
|
|
1334
|
+
case "h3": {
|
|
1335
|
+
const content = inner();
|
|
1336
|
+
if (ctx.pinMode) {
|
|
1337
|
+
warning(ctx, "pin.unsupported-rich-text", "想法正文不支持标题,已降级为文本。");
|
|
1338
|
+
return `<p>${content}</p>`;
|
|
1339
|
+
}
|
|
1340
|
+
return `<${tag}>${content}</${tag}>`;
|
|
1341
|
+
}
|
|
1342
|
+
case "ol":
|
|
1343
|
+
case "ul":
|
|
1344
|
+
case "li":
|
|
1345
|
+
case "p":
|
|
1346
|
+
case "blockquote":
|
|
1347
|
+
case "tbody":
|
|
1348
|
+
case "tr":
|
|
1349
|
+
case "th":
|
|
1350
|
+
case "td":
|
|
1351
|
+
if (ctx.pinMode && tag !== "p") {
|
|
1352
|
+
warning(ctx, "pin.unsupported-rich-text", `想法正文不支持 <${tag}>,已降级为文本。`);
|
|
1353
|
+
return inner();
|
|
1354
|
+
}
|
|
1355
|
+
return `<${tag}>${inner()}</${tag}>`;
|
|
1356
|
+
default: return inner();
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
function sanitizeInputHtml(body, ctx) {
|
|
1360
|
+
return parseFragment(body).childNodes.map((c) => sanitizeNode(c, ctx)).join("");
|
|
1361
|
+
}
|
|
1362
|
+
/** 行内原始 HTML 消毒:与 format='html' 走同一 parse5 路径,
|
|
1363
|
+
* 但只返回内联内容(不包 <p>),供 markdown 行内 html token 使用。 */
|
|
1364
|
+
function sanitizeInlineHtml(raw, ctx) {
|
|
1365
|
+
return parseFragment(raw).childNodes.map((c) => sanitizeNode(c, ctx)).join("");
|
|
1366
|
+
}
|
|
1367
|
+
/** 用 parse5 把 HTML 实体解码为可见字符(< → <)。 */
|
|
1368
|
+
function decodeEntities(text) {
|
|
1369
|
+
const frag = parseFragment(text);
|
|
1370
|
+
let out = "";
|
|
1371
|
+
const walk = (nodes) => {
|
|
1372
|
+
for (const n of nodes) if (n.nodeName === "#text") out += n.value;
|
|
1373
|
+
else walk(n.childNodes ?? []);
|
|
1374
|
+
};
|
|
1375
|
+
walk(frag.childNodes);
|
|
1376
|
+
return out;
|
|
1377
|
+
}
|
|
1378
|
+
function normalizeDraft(input, issues) {
|
|
1379
|
+
if (!isRecord(input)) {
|
|
1380
|
+
issues.push({
|
|
1381
|
+
severity: "error",
|
|
1382
|
+
code: "input.invalid",
|
|
1383
|
+
message: "草稿输入必须是对象。"
|
|
1384
|
+
});
|
|
1385
|
+
return {
|
|
1386
|
+
type: "article",
|
|
1387
|
+
title: "",
|
|
1388
|
+
body: "",
|
|
1389
|
+
format: "markdown"
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
const type = [
|
|
1393
|
+
"article",
|
|
1394
|
+
"question",
|
|
1395
|
+
"pin"
|
|
1396
|
+
].includes(String(input.type)) ? input.type : "article";
|
|
1397
|
+
if (input.type !== void 0 && input.type !== type) issues.push({
|
|
1398
|
+
severity: "error",
|
|
1399
|
+
code: "input.bad-type",
|
|
1400
|
+
message: `不支持的内容形态:${String(input.type)}。`
|
|
1401
|
+
});
|
|
1402
|
+
const format = input.format === "html" ? "html" : "markdown";
|
|
1403
|
+
const draft = {
|
|
1404
|
+
type,
|
|
1405
|
+
title: typeof input.title === "string" ? input.title : "",
|
|
1406
|
+
body: typeof input.body === "string" ? input.body : "",
|
|
1407
|
+
format
|
|
1408
|
+
};
|
|
1409
|
+
if (input.images !== void 0) {
|
|
1410
|
+
if (!Array.isArray(input.images)) issues.push({
|
|
1411
|
+
severity: "error",
|
|
1412
|
+
code: "input.bad-images",
|
|
1413
|
+
message: "images 必须是数组。"
|
|
1414
|
+
});
|
|
1415
|
+
else draft.images = input.images.filter((i) => isRecord(i)).map((i) => ({
|
|
1416
|
+
url: typeof i.url === "string" ? i.url : "",
|
|
1417
|
+
originalUrl: typeof i.originalUrl === "string" ? i.originalUrl : void 0,
|
|
1418
|
+
width: typeof i.width === "number" && Number.isFinite(i.width) ? i.width : void 0,
|
|
1419
|
+
height: typeof i.height === "number" && Number.isFinite(i.height) ? i.height : void 0,
|
|
1420
|
+
watermark: i.watermark === true,
|
|
1421
|
+
mediaId: typeof i.mediaId === "string" ? i.mediaId : void 0
|
|
1422
|
+
})).filter((i) => i.url || i.originalUrl);
|
|
1423
|
+
}
|
|
1424
|
+
if (input.linkCard !== void 0 && input.linkCard !== null) {
|
|
1425
|
+
if (isRecord(input.linkCard) && typeof input.linkCard.url === "string" && input.linkCard.url.trim()) {
|
|
1426
|
+
const lc = input.linkCard;
|
|
1427
|
+
draft.linkCard = {
|
|
1428
|
+
url: lc.url.trim(),
|
|
1429
|
+
title: typeof lc.title === "string" ? lc.title : void 0,
|
|
1430
|
+
cover: typeof lc.cover === "string" ? lc.cover : void 0,
|
|
1431
|
+
contentType: typeof lc.contentType === "string" ? lc.contentType : void 0,
|
|
1432
|
+
contentId: typeof lc.contentId === "string" ? lc.contentId : void 0,
|
|
1433
|
+
isFromForward: lc.isFromForward === true ? true : void 0
|
|
1434
|
+
};
|
|
1435
|
+
} else issues.push({
|
|
1436
|
+
severity: "warning",
|
|
1437
|
+
code: "input.bad-linkcard",
|
|
1438
|
+
message: "linkCard 缺少有效 url,已忽略。"
|
|
1439
|
+
});
|
|
1440
|
+
if (draft.linkCard) {
|
|
1441
|
+
if (!isAbsoluteHttpUrl(draft.linkCard.url)) issues.push({
|
|
1442
|
+
severity: "error",
|
|
1443
|
+
code: "linkcard.url-unsafe",
|
|
1444
|
+
message: `linkCard.url 必须是 http(s) 链接:${draft.linkCard.url}`
|
|
1445
|
+
});
|
|
1446
|
+
if (draft.linkCard.cover && !isZhihuImageUrl(draft.linkCard.cover)) issues.push({
|
|
1447
|
+
severity: "error",
|
|
1448
|
+
code: "linkcard.cover-unsafe",
|
|
1449
|
+
message: `linkCard.cover 必须是 HTTPS 知乎图片域链接:${draft.linkCard.cover}`
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
if (input.settings !== void 0 && input.settings !== null) {
|
|
1454
|
+
if (!isRecord(input.settings)) issues.push({
|
|
1455
|
+
severity: "warning",
|
|
1456
|
+
code: "input.bad-settings",
|
|
1457
|
+
message: "settings 必须是对象,已忽略。"
|
|
1458
|
+
});
|
|
1459
|
+
else {
|
|
1460
|
+
const s = input.settings;
|
|
1461
|
+
const settings = {};
|
|
1462
|
+
if (s.commentPermission !== void 0) settings.commentPermission = String(s.commentPermission);
|
|
1463
|
+
if (s.tableOfContentsEnabled !== void 0) settings.tableOfContentsEnabled = s.tableOfContentsEnabled === true;
|
|
1464
|
+
if (s.creationStatement !== void 0) settings.creationStatement = String(s.creationStatement);
|
|
1465
|
+
if (Array.isArray(s.topics)) settings.topics = s.topics.filter((t) => typeof t === "string" && !!t.trim()).map((t) => t.trim());
|
|
1466
|
+
if (typeof s.ring === "string" && s.ring.trim()) settings.ring = s.ring.trim();
|
|
1467
|
+
if (Object.keys(settings).length) draft.settings = settings;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
return draft;
|
|
1471
|
+
}
|
|
1472
|
+
function prepareZhihuSyncDraft(input) {
|
|
1473
|
+
const issues = [];
|
|
1474
|
+
const draft = normalizeDraft(input, issues);
|
|
1475
|
+
const ph = makePlaceholders();
|
|
1476
|
+
const ctx = {
|
|
1477
|
+
issues,
|
|
1478
|
+
type: draft.type,
|
|
1479
|
+
pinMode: draft.type === "pin",
|
|
1480
|
+
bodyImages: [],
|
|
1481
|
+
formulas: [],
|
|
1482
|
+
ph
|
|
1483
|
+
};
|
|
1484
|
+
let title = draft.title;
|
|
1485
|
+
if (draft.type === "question") {
|
|
1486
|
+
title = title.trimEnd();
|
|
1487
|
+
if (title && !/[??]\s*$/.test(title)) title += "?";
|
|
1488
|
+
draft.title = title;
|
|
1489
|
+
}
|
|
1490
|
+
const titleChars = [...draft.title].length;
|
|
1491
|
+
if (/\n/.test(draft.title)) error(ctx, "title.multiline", "标题禁止换行。");
|
|
1492
|
+
if (draft.type === "question") {
|
|
1493
|
+
if (titleChars === 0) error(ctx, "title.required", "提问标题必填。");
|
|
1494
|
+
if (titleChars > 0 && titleChars < SYNC_LIMITS.questionTitleMin) error(ctx, "title.too-short", `提问标题 4~51 字(含结尾问号),当前 ${titleChars} 字。`);
|
|
1495
|
+
if (titleChars > SYNC_LIMITS.questionTitleMax) error(ctx, "title.too-long", `提问标题 4~51 字(含结尾问号),当前 ${titleChars} 字。`);
|
|
1496
|
+
if ((draft.title.match(/[??]/g) ?? []).length > 1) warning(ctx, "title.multi-question", "标题含多个问号,请确认只表达一个明确问题。");
|
|
1497
|
+
} else if (draft.type === "pin") {
|
|
1498
|
+
if (titleChars > SYNC_LIMITS.pinTitleMax) error(ctx, "title.too-long", `想法标题最多 ${SYNC_LIMITS.pinTitleMax} 字,当前 ${titleChars} 字。`);
|
|
1499
|
+
} else {
|
|
1500
|
+
if (titleChars === 0) error(ctx, "title.required", "文章标题必填。");
|
|
1501
|
+
if (titleChars > SYNC_LIMITS.articleTitleMax) error(ctx, "title.too-long", `文章标题最多 ${SYNC_LIMITS.articleTitleMax} 字,当前 ${titleChars} 字。`);
|
|
1502
|
+
}
|
|
1503
|
+
let html = "";
|
|
1504
|
+
if (draft.format === "markdown") html = blockToHtml(lexer(draft.body), ctx);
|
|
1505
|
+
else html = sanitizeInputHtml(draft.body, ctx);
|
|
1506
|
+
const formulaPrefix = ph.formula(0).replace(/0$/, "");
|
|
1507
|
+
const formulaRe = new RegExp(`${formulaPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\d+)`, "g");
|
|
1508
|
+
html = html.replace(formulaRe, (_, idx) => {
|
|
1509
|
+
const f = ctx.formulas[Number(idx)];
|
|
1510
|
+
return f ? latexToEquationImg(f.latex, f.block) : "";
|
|
1511
|
+
});
|
|
1512
|
+
let pinTopicCount = 0;
|
|
1513
|
+
if (draft.type === "pin") {
|
|
1514
|
+
const injected = injectPinHashTags(html);
|
|
1515
|
+
html = injected.html;
|
|
1516
|
+
pinTopicCount = injected.count;
|
|
1517
|
+
if (pinTopicCount > SYNC_LIMITS.pinTopicsMax) error(ctx, "topics.too-many", `想法话题最多 ${SYNC_LIMITS.pinTopicsMax} 个,当前 ${pinTopicCount} 个。`);
|
|
1518
|
+
}
|
|
1519
|
+
resolveBodyImages(ctx, draft);
|
|
1520
|
+
const imgPrefix = ph.img(0).replace(/0$/, "");
|
|
1521
|
+
const imgRe = new RegExp(`${imgPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\d+)`, "g");
|
|
1522
|
+
html = html.replace(imgRe, (_, idx) => {
|
|
1523
|
+
if (ctx.pinMode) return "";
|
|
1524
|
+
return renderBodyImage(ctx.bodyImages[Number(idx)], ctx);
|
|
1525
|
+
});
|
|
1526
|
+
if (draft.type === "pin" && ctx.bodyImages.length) {
|
|
1527
|
+
const stray = ctx.bodyImages.filter((r) => r.resolved?.url);
|
|
1528
|
+
if (stray.length) {
|
|
1529
|
+
warning(ctx, "pin.body-image-moved", "想法正文不支持内嵌图片,已将正文图片移入独立媒体区。");
|
|
1530
|
+
draft.images = [...(draft.images ?? []).filter((i) => !stray.some((r) => r.resolved === i)), ...stray.map((r) => r.resolved)];
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
const bodyChars = plainTextLength(html);
|
|
1534
|
+
if (draft.type === "article") {
|
|
1535
|
+
if (bodyChars < SYNC_LIMITS.articleBodyMin) error(ctx, "body.too-short", `文章正文至少 ${SYNC_LIMITS.articleBodyMin} 字(纯文本),当前 ${bodyChars} 字。`);
|
|
1536
|
+
if (bodyChars > SYNC_LIMITS.articleBodyMax) error(ctx, "body.too-long", `文章正文最多 ${SYNC_LIMITS.articleBodyMax} 字(纯文本),当前 ${bodyChars} 字。`);
|
|
1537
|
+
} else if (draft.type === "pin") {
|
|
1538
|
+
if (bodyChars > SYNC_LIMITS.pinBodyMax) error(ctx, "body.too-long", `想法正文最多 ${SYNC_LIMITS.pinBodyMax} 字(纯文本),当前 ${bodyChars} 字。`);
|
|
1539
|
+
if (!(bodyChars > 0 || (draft.images?.length ?? 0) > 0 || !!draft.linkCard)) error(ctx, "body.required", "想法正文、图片或链接卡片至少需要一项有效内容。");
|
|
1540
|
+
else if (bodyChars === 0) error(ctx, "body.required", "想法正文不能为空,请补充正文内容。");
|
|
1541
|
+
}
|
|
1542
|
+
const pinImages = draft.type === "pin" ? (draft.images ?? []).filter((i) => i.url) : [];
|
|
1543
|
+
if (draft.type === "pin") {
|
|
1544
|
+
for (const img of pinImages) if (!isZhihuImageUrl(img.url)) error(ctx, "image.unsafe-url", `独立图片发布 URL 不是 HTTPS 知乎图片域:${img.url}。`);
|
|
1545
|
+
if (pinImages.length > SYNC_LIMITS.pinImagesMax) error(ctx, "images.too-many", `想法独立图片最多 ${SYNC_LIMITS.pinImagesMax} 张,当前 ${pinImages.length} 张。`);
|
|
1546
|
+
if (pinImages.length && draft.linkCard) error(ctx, "pin.media-linkcard-exclusive", "单条想法不支持同时包含独立图片与链接卡片。");
|
|
1547
|
+
}
|
|
1548
|
+
validateSettings(draft, issues, pinImages.length);
|
|
1549
|
+
return {
|
|
1550
|
+
draft,
|
|
1551
|
+
html,
|
|
1552
|
+
issues,
|
|
1553
|
+
canPublish: !issues.some((i) => i.severity === "error"),
|
|
1554
|
+
counts: {
|
|
1555
|
+
titleChars,
|
|
1556
|
+
bodyChars,
|
|
1557
|
+
images: draft.type === "pin" ? pinImages.length : ctx.bodyImages.length,
|
|
1558
|
+
topics: draft.type === "pin" ? pinTopicCount : draft.settings?.topics?.length ?? 0
|
|
1559
|
+
}
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
function validateSettings(draft, issues, pinImageCount) {
|
|
1563
|
+
const ctx = {
|
|
1564
|
+
issues,
|
|
1565
|
+
type: draft.type,
|
|
1566
|
+
pinMode: draft.type === "pin",
|
|
1567
|
+
bodyImages: [],
|
|
1568
|
+
formulas: [],
|
|
1569
|
+
ph: {
|
|
1570
|
+
img: () => "",
|
|
1571
|
+
formula: () => ""
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
1574
|
+
const s = draft.settings ?? {};
|
|
1575
|
+
if (draft.type === "question") {
|
|
1576
|
+
for (const [key, label] of [
|
|
1577
|
+
["commentPermission", "评论权限"],
|
|
1578
|
+
["creationStatement", "创作声明"],
|
|
1579
|
+
["tableOfContentsEnabled", "目录"],
|
|
1580
|
+
["ring", "圈子"]
|
|
1581
|
+
]) if (s[key] !== void 0) warning(ctx, `settings.unsupported-${key}`, `提问不支持${label}配置,发布时将忽略。`);
|
|
1582
|
+
}
|
|
1583
|
+
if (draft.type === "article") {
|
|
1584
|
+
if (s.ring !== void 0) warning(ctx, "settings.unsupported-ring", "文章不支持圈子配置,发布时将忽略。");
|
|
1585
|
+
}
|
|
1586
|
+
if (s.topics?.length) {
|
|
1587
|
+
if (draft.type === "pin") warning(ctx, "settings.unsupported-topics", "想法话题属于正文(#话题名#),不使用 settings.topics,发布时将忽略。");
|
|
1588
|
+
else {
|
|
1589
|
+
const max = draft.type === "article" ? SYNC_LIMITS.articleTopicsMax : SYNC_LIMITS.questionTopicsMax;
|
|
1590
|
+
if (s.topics.filter((t) => strictZhihuUrl(t, /^\/topic\/(\d+)(?:\/|$)/)).length !== s.topics.length) error(ctx, "topics.invalid", "话题必须是知乎话题链接(https://www.zhihu.com/topic/{token}/...)。");
|
|
1591
|
+
if (s.topics.length > max) error(ctx, "topics.too-many", `${draft.type === "article" ? "文章" : "提问"}话题最多 ${max} 个,当前 ${s.topics.length} 个。`);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
if (s.commentPermission !== void 0 && draft.type !== "question") {
|
|
1595
|
+
if (!(draft.type === "pin" ? SYNC_PIN_COMMENT_PERMISSIONS : SYNC_ARTICLE_COMMENT_PERMISSIONS).includes(s.commentPermission)) error(ctx, "comment-permission.invalid", `当前形态不支持的评论权限:${String(s.commentPermission)}。`);
|
|
1596
|
+
}
|
|
1597
|
+
if (s.creationStatement !== void 0) {
|
|
1598
|
+
if (draft.type === "pin") {
|
|
1599
|
+
if (s.creationStatement !== "ai_creation") error(ctx, "creation-statement.invalid", `想法仅支持 ai_creation 创作声明,当前:${String(s.creationStatement)}。`);
|
|
1600
|
+
} else if (draft.type === "article") {
|
|
1601
|
+
if (!SYNC_ARTICLE_CREATION_STATEMENTS.includes(String(s.creationStatement))) error(ctx, "creation-statement.invalid", `不支持的创作声明:${String(s.creationStatement)}。`);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
if (s.ring !== void 0 && draft.type === "pin") {
|
|
1605
|
+
if (!strictZhihuUrl(s.ring, /^\/ring\/host\/(\d+)(?:\/|$)/)) error(ctx, "ring.invalid", "圈子必须是知乎圈子链接(https://www.zhihu.com/ring/host/{id})。");
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
function buildZhihuPublishRequest(prepared) {
|
|
1609
|
+
if (!isRecord(prepared) || prepared.canPublish !== true || !isRecord(prepared.draft)) throw new Error("buildZhihuPublishRequest: 需要 prepareZhihuSyncDraft 返回且 canPublish 为 true 的结果。");
|
|
1610
|
+
const draft = prepared.draft;
|
|
1611
|
+
const html = typeof prepared.html === "string" ? prepared.html : "";
|
|
1612
|
+
const s = draft.settings ?? {};
|
|
1613
|
+
const base = {
|
|
1614
|
+
type: draft.type,
|
|
1615
|
+
confirmed: true,
|
|
1616
|
+
confirm_note: "confirmed by user after local preview"
|
|
1617
|
+
};
|
|
1618
|
+
if (draft.type === "article") {
|
|
1619
|
+
const content = {
|
|
1620
|
+
title: draft.title,
|
|
1621
|
+
html,
|
|
1622
|
+
comment_permission: s.commentPermission ?? "all",
|
|
1623
|
+
table_of_contents_enabled: s.tableOfContentsEnabled ?? false
|
|
1624
|
+
};
|
|
1625
|
+
if (s.creationStatement) content.creation_statement = s.creationStatement;
|
|
1626
|
+
const topics = extractTopicTokens(s.topics ?? [], SYNC_LIMITS.articleTopicsMax);
|
|
1627
|
+
if (topics.length) content.topics = topics;
|
|
1628
|
+
return {
|
|
1629
|
+
...base,
|
|
1630
|
+
content
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
if (draft.type === "question") {
|
|
1634
|
+
const content = {
|
|
1635
|
+
title: draft.title,
|
|
1636
|
+
html
|
|
1637
|
+
};
|
|
1638
|
+
const topics = extractTopicTokens(s.topics ?? [], SYNC_LIMITS.questionTopicsMax);
|
|
1639
|
+
if (topics.length) content.topics = topics;
|
|
1640
|
+
return {
|
|
1641
|
+
...base,
|
|
1642
|
+
content
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
const content = {
|
|
1646
|
+
title: draft.title,
|
|
1647
|
+
html,
|
|
1648
|
+
comment_permission: s.commentPermission ?? "all"
|
|
1649
|
+
};
|
|
1650
|
+
const images = (draft.images ?? []).filter((i) => typeof i.url === "string" && i.url);
|
|
1651
|
+
if (images.length) content.images = images.map((i) => ({ url: i.url }));
|
|
1652
|
+
else if (draft.linkCard) {
|
|
1653
|
+
const lc = draft.linkCard;
|
|
1654
|
+
content.linkcard = {
|
|
1655
|
+
data_content_type: lc.contentType ?? "common_url",
|
|
1656
|
+
data_content_id: lc.contentId ?? "0",
|
|
1657
|
+
url: lc.url,
|
|
1658
|
+
data_draft_title: lc.title ?? "",
|
|
1659
|
+
data_draft_cover: lc.cover ?? "",
|
|
1660
|
+
is_from_forward: lc.isFromForward ?? false
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
if (s.creationStatement === "ai_creation") content.creation_statement = "ai_creation";
|
|
1664
|
+
const ring = extractRingId(s.ring);
|
|
1665
|
+
if (ring) content.ring = ring;
|
|
1666
|
+
return {
|
|
1667
|
+
...base,
|
|
1668
|
+
content
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
function strictZhihuUrl(raw, pathRe) {
|
|
1672
|
+
try {
|
|
1673
|
+
const u = new URL(raw);
|
|
1674
|
+
if (u.protocol !== "https:" || u.username || u.password || u.port) return "";
|
|
1675
|
+
if (u.hostname !== "www.zhihu.com") return "";
|
|
1676
|
+
return u.pathname.match(pathRe)?.[1] ?? "";
|
|
1677
|
+
} catch {
|
|
1678
|
+
return "";
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
function extractTopicTokens(urls, max) {
|
|
1682
|
+
const out = [];
|
|
1683
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1684
|
+
for (const u of urls) {
|
|
1685
|
+
const token = strictZhihuUrl(String(u), /^\/topic\/(\d+)(?:\/|$)/);
|
|
1686
|
+
if (!token || seen.has(token)) continue;
|
|
1687
|
+
seen.add(token);
|
|
1688
|
+
out.push({
|
|
1689
|
+
topic_id: "",
|
|
1690
|
+
topic_token: token,
|
|
1691
|
+
topic_name: ""
|
|
1692
|
+
});
|
|
1693
|
+
if (out.length >= max) break;
|
|
1694
|
+
}
|
|
1695
|
+
return out;
|
|
1696
|
+
}
|
|
1697
|
+
function extractRingId(url) {
|
|
1698
|
+
if (!url) return void 0;
|
|
1699
|
+
const id = strictZhihuUrl(url, /^\/ring\/host\/(\d+)(?:\/|$)/);
|
|
1700
|
+
return id ? {
|
|
1701
|
+
ring_id: id,
|
|
1702
|
+
ring_name: ""
|
|
1703
|
+
} : void 0;
|
|
1704
|
+
}
|
|
1705
|
+
const ZHIHU_SYNC_ALLOWED_ORIGIN = "https://openapi.zhihu.com";
|
|
1706
|
+
/**
|
|
1707
|
+
* 发布/媒体调用错误。definite === true 表示已确定「这次请求没有成功」
|
|
1708
|
+
* (上游业务拒绝 / 鉴权拒绝 / 限流 / 参数错误 / URL 非法),可安全重试;
|
|
1709
|
+
* definite === false 表示结果不确定(超时 / 网络错 / 5xx / 异常响应),
|
|
1710
|
+
* service 必须保留 publishing 意图并提示用户核对,避免重复发送。
|
|
1711
|
+
*/
|
|
1712
|
+
var ZhihuSyncApiError = class extends Error {
|
|
1713
|
+
code;
|
|
1714
|
+
definite;
|
|
1715
|
+
httpStatus;
|
|
1716
|
+
upstreamStatus;
|
|
1717
|
+
upstreamMsg;
|
|
1718
|
+
constructor(code, message, definite, httpStatus, upstreamStatus, upstreamMsg) {
|
|
1719
|
+
super(message);
|
|
1720
|
+
this.code = code;
|
|
1721
|
+
this.definite = definite;
|
|
1722
|
+
this.httpStatus = httpStatus;
|
|
1723
|
+
this.upstreamStatus = upstreamStatus;
|
|
1724
|
+
this.upstreamMsg = upstreamMsg;
|
|
1725
|
+
this.name = "ZhihuSyncApiError";
|
|
1726
|
+
}
|
|
1727
|
+
};
|
|
1728
|
+
const SIGNED_HEADER_NAMES = /* @__PURE__ */ new Set([
|
|
1729
|
+
"content-type",
|
|
1730
|
+
"x-app-key",
|
|
1731
|
+
"x-timestamp",
|
|
1732
|
+
"x-log-id",
|
|
1733
|
+
"x-extra-info",
|
|
1734
|
+
"x-sign"
|
|
1735
|
+
]);
|
|
1736
|
+
/**
|
|
1737
|
+
* 上游错误消息脱敏。secrets 可能以任意长度/形态出现在上游 msg、HTTP body 或
|
|
1738
|
+
* 网络错误 message 中——只做长 base64/hex 替换不足以保护短 secret 或 appKey,
|
|
1739
|
+
* 因此对「当前凭据」与「签名值」做精确字符串替换,其余仍按长度折叠。
|
|
1740
|
+
*/
|
|
1741
|
+
function sanitizeMessage(value, secrets = []) {
|
|
1742
|
+
if (typeof value !== "string" || !value) return "";
|
|
1743
|
+
let out = value;
|
|
1744
|
+
for (const s of secrets) if (s) out = out.split(s).join("<redacted>");
|
|
1745
|
+
return out.replace(/[A-Za-z0-9+/=]{24,}/g, "<redacted>").replace(/[0-9a-f]{32,}/gi, "<redacted>").slice(0, 400);
|
|
1746
|
+
}
|
|
1747
|
+
function resolveBase(options) {
|
|
1748
|
+
const raw = options.baseUrl ?? "https://openapi.zhihu.com";
|
|
1749
|
+
let base;
|
|
1750
|
+
try {
|
|
1751
|
+
base = new URL(raw);
|
|
1752
|
+
} catch {
|
|
1753
|
+
throw new ZhihuSyncApiError("INVALID_URL", `无效的 baseUrl:${sanitizeMessage(raw)}`, true);
|
|
1754
|
+
}
|
|
1755
|
+
if (base.protocol !== "https:" || base.origin !== "https://openapi.zhihu.com") throw new ZhihuSyncApiError("INVALID_URL", `OpenAPI baseUrl 必须等于 ${ZHIHU_SYNC_ALLOWED_ORIGIN}。`, true);
|
|
1756
|
+
return base;
|
|
1757
|
+
}
|
|
1758
|
+
function syncUrl(pathOrUrl, base) {
|
|
1759
|
+
let url;
|
|
1760
|
+
if (/^https?:\/\//i.test(pathOrUrl)) try {
|
|
1761
|
+
url = new URL(pathOrUrl);
|
|
1762
|
+
} catch {
|
|
1763
|
+
throw new ZhihuSyncApiError("INVALID_URL", `无效的请求地址:${sanitizeMessage(pathOrUrl)}`, true);
|
|
1764
|
+
}
|
|
1765
|
+
else try {
|
|
1766
|
+
url = new URL(pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`, base.origin + "/");
|
|
1767
|
+
} catch {
|
|
1768
|
+
throw new ZhihuSyncApiError("INVALID_URL", `无效的请求路径:${sanitizeMessage(pathOrUrl)}`, true);
|
|
1769
|
+
}
|
|
1770
|
+
if (url.protocol !== "https:" || url.username || url.password) throw new ZhihuSyncApiError("INVALID_URL", "请求地址必须为无账号密码的 HTTPS。", true);
|
|
1771
|
+
if (url.origin !== base.origin) throw new ZhihuSyncApiError("INVALID_URL", `拒绝向非 OpenAPI 源发起请求:${url.origin}(允许 ${base.origin})。`, true);
|
|
1772
|
+
return url.toString();
|
|
1773
|
+
}
|
|
1774
|
+
function sign(appKey, timestamp, logId, extraInfo, secret) {
|
|
1775
|
+
const signString = `app_key:${appKey}|ts:${timestamp}|logid:${logId}|extra_info:${extraInfo}`;
|
|
1776
|
+
return createHmac("sha256", secret).update(signString, "utf8").digest("base64");
|
|
1777
|
+
}
|
|
1778
|
+
function buildHeaders(appKey, timestamp, logId, extraInfo, appSecret, extraHeaders) {
|
|
1779
|
+
const signed = {
|
|
1780
|
+
"Content-Type": "application/json",
|
|
1781
|
+
"X-App-Key": appKey,
|
|
1782
|
+
"X-Timestamp": timestamp,
|
|
1783
|
+
"X-Log-Id": logId,
|
|
1784
|
+
"X-Extra-Info": extraInfo,
|
|
1785
|
+
"X-Sign": sign(appKey, timestamp, logId, extraInfo, appSecret)
|
|
1786
|
+
};
|
|
1787
|
+
for (const [name, value] of Object.entries(extraHeaders ?? {})) {
|
|
1788
|
+
const lower = name.toLowerCase();
|
|
1789
|
+
if (SIGNED_HEADER_NAMES.has(lower)) throw new ZhihuSyncApiError("INVALID_URL", `init.headers 不允许覆盖签名或内容类型头:${name}。`, true);
|
|
1790
|
+
signed[name] = value;
|
|
1791
|
+
}
|
|
1792
|
+
return signed;
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* 发起一次签名 OpenAPI 请求并解包响应。
|
|
1796
|
+
*
|
|
1797
|
+
* 返回规则:
|
|
1798
|
+
* - body 是对象且带 status 字段(发布信封):status===0 → 返回 body.data;
|
|
1799
|
+
* 其余 → UPSTREAM_REJECTED(definite,消息含 status / 已脱敏 msg)。
|
|
1800
|
+
* - body 不带 status(媒体 Apply/Commit 扁平响应):原样返回 body。
|
|
1801
|
+
* - 非 2xx:401→AUTH_REJECTED、429→RATE_LIMITED、5xx→HTTP_ERROR(不确定)、
|
|
1802
|
+
* 其他 4xx→HTTP_ERROR(definite);若 body 是可解析信封仍按信封处理。
|
|
1803
|
+
*/
|
|
1804
|
+
async function syncRequest(pathOrUrl, init, options, signal) {
|
|
1805
|
+
const url = syncUrl(pathOrUrl, resolveBase(options));
|
|
1806
|
+
const credentials = await options.resolveCredentials();
|
|
1807
|
+
const appKey = credentials.appKey?.trim();
|
|
1808
|
+
const appSecret = credentials.appSecret?.trim();
|
|
1809
|
+
if (!appKey || !appSecret) throw new ZhihuSyncApiError("TOKEN_MISSING", "缺少知乎 OpenAPI 凭证(app key / app secret)。", true);
|
|
1810
|
+
const headers = buildHeaders(appKey, String(Math.floor(Date.now() / 1e3)), randomUUID().replace(/-/g, ""), init.extraInfo ?? "", appSecret, init.headers);
|
|
1811
|
+
const fetcher = options.fetcher ?? globalThis.fetch;
|
|
1812
|
+
if (!fetcher) throw new ZhihuSyncApiError("NETWORK_ERROR", "当前环境没有可用的 fetch。", false);
|
|
1813
|
+
const secrets = [
|
|
1814
|
+
appKey,
|
|
1815
|
+
appSecret,
|
|
1816
|
+
headers["X-Sign"]
|
|
1817
|
+
].filter(Boolean);
|
|
1818
|
+
const timeout = options.timeoutMs ?? 3e4;
|
|
1819
|
+
const timeoutController = new AbortController();
|
|
1820
|
+
const timer = setTimeout(() => timeoutController.abort(), timeout);
|
|
1821
|
+
const combined = signal ? AbortSignal.any([signal, timeoutController.signal]) : timeoutController.signal;
|
|
1822
|
+
let response;
|
|
1823
|
+
let text;
|
|
1824
|
+
let body;
|
|
1825
|
+
try {
|
|
1826
|
+
if (signal?.aborted) throw new ZhihuSyncApiError("CANCELLED", "请求已取消。", true);
|
|
1827
|
+
combined.throwIfAborted();
|
|
1828
|
+
response = await fetcher(url, {
|
|
1829
|
+
method: init.method,
|
|
1830
|
+
headers,
|
|
1831
|
+
body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
|
|
1832
|
+
redirect: "error",
|
|
1833
|
+
signal: combined
|
|
1834
|
+
});
|
|
1835
|
+
let abortReject;
|
|
1836
|
+
const abortPromise = new Promise((_, reject) => {
|
|
1837
|
+
abortReject = () => reject(new DOMException("aborted", "AbortError"));
|
|
1838
|
+
combined.addEventListener("abort", abortReject, { once: true });
|
|
1839
|
+
});
|
|
1840
|
+
abortPromise.catch(() => {});
|
|
1841
|
+
try {
|
|
1842
|
+
text = await Promise.race([response.text(), abortPromise]);
|
|
1843
|
+
} finally {
|
|
1844
|
+
combined.removeEventListener("abort", abortReject);
|
|
1845
|
+
}
|
|
1846
|
+
if (text) body = JSON.parse(text);
|
|
1847
|
+
} catch (error) {
|
|
1848
|
+
if (error instanceof ZhihuSyncApiError) throw error;
|
|
1849
|
+
if (signal?.aborted) throw new ZhihuSyncApiError("CANCELLED", "请求已取消。", false);
|
|
1850
|
+
if (timeoutController.signal.aborted) throw new ZhihuSyncApiError("TIMEOUT", `请求超时(${Math.round(timeout / 1e3)}s)。`, false);
|
|
1851
|
+
if (error instanceof SyntaxError) throw new ZhihuSyncApiError("BAD_RESPONSE", `响应不是合法 JSON(HTTP ${response.status})。`, false, response.status);
|
|
1852
|
+
throw new ZhihuSyncApiError("NETWORK_ERROR", "网络请求失败,请检查连接后重试。", false);
|
|
1853
|
+
} finally {
|
|
1854
|
+
clearTimeout(timer);
|
|
1855
|
+
}
|
|
1856
|
+
const envelope = body && typeof body === "object" && !Array.isArray(body) ? body : void 0;
|
|
1857
|
+
const hasStatus = envelope !== void 0 && "status" in envelope;
|
|
1858
|
+
if (!response.ok) {
|
|
1859
|
+
if (hasStatus && typeof envelope.status === "number" && envelope.status !== 0) throw new ZhihuSyncApiError("UPSTREAM_REJECTED", `发布被服务端拒绝(status=${envelope.status}):${sanitizeMessage(envelope.msg, secrets)}`, true, response.status, envelope.status, sanitizeMessage(envelope.msg, secrets));
|
|
1860
|
+
if (hasStatus && typeof envelope.status !== "number") throw new ZhihuSyncApiError("BAD_RESPONSE", `发布响应 status 不是数字(收到 ${typeof envelope.status})。`, false, response.status);
|
|
1861
|
+
if (response.status === 401) throw new ZhihuSyncApiError("AUTH_REJECTED", "鉴权失败(HTTP 401)。请检查 app key / app secret。", true, 401);
|
|
1862
|
+
if (response.status === 429) throw new ZhihuSyncApiError("RATE_LIMITED", "发布频率超限(每人每天最多 50 次)。", true, 429);
|
|
1863
|
+
throw new ZhihuSyncApiError("HTTP_ERROR", `HTTP ${response.status}:${sanitizeMessage(envelope?.msg, secrets) || "请求失败"}`, response.status >= 400 && response.status < 500, response.status, void 0, sanitizeMessage(envelope?.msg, secrets));
|
|
1864
|
+
}
|
|
1865
|
+
if (hasStatus) {
|
|
1866
|
+
if (typeof envelope.status === "number" && envelope.status === 0) return envelope.data;
|
|
1867
|
+
if (typeof envelope.status === "number") throw new ZhihuSyncApiError("UPSTREAM_REJECTED", `发布被服务端拒绝(status=${envelope.status}):${sanitizeMessage(envelope.msg, secrets)}`, true, response.status, envelope.status, sanitizeMessage(envelope.msg, secrets));
|
|
1868
|
+
throw new ZhihuSyncApiError("BAD_RESPONSE", `发布响应 status 不是数字(收到 ${typeof envelope.status})。`, false, response.status);
|
|
1869
|
+
}
|
|
1870
|
+
return body;
|
|
1871
|
+
}
|
|
1872
|
+
/** 知乎发布成功 URL:必须是 https 且属于知乎域(与 content 的 ZHIHU_DOMAIN_RE 一致)。 */
|
|
1873
|
+
const ZHIHU_PUBLISH_DOMAIN_RE = /^([a-z0-9-]+\.)*(zhimg\.com|zhihu\.com)$/i;
|
|
1874
|
+
function isValidPublishUrl(value) {
|
|
1875
|
+
try {
|
|
1876
|
+
const u = new URL(value);
|
|
1877
|
+
return u.protocol === "https:" && !u.username && !u.password && ZHIHU_PUBLISH_DOMAIN_RE.test(u.hostname);
|
|
1878
|
+
} catch {
|
|
1879
|
+
return false;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* 将 prepareZhihuSyncDraft 的结果组装并发送到 /openapi/publish。
|
|
1884
|
+
*
|
|
1885
|
+
* 发送前不做内容字段二次校验(发布字段唯一事实源是 SyncPrepared);
|
|
1886
|
+
* 上层负责:预览确认、持久化 publishing 意图、按 definite 标记结果。
|
|
1887
|
+
*/
|
|
1888
|
+
async function publishZhihuSync(prepared, options, signal) {
|
|
1889
|
+
const request = buildZhihuPublishRequest(prepared);
|
|
1890
|
+
const row = await syncRequest("/openapi/publish", {
|
|
1891
|
+
method: "POST",
|
|
1892
|
+
body: request
|
|
1893
|
+
}, options, signal) ?? {};
|
|
1894
|
+
const contentToken = typeof row.content_token === "string" || typeof row.content_token === "number" ? String(row.content_token) : "";
|
|
1895
|
+
const url = typeof row.url === "string" ? row.url : "";
|
|
1896
|
+
const type = typeof row.type === "string" ? row.type : "";
|
|
1897
|
+
if (!contentToken || !url) throw new ZhihuSyncApiError("BAD_RESPONSE", "发布成功响应缺少 content_token 或 url。", false);
|
|
1898
|
+
if (!isValidPublishUrl(url)) throw new ZhihuSyncApiError("BAD_RESPONSE", `发布成功响应返回了无效的 url:${sanitizeMessage(url, [])}`, false);
|
|
1899
|
+
if (type !== request.type) throw new ZhihuSyncApiError("BAD_RESPONSE", `发布响应 type 与请求不一致:${String(type)} ≠ ${request.type}。`, false);
|
|
1900
|
+
return {
|
|
1901
|
+
url,
|
|
1902
|
+
contentToken,
|
|
1903
|
+
type
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
//#endregion
|
|
1907
|
+
//#region src/sync-media.ts
|
|
1908
|
+
/**
|
|
1909
|
+
* 知乎官方媒体上传(仅图片)。协议来源:只读参考
|
|
1910
|
+
* zhihu-mediacloud-uploader v0.1.4(mediacloud_uploader/api.py、client.py、cloud/ali.py、
|
|
1911
|
+
* watermark_poller.py、image_validator.py),移植为 Node TypeScript 原生 fetch。
|
|
1912
|
+
*
|
|
1913
|
+
* 流程:本地校验 → ApplyUpload(POST /openapi/media/uploads)→ 阿里 OSS PutObject
|
|
1914
|
+
* (自建 HTTPS 直传,OSS v1 签名)→ CommitUpload(PATCH)→ 水印轮询
|
|
1915
|
+
* (GET /openapi/media/images/{id}/watermark)。秒传(UPLOAD_SUCCESS)跳过云直传。
|
|
1916
|
+
*
|
|
1917
|
+
* 安全约束:重定向拒绝;OSS 目的主机仅允许 https 且 *.aliyuncs.com;
|
|
1918
|
+
* 凭据、STS、签名绝不进入返回值或错误消息(统一脱敏)。
|
|
1919
|
+
*/
|
|
1920
|
+
var ZhihuSyncMediaError = class extends Error {
|
|
1921
|
+
code;
|
|
1922
|
+
httpStatus;
|
|
1923
|
+
vendorCode;
|
|
1924
|
+
constructor(code, message, extra = {}) {
|
|
1925
|
+
super(message);
|
|
1926
|
+
this.name = "ZhihuSyncMediaError";
|
|
1927
|
+
this.code = code;
|
|
1928
|
+
if (extra.httpStatus !== void 0) this.httpStatus = extra.httpStatus;
|
|
1929
|
+
if (extra.vendorCode !== void 0) this.vendorCode = extra.vendorCode;
|
|
1930
|
+
}
|
|
1931
|
+
};
|
|
1932
|
+
const PATH_APPLY_UPLOAD = "/openapi/media/uploads";
|
|
1933
|
+
const PATH_COMMIT_UPLOAD = (sessionKey) => `/openapi/media/uploads/${sessionKey}`;
|
|
1934
|
+
const PATH_IMAGE_WATERMARK = (imageId) => `/openapi/media/images/${imageId}/watermark`;
|
|
1935
|
+
const TEMPLATE_IMAGE = "image-upload-default";
|
|
1936
|
+
const MEDIA_TYPE_IMAGE = "image";
|
|
1937
|
+
const PLATFORM_WEB = "web";
|
|
1938
|
+
const UPLOADING = "UPLOADING";
|
|
1939
|
+
const UPLOAD_SUCCESS = "UPLOAD_SUCCESS";
|
|
1940
|
+
const UPLOAD_FAIL = "UPLOAD_FAIL";
|
|
1941
|
+
const SIZE_LIMIT_GIF = 15728640;
|
|
1942
|
+
const SIZE_LIMIT_OTHER = 31457280;
|
|
1943
|
+
const MAX_LONG_SIDE = 16384;
|
|
1944
|
+
const MAX_TOTAL_PIXELS = 2e8;
|
|
1945
|
+
const WATERMARK_INTERVAL_MS = 500;
|
|
1946
|
+
const WATERMARK_TIMEOUT_MS = 3e4;
|
|
1947
|
+
const OSS_TIMEOUT_MS = 12e4;
|
|
1948
|
+
const VENDOR_ALI = "ali";
|
|
1949
|
+
const OSS_EXPIRED_CODES = /* @__PURE__ */ new Set([
|
|
1950
|
+
"InvalidAccessKeyId",
|
|
1951
|
+
"SecurityTokenExpired",
|
|
1952
|
+
"AccessDenied",
|
|
1953
|
+
"InvalidSecurityToken"
|
|
1954
|
+
]);
|
|
1955
|
+
const record = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
1956
|
+
const str = (v) => typeof v === "string" ? v : "";
|
|
1957
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
1958
|
+
/** 从待抛出消息中抹除一切已知敏感串(STS、对象名等不参与签名但属临时授权信息)。 */
|
|
1959
|
+
function redact(message, secrets) {
|
|
1960
|
+
let out = message;
|
|
1961
|
+
for (const s of secrets) if (s && s.length >= 4) out = out.split(s).join("[REDACTED]");
|
|
1962
|
+
return out;
|
|
1963
|
+
}
|
|
1964
|
+
function throwAborted(signal) {
|
|
1965
|
+
throw new ZhihuSyncMediaError("CANCELLED", "上传已取消。");
|
|
1966
|
+
}
|
|
1967
|
+
function parseMediaUrl(raw) {
|
|
1968
|
+
if (!record(raw)) return void 0;
|
|
1969
|
+
const backups = Array.isArray(raw.backups) ? raw.backups.filter((b) => typeof b === "string") : [];
|
|
1970
|
+
return {
|
|
1971
|
+
primary: str(raw.primary) || backups[0] || "",
|
|
1972
|
+
backups
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
const PUBLIC_IMAGE_HOST_RE = /^(?:[a-z0-9-]+\.)*(zhimg\.com|zhihu\.com)$/i;
|
|
1976
|
+
function publicImageUrl(u) {
|
|
1977
|
+
for (const candidate of [u?.primary, ...u?.backups ?? []]) {
|
|
1978
|
+
if (!candidate) continue;
|
|
1979
|
+
try {
|
|
1980
|
+
const url = new URL(candidate);
|
|
1981
|
+
if (url.protocol !== "https:" || url.username || url.password) continue;
|
|
1982
|
+
if (!PUBLIC_IMAGE_HOST_RE.test(url.hostname)) continue;
|
|
1983
|
+
return candidate;
|
|
1984
|
+
} catch {
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
return "";
|
|
1989
|
+
}
|
|
1990
|
+
const firstUrl = publicImageUrl;
|
|
1991
|
+
function sleep(ms, signal) {
|
|
1992
|
+
return new Promise((resolve, reject) => {
|
|
1993
|
+
if (signal.aborted) return reject(new ZhihuSyncMediaError("CANCELLED", "上传已取消。"));
|
|
1994
|
+
const timer = setTimeout(() => {
|
|
1995
|
+
cleanup();
|
|
1996
|
+
resolve();
|
|
1997
|
+
}, ms);
|
|
1998
|
+
const onAbort = () => {
|
|
1999
|
+
cleanup();
|
|
2000
|
+
reject(new ZhihuSyncMediaError("CANCELLED", "上传已取消。"));
|
|
2001
|
+
};
|
|
2002
|
+
const cleanup = () => {
|
|
2003
|
+
clearTimeout(timer);
|
|
2004
|
+
signal.removeEventListener("abort", onAbort);
|
|
2005
|
+
};
|
|
2006
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
function sniffImage(buf) {
|
|
2010
|
+
const bad = () => new ZhihuSyncMediaError("UNSUPPORTED_TYPE", "无法识别图片格式,仅支持 JPEG / PNG / GIF / WebP / BMP;HEIC / AVIF / TIFF 等请先转换为上述格式。");
|
|
2011
|
+
if (buf.byteLength < 12) throw bad();
|
|
2012
|
+
if (buf[4] === 102 && buf[5] === 116 && buf[6] === 121 && buf[7] === 112) {
|
|
2013
|
+
const brand = String.fromCharCode(buf[8], buf[9], buf[10], buf[11]).toLowerCase();
|
|
2014
|
+
if (/^(heic|heix|hevc|hevx|mif1|msf1|avif|avis)$/.test(brand)) throw new ZhihuSyncMediaError("NEEDS_TRANSCODE", `检测到 ${brand.toUpperCase()} 格式,当前不内置转码,请先转换为 JPEG / PNG / WebP 后再上传。`);
|
|
2015
|
+
throw bad();
|
|
2016
|
+
}
|
|
2017
|
+
if (buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71 && buf[4] === 13 && buf[5] === 10 && buf[6] === 26 && buf[7] === 10) {
|
|
2018
|
+
if (buf.byteLength < 24) throw bad();
|
|
2019
|
+
const w = (buf[16] << 24 | buf[17] << 16 | buf[18] << 8 | buf[19]) >>> 0;
|
|
2020
|
+
const h = (buf[20] << 24 | buf[21] << 16 | buf[22] << 8 | buf[23]) >>> 0;
|
|
2021
|
+
if (!w || !h) throw bad();
|
|
2022
|
+
return {
|
|
2023
|
+
format: "PNG",
|
|
2024
|
+
ext: ".png",
|
|
2025
|
+
contentType: "image/png",
|
|
2026
|
+
width: w,
|
|
2027
|
+
height: h,
|
|
2028
|
+
gif: false
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2031
|
+
if (buf[0] === 71 && buf[1] === 73 && buf[2] === 70 && buf[3] === 56) {
|
|
2032
|
+
if (buf.byteLength < 10) throw bad();
|
|
2033
|
+
const w = buf[6] | buf[7] << 8;
|
|
2034
|
+
const h = buf[8] | buf[9] << 8;
|
|
2035
|
+
if (!w || !h) throw bad();
|
|
2036
|
+
return {
|
|
2037
|
+
format: "GIF",
|
|
2038
|
+
ext: ".gif",
|
|
2039
|
+
contentType: "image/gif",
|
|
2040
|
+
width: w,
|
|
2041
|
+
height: h,
|
|
2042
|
+
gif: true
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
if (buf[0] === 66 && buf[1] === 77) {
|
|
2046
|
+
if (buf.byteLength < 26) throw bad();
|
|
2047
|
+
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
2048
|
+
if (dv.getUint32(14, true) < 40) throw bad();
|
|
2049
|
+
const w = Math.abs(dv.getInt32(18, true));
|
|
2050
|
+
const h = Math.abs(dv.getInt32(22, true));
|
|
2051
|
+
if (!w || !h) throw bad();
|
|
2052
|
+
return {
|
|
2053
|
+
format: "BMP",
|
|
2054
|
+
ext: ".bmp",
|
|
2055
|
+
contentType: "image/bmp",
|
|
2056
|
+
width: w,
|
|
2057
|
+
height: h,
|
|
2058
|
+
gif: false
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
if (buf[0] === 82 && buf[1] === 73 && buf[2] === 70 && buf[3] === 70 && buf[8] === 87 && buf[9] === 69 && buf[10] === 66 && buf[11] === 80) {
|
|
2062
|
+
if (buf.byteLength < 30) throw bad();
|
|
2063
|
+
const four = String.fromCharCode(buf[12], buf[13], buf[14], buf[15]);
|
|
2064
|
+
if (four === "VP8X") return {
|
|
2065
|
+
format: "WEBP",
|
|
2066
|
+
ext: ".webp",
|
|
2067
|
+
contentType: "image/webp",
|
|
2068
|
+
width: (buf[24] | buf[25] << 8 | buf[26] << 16) + 1,
|
|
2069
|
+
height: (buf[27] | buf[28] << 8 | buf[29] << 16) + 1,
|
|
2070
|
+
gif: false
|
|
2071
|
+
};
|
|
2072
|
+
if (four === "VP8L") {
|
|
2073
|
+
const bits = buf[21] | buf[22] << 8 | buf[23] << 16 | buf[24] << 24;
|
|
2074
|
+
return {
|
|
2075
|
+
format: "WEBP",
|
|
2076
|
+
ext: ".webp",
|
|
2077
|
+
contentType: "image/webp",
|
|
2078
|
+
width: (bits & 16383) + 1,
|
|
2079
|
+
height: (bits >>> 14 & 16383) + 1,
|
|
2080
|
+
gif: false
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
if (four === "VP8 ") {
|
|
2084
|
+
if (buf[23] !== 157 || buf[24] !== 1 || buf[25] !== 42) throw bad();
|
|
2085
|
+
const w = (buf[26] | buf[27] << 8) & 16383;
|
|
2086
|
+
const h = (buf[28] | buf[29] << 8) & 16383;
|
|
2087
|
+
if (!w || !h) throw bad();
|
|
2088
|
+
return {
|
|
2089
|
+
format: "WEBP",
|
|
2090
|
+
ext: ".webp",
|
|
2091
|
+
contentType: "image/webp",
|
|
2092
|
+
width: w,
|
|
2093
|
+
height: h,
|
|
2094
|
+
gif: false
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
throw bad();
|
|
2098
|
+
}
|
|
2099
|
+
if (buf[0] === 255 && buf[1] === 216) {
|
|
2100
|
+
let off = 2;
|
|
2101
|
+
while (off + 9 < buf.byteLength) {
|
|
2102
|
+
if (buf[off] !== 255) {
|
|
2103
|
+
off++;
|
|
2104
|
+
continue;
|
|
2105
|
+
}
|
|
2106
|
+
let marker = buf[off + 1];
|
|
2107
|
+
while (marker === 255 && off + 2 < buf.byteLength) {
|
|
2108
|
+
off++;
|
|
2109
|
+
marker = buf[off + 1];
|
|
2110
|
+
}
|
|
2111
|
+
if (marker === 216 || marker === 217 || marker === 1 || marker >= 208 && marker <= 215) {
|
|
2112
|
+
off += 2;
|
|
2113
|
+
continue;
|
|
2114
|
+
}
|
|
2115
|
+
if (off + 3 >= buf.byteLength) break;
|
|
2116
|
+
const len = buf[off + 2] << 8 | buf[off + 3];
|
|
2117
|
+
if (len < 2) break;
|
|
2118
|
+
if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204 && off + 8 < buf.byteLength) {
|
|
2119
|
+
const h = buf[off + 5] << 8 | buf[off + 6];
|
|
2120
|
+
const w = buf[off + 7] << 8 | buf[off + 8];
|
|
2121
|
+
if (!w || !h) throw bad();
|
|
2122
|
+
return {
|
|
2123
|
+
format: "JPEG",
|
|
2124
|
+
ext: ".jpg",
|
|
2125
|
+
contentType: "image/jpeg",
|
|
2126
|
+
width: w,
|
|
2127
|
+
height: h,
|
|
2128
|
+
gif: false
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
off += 2 + len;
|
|
2132
|
+
}
|
|
2133
|
+
throw bad();
|
|
2134
|
+
}
|
|
2135
|
+
throw bad();
|
|
2136
|
+
}
|
|
2137
|
+
function validateImage(meta, sizeBytes) {
|
|
2138
|
+
const limit = meta.gif ? SIZE_LIMIT_GIF : SIZE_LIMIT_OTHER;
|
|
2139
|
+
if (sizeBytes > limit) throw new ZhihuSyncMediaError("LIMIT_EXCEEDED", `图片大小超限(${(sizeBytes / 1048576).toFixed(1)} MB),上限 ${limit / 1048576} MB${meta.gif ? "(GIF)" : ""},请压缩后重试。`);
|
|
2140
|
+
const longSide = Math.max(meta.width, meta.height);
|
|
2141
|
+
if (longSide > MAX_LONG_SIDE) throw new ZhihuSyncMediaError("LIMIT_EXCEEDED", `图片长边 ${longSide}px 超限,上限 ${MAX_LONG_SIDE}px。`);
|
|
2142
|
+
if (meta.width * meta.height > MAX_TOTAL_PIXELS) throw new ZhihuSyncMediaError("LIMIT_EXCEEDED", "图片总像素数超限(上限 2 亿像素)。");
|
|
2143
|
+
}
|
|
2144
|
+
function parseApply(data) {
|
|
2145
|
+
if (!record(data)) throw new ZhihuSyncMediaError("BAD_RESPONSE", "ApplyUpload 响应不是对象。");
|
|
2146
|
+
const config = record(data.upload_config) ? data.upload_config : {};
|
|
2147
|
+
const addressRaw = record(config.address) ? config.address : void 0;
|
|
2148
|
+
const authRaw = record(data.upload_auth) ? data.upload_auth : void 0;
|
|
2149
|
+
const media = record(data.upload_media) ? data.upload_media : {};
|
|
2150
|
+
return {
|
|
2151
|
+
state: str(data.upload_state),
|
|
2152
|
+
sessionKey: str(data.session_key),
|
|
2153
|
+
vendorCode: str(config.vendor_code),
|
|
2154
|
+
commitAddress: str(media.commit_address),
|
|
2155
|
+
objectKey: str(media.key),
|
|
2156
|
+
processes: str(media.processes),
|
|
2157
|
+
extraParams: record(data.extra_params) ? data.extra_params : {},
|
|
2158
|
+
auth: authRaw ? {
|
|
2159
|
+
accessKeyId: str(authRaw.access_key_id),
|
|
2160
|
+
secretAccessKey: str(authRaw.secret_access_key),
|
|
2161
|
+
securityToken: str(authRaw.security_token),
|
|
2162
|
+
expiration: num(authRaw.expiration)
|
|
2163
|
+
} : void 0,
|
|
2164
|
+
address: addressRaw ? {
|
|
2165
|
+
endpoint: str(addressRaw.endpoint),
|
|
2166
|
+
bucketName: str(addressRaw.bucket_name),
|
|
2167
|
+
objectName: str(addressRaw.object_name),
|
|
2168
|
+
isCname: addressRaw.is_cname === true
|
|
2169
|
+
} : void 0
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
function parseCommit(data) {
|
|
2173
|
+
if (!record(data)) throw new ZhihuSyncMediaError("BAD_RESPONSE", "CommitUpload 响应不是对象。");
|
|
2174
|
+
const meta = record(data.media_meta) ? data.media_meta : void 0;
|
|
2175
|
+
return {
|
|
2176
|
+
result: str(data.upload_results),
|
|
2177
|
+
mediaKey: str(data.media_key),
|
|
2178
|
+
mediaUrl: parseMediaUrl(data.media_url),
|
|
2179
|
+
width: meta ? num(meta.width) || void 0 : void 0,
|
|
2180
|
+
height: meta ? num(meta.height) || void 0 : void 0
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
const OSS_HOST_RE = /^(?:[a-z0-9-]+\.)*aliyuncs\.com$/;
|
|
2184
|
+
const OSS_BUCKET_RE = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
|
|
2185
|
+
function ossEndpointHost(endpoint) {
|
|
2186
|
+
const text = endpoint.trim();
|
|
2187
|
+
if (!text) throw new ZhihuSyncMediaError("BAD_RESPONSE", "OSS endpoint 为空。");
|
|
2188
|
+
if (/^http:\/\//i.test(text)) throw new ZhihuSyncMediaError("OSS_ERROR", "OSS endpoint 必须使用 HTTPS。");
|
|
2189
|
+
const host = text.replace(/^https:\/\//i, "").replace(/\/+$/, "").toLowerCase();
|
|
2190
|
+
if (!OSS_HOST_RE.test(host)) throw new ZhihuSyncMediaError("OSS_ERROR", "OSS endpoint 必须是不带路径、端口与账号密码的 aliyuncs.com 主机名。");
|
|
2191
|
+
return host;
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2194
|
+
* 拼出直传地址,并校验最终 URL:主机名必须与预期的合规主机完全一致,且不得带账号密码。
|
|
2195
|
+
* 返回的就是实际发出去的那个 URL——校验与请求必须是同一个地址。
|
|
2196
|
+
*/
|
|
2197
|
+
function ossTargetUrl(bucket, host, encodedKey, isCname) {
|
|
2198
|
+
if (!OSS_BUCKET_RE.test(bucket)) throw new ZhihuSyncMediaError("BAD_RESPONSE", "OSS bucket 名称不合法。");
|
|
2199
|
+
const requestHost = isCname ? host : `${bucket}.${host}`;
|
|
2200
|
+
let target;
|
|
2201
|
+
try {
|
|
2202
|
+
target = new URL(`https://${requestHost}/${encodedKey}`);
|
|
2203
|
+
} catch {
|
|
2204
|
+
throw new ZhihuSyncMediaError("OSS_ERROR", "OSS 目标地址不合法。");
|
|
2205
|
+
}
|
|
2206
|
+
if (target.protocol !== "https:" || target.username || target.password || target.hostname !== requestHost) throw new ZhihuSyncMediaError("OSS_ERROR", "OSS 目标地址不合法。");
|
|
2207
|
+
return target.toString();
|
|
2208
|
+
}
|
|
2209
|
+
function encodeObjectName(name) {
|
|
2210
|
+
return name.split("/").map((seg) => encodeURIComponent(seg)).join("/");
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* OSS v1 header 签名:
|
|
2214
|
+
* StringToSign = VERB \n Content-MD5 \n Content-Type \n Date \n x-oss-* \n CanonicalizedResource
|
|
2215
|
+
*/
|
|
2216
|
+
function ossAuthorization(verb, contentMd5B64, contentType, date, auth, bucket, encodedKey) {
|
|
2217
|
+
const toSign = `${verb}\n${contentMd5B64}\n${contentType}\n${date}\n${`x-oss-security-token:${auth.securityToken}\n`}${`/${bucket}/${encodedKey}`}`;
|
|
2218
|
+
const sig = createHmac("sha1", auth.secretAccessKey).update(toSign, "utf8").digest("base64");
|
|
2219
|
+
return `OSS ${auth.accessKeyId}:${sig}`;
|
|
2220
|
+
}
|
|
2221
|
+
function parseOssErrorCode(xml) {
|
|
2222
|
+
const m = /<Code>([^<]+)<\/Code>/i.exec(xml);
|
|
2223
|
+
return m ? m[1].trim() : "";
|
|
2224
|
+
}
|
|
2225
|
+
async function ossPutObject(fetcher, auth, address, body, contentType, md5B64, signal, timeoutMs) {
|
|
2226
|
+
const host = ossEndpointHost(address.endpoint);
|
|
2227
|
+
const encodedKey = encodeObjectName(address.objectName);
|
|
2228
|
+
const url = ossTargetUrl(address.bucketName, host, encodedKey, address.isCname);
|
|
2229
|
+
const date = (/* @__PURE__ */ new Date()).toUTCString();
|
|
2230
|
+
const authorization = ossAuthorization("PUT", md5B64, contentType, date, auth, address.bucketName, encodedKey);
|
|
2231
|
+
const controller = new AbortController();
|
|
2232
|
+
const onAbort = () => controller.abort(signal.reason);
|
|
2233
|
+
if (signal.aborted) onAbort();
|
|
2234
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
2235
|
+
const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("oss put timeout")), timeoutMs);
|
|
2236
|
+
try {
|
|
2237
|
+
const res = await fetcher(url, {
|
|
2238
|
+
method: "PUT",
|
|
2239
|
+
redirect: "error",
|
|
2240
|
+
headers: {
|
|
2241
|
+
Date: date,
|
|
2242
|
+
"Content-Type": contentType,
|
|
2243
|
+
"Content-MD5": md5B64,
|
|
2244
|
+
"x-oss-security-token": auth.securityToken,
|
|
2245
|
+
Authorization: authorization
|
|
2246
|
+
},
|
|
2247
|
+
body,
|
|
2248
|
+
signal: controller.signal
|
|
2249
|
+
});
|
|
2250
|
+
if (res.ok) return { ok: true };
|
|
2251
|
+
return {
|
|
2252
|
+
ok: false,
|
|
2253
|
+
ossCode: parseOssErrorCode(await res.text().catch(() => "")),
|
|
2254
|
+
status: res.status
|
|
2255
|
+
};
|
|
2256
|
+
} finally {
|
|
2257
|
+
clearTimeout(timer);
|
|
2258
|
+
signal.removeEventListener("abort", onAbort);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
function resolveSyncRequest(options) {
|
|
2262
|
+
return options.syncRequest ?? syncRequest;
|
|
2263
|
+
}
|
|
2264
|
+
async function uploadZhihuSyncMedia(input, options, signal) {
|
|
2265
|
+
const secrets = [];
|
|
2266
|
+
try {
|
|
2267
|
+
return await uploadImpl(input, options, signal, secrets);
|
|
2268
|
+
} catch (error) {
|
|
2269
|
+
if (error instanceof ZhihuSyncMediaError) {
|
|
2270
|
+
error.message = redact(error.message, secrets);
|
|
2271
|
+
throw error;
|
|
2272
|
+
}
|
|
2273
|
+
throw new ZhihuSyncMediaError("API_ERROR", redact(`媒体上传失败:${error instanceof Error ? error.message : String(error)}`, secrets));
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
async function uploadImpl(input, options, signal, secrets) {
|
|
2277
|
+
if (signal.aborted) throwAborted(signal);
|
|
2278
|
+
if (input.kind !== void 0 && input.kind !== "image") throw new ZhihuSyncMediaError("UNSUPPORTED_TYPE", "当前仅支持图片上传;视频与对象文件未接入。");
|
|
2279
|
+
const scene = input.scene;
|
|
2280
|
+
if (scene !== "article" && scene !== "question" && scene !== "pin") throw new ZhihuSyncMediaError("INVALID_INPUT", `scene 必须是 article / question / pin,收到 ${String(scene)}。`);
|
|
2281
|
+
const fileName = (input.fileName.trim().split(/[\\/]/).pop() ?? "").trim();
|
|
2282
|
+
if (!fileName || [...fileName].some((ch) => (ch.codePointAt(0) ?? 0) < 32 || ch.codePointAt(0) === 127)) throw new ZhihuSyncMediaError("INVALID_INPUT", "fileName 不能为空且不能包含控制字符。");
|
|
2283
|
+
const b64 = input.contentBase64.trim();
|
|
2284
|
+
if (!b64 || !/^[A-Za-z0-9+/]*={0,2}$/.test(b64) || b64.length % 4 !== 0) throw new ZhihuSyncMediaError("INVALID_INPUT", "contentBase64 不是合法 base64。");
|
|
2285
|
+
const bytes = new Uint8Array(Buffer.from(b64, "base64"));
|
|
2286
|
+
if (!bytes.byteLength) throw new ZhihuSyncMediaError("INVALID_INPUT", "图片内容为空。");
|
|
2287
|
+
const meta = sniffImage(bytes);
|
|
2288
|
+
validateImage(meta, bytes.byteLength);
|
|
2289
|
+
const md5Hex = createHash("md5").update(bytes).digest("hex");
|
|
2290
|
+
const md5B64 = createHash("md5").update(bytes).digest("base64");
|
|
2291
|
+
const syncRequest = resolveSyncRequest(options);
|
|
2292
|
+
try {
|
|
2293
|
+
const creds = await options.resolveCredentials();
|
|
2294
|
+
if (creds?.appKey) secrets.push(creds.appKey);
|
|
2295
|
+
if (creds?.appSecret) secrets.push(creds.appSecret);
|
|
2296
|
+
} catch {}
|
|
2297
|
+
if (signal.aborted) throwAborted(signal);
|
|
2298
|
+
const warnings = [];
|
|
2299
|
+
const applyBody = {
|
|
2300
|
+
template_name: TEMPLATE_IMAGE,
|
|
2301
|
+
media_type: MEDIA_TYPE_IMAGE,
|
|
2302
|
+
file_name: fileName,
|
|
2303
|
+
file_extension: meta.ext,
|
|
2304
|
+
file_size: bytes.byteLength
|
|
2305
|
+
};
|
|
2306
|
+
const applyHeaders = {
|
|
2307
|
+
"X-Media-Scene-Name": scene,
|
|
2308
|
+
"X-Media-Upload-Platform": PLATFORM_WEB,
|
|
2309
|
+
"X-Upload-Content-Type": meta.contentType,
|
|
2310
|
+
"X-Upload-Content-Hash": md5Hex
|
|
2311
|
+
};
|
|
2312
|
+
let apply = parseApply(await syncRequest(PATH_APPLY_UPLOAD, {
|
|
2313
|
+
method: "POST",
|
|
2314
|
+
headers: applyHeaders,
|
|
2315
|
+
body: applyBody
|
|
2316
|
+
}, options, signal));
|
|
2317
|
+
if (apply.auth) secrets.push(apply.auth.accessKeyId, apply.auth.secretAccessKey, apply.auth.securityToken);
|
|
2318
|
+
if (signal.aborted) throwAborted(signal);
|
|
2319
|
+
let mediaKey = "";
|
|
2320
|
+
let mediaUrl;
|
|
2321
|
+
let width = meta.width;
|
|
2322
|
+
let height = meta.height;
|
|
2323
|
+
if (apply.state === UPLOAD_SUCCESS) {
|
|
2324
|
+
const ep = apply.extraParams;
|
|
2325
|
+
mediaUrl = parseMediaUrl(ep.url);
|
|
2326
|
+
mediaKey = apply.objectKey;
|
|
2327
|
+
width = num(ep.width) || width;
|
|
2328
|
+
height = num(ep.height) || height;
|
|
2329
|
+
} else if (apply.state === UPLOADING) {
|
|
2330
|
+
if (apply.vendorCode !== VENDOR_ALI) throw new ZhihuSyncMediaError("UNSUPPORTED_VENDOR", `服务端下发未实现的云厂商 ${apply.vendorCode || "(空)"};当前仅支持阿里 OSS。`, { vendorCode: apply.vendorCode || void 0 });
|
|
2331
|
+
if (!apply.auth || !apply.address || !apply.auth.accessKeyId || !apply.auth.secretAccessKey || !apply.auth.securityToken || !apply.address.endpoint || !apply.address.bucketName || !apply.address.objectName) throw new ZhihuSyncMediaError("BAD_RESPONSE", "ApplyUpload 缺少 OSS 凭证或地址,无法直传。");
|
|
2332
|
+
const fetcher = options.fetcher ?? (typeof fetch === "function" ? fetch : void 0);
|
|
2333
|
+
if (!fetcher) throw new ZhihuSyncMediaError("BAD_RESPONSE", "当前环境没有可用的 fetch。");
|
|
2334
|
+
let oss = await ossPut(fetcher, apply, bytes, meta.contentType, md5B64, signal, options.timeoutMs);
|
|
2335
|
+
if (!oss.ok && OSS_EXPIRED_CODES.has(oss.ossCode)) {
|
|
2336
|
+
const refreshed = parseApply(await syncRequest(PATH_APPLY_UPLOAD, {
|
|
2337
|
+
method: "POST",
|
|
2338
|
+
headers: applyHeaders,
|
|
2339
|
+
body: {
|
|
2340
|
+
template_name: TEMPLATE_IMAGE,
|
|
2341
|
+
media_type: MEDIA_TYPE_IMAGE,
|
|
2342
|
+
session_key: apply.sessionKey
|
|
2343
|
+
}
|
|
2344
|
+
}, options, signal));
|
|
2345
|
+
if (refreshed.state === UPLOAD_FAIL || !refreshed.sessionKey) throw new ZhihuSyncMediaError("SESSION_EXPIRED", "上传会话已过期,刷新凭证被拒绝。");
|
|
2346
|
+
apply = refreshed;
|
|
2347
|
+
if (apply.auth) secrets.push(apply.auth.accessKeyId, apply.auth.secretAccessKey, apply.auth.securityToken);
|
|
2348
|
+
if (!apply.auth || !apply.address) throw new ZhihuSyncMediaError("BAD_RESPONSE", "刷新后的 ApplyUpload 缺少 OSS 凭证或地址。");
|
|
2349
|
+
oss = await ossPut(fetcher, apply, bytes, meta.contentType, md5B64, signal, options.timeoutMs);
|
|
2350
|
+
}
|
|
2351
|
+
let ossError = "";
|
|
2352
|
+
if (!oss.ok) ossError = `OSS PutObject HTTP ${oss.status}${oss.ossCode ? ` code=${oss.ossCode}` : ""}`;
|
|
2353
|
+
const commitBody = {
|
|
2354
|
+
media_type: MEDIA_TYPE_IMAGE,
|
|
2355
|
+
client_upload_state: oss.ok ? UPLOAD_SUCCESS : UPLOAD_FAIL
|
|
2356
|
+
};
|
|
2357
|
+
if (ossError) commitBody.error_log = ossError;
|
|
2358
|
+
if (apply.processes) commitBody.processes = apply.processes;
|
|
2359
|
+
const commit = parseCommit(await syncRequest(apply.commitAddress || PATH_COMMIT_UPLOAD(apply.sessionKey), {
|
|
2360
|
+
method: "PATCH",
|
|
2361
|
+
headers: {
|
|
2362
|
+
"X-Media-Scene-Name": scene,
|
|
2363
|
+
"X-Media-Upload-Platform": PLATFORM_WEB,
|
|
2364
|
+
"X-Security-Token": apply.auth?.securityToken ?? ""
|
|
2365
|
+
},
|
|
2366
|
+
body: commitBody
|
|
2367
|
+
}, options, signal));
|
|
2368
|
+
if (!oss.ok) throw new ZhihuSyncMediaError("OSS_ERROR", `云端直传失败:${ossError}`, { httpStatus: oss.status });
|
|
2369
|
+
if (commit.result !== UPLOAD_SUCCESS) throw new ZhihuSyncMediaError("API_ERROR", `CommitUpload 未成功:${commit.result || "(空结果)"}。`);
|
|
2370
|
+
mediaKey = commit.mediaKey || apply.objectKey;
|
|
2371
|
+
mediaUrl = commit.mediaUrl;
|
|
2372
|
+
width = commit.width ?? width;
|
|
2373
|
+
height = commit.height ?? height;
|
|
2374
|
+
} else throw new ZhihuSyncMediaError("API_ERROR", `ApplyUpload 返回 ${apply.state || "(空状态)"}${apply.state === UPLOAD_FAIL ? ",会话被拒绝" : ""}。`);
|
|
2375
|
+
const originalKey = `v2-${md5Hex}`;
|
|
2376
|
+
if (!mediaUrl || !firstUrl(mediaUrl)) throw new ZhihuSyncMediaError("BAD_RESPONSE", "上传成功但服务端未返回可访问图片 URL。");
|
|
2377
|
+
const originalUrl = firstUrl(mediaUrl);
|
|
2378
|
+
const watermark = await pollWatermark(syncRequest, apply.sessionKey, scene, originalKey, options, signal, warnings);
|
|
2379
|
+
if (watermark) return {
|
|
2380
|
+
kind: "image",
|
|
2381
|
+
url: firstUrl(watermark.url),
|
|
2382
|
+
originalUrl,
|
|
2383
|
+
mediaId: watermark.token,
|
|
2384
|
+
width,
|
|
2385
|
+
height,
|
|
2386
|
+
watermark: true,
|
|
2387
|
+
...warnings.length ? { warnings } : {}
|
|
2388
|
+
};
|
|
2389
|
+
return {
|
|
2390
|
+
kind: "image",
|
|
2391
|
+
url: originalUrl,
|
|
2392
|
+
mediaId: mediaKey && mediaKey !== apply.objectKey ? mediaKey : originalKey,
|
|
2393
|
+
width,
|
|
2394
|
+
height,
|
|
2395
|
+
watermark: false,
|
|
2396
|
+
...warnings.length ? { warnings } : {}
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
async function ossPut(fetcher, apply, bytes, contentType, md5B64, signal, timeoutMs) {
|
|
2400
|
+
const auth = apply.auth;
|
|
2401
|
+
const address = apply.address;
|
|
2402
|
+
try {
|
|
2403
|
+
return await ossPutObject(fetcher, auth, address, bytes, contentType, md5B64, signal, timeoutMs && timeoutMs > 0 ? Math.max(timeoutMs, OSS_TIMEOUT_MS / 4) : OSS_TIMEOUT_MS);
|
|
2404
|
+
} catch (error) {
|
|
2405
|
+
if (error instanceof ZhihuSyncMediaError) throw error;
|
|
2406
|
+
if (error?.name === "AbortError") {
|
|
2407
|
+
if (signal.aborted) throwAborted(signal);
|
|
2408
|
+
throw new ZhihuSyncMediaError("TIMEOUT", "云端直传超时。");
|
|
2409
|
+
}
|
|
2410
|
+
return {
|
|
2411
|
+
ok: false,
|
|
2412
|
+
ossCode: "",
|
|
2413
|
+
status: 0
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
async function pollWatermark(request, imageId, scene, originalKey, options, signal, warnings) {
|
|
2418
|
+
if (!imageId) return void 0;
|
|
2419
|
+
const interval = options.watermarkPoll?.intervalMs ?? WATERMARK_INTERVAL_MS;
|
|
2420
|
+
const timeout = options.watermarkPoll?.timeoutMs ?? WATERMARK_TIMEOUT_MS;
|
|
2421
|
+
const doSleep = options.watermarkPoll?.sleep ?? sleep;
|
|
2422
|
+
const deadline = Date.now() + timeout;
|
|
2423
|
+
while (Date.now() < deadline) {
|
|
2424
|
+
if (signal.aborted) throwAborted(signal);
|
|
2425
|
+
let data;
|
|
2426
|
+
try {
|
|
2427
|
+
const remaining = deadline - Date.now();
|
|
2428
|
+
const scoped = {
|
|
2429
|
+
...options,
|
|
2430
|
+
timeoutMs: Math.max(1, Math.min(options.timeoutMs ?? 3e4, remaining))
|
|
2431
|
+
};
|
|
2432
|
+
data = await request(PATH_IMAGE_WATERMARK(imageId), {
|
|
2433
|
+
method: "GET",
|
|
2434
|
+
headers: { "X-Media-Scene-Name": scene }
|
|
2435
|
+
}, scoped, signal);
|
|
2436
|
+
} catch (error) {
|
|
2437
|
+
if (signal.aborted) throwAborted(signal);
|
|
2438
|
+
const status = error?.httpStatus ?? error?.status;
|
|
2439
|
+
if (typeof status === "number" && status >= 400 && status < 500) break;
|
|
2440
|
+
await doSleep(Math.min(interval, Math.max(0, deadline - Date.now())), signal);
|
|
2441
|
+
continue;
|
|
2442
|
+
}
|
|
2443
|
+
if (!record(data)) break;
|
|
2444
|
+
const state = str(data.state);
|
|
2445
|
+
if (state === "success") {
|
|
2446
|
+
const token = str(data.watermark_image_token);
|
|
2447
|
+
const url = parseMediaUrl(data.watermark_image_url);
|
|
2448
|
+
if (url && firstUrl(url) && token && token !== originalKey) {
|
|
2449
|
+
if (!url.primary && url.backups[0]) warnings.push("水印图主 URL 为空,已使用备用 URL。");
|
|
2450
|
+
return {
|
|
2451
|
+
token,
|
|
2452
|
+
url
|
|
2453
|
+
};
|
|
2454
|
+
}
|
|
2455
|
+
break;
|
|
2456
|
+
}
|
|
2457
|
+
if (state === "failed") break;
|
|
2458
|
+
await doSleep(Math.min(interval, Math.max(0, deadline - Date.now())), signal);
|
|
2459
|
+
}
|
|
2460
|
+
warnings.push("水印处理未完成,已使用未加水印的原图 URL。");
|
|
2461
|
+
}
|
|
2462
|
+
//#endregion
|
|
2463
|
+
//#region src/sync-service.ts
|
|
2464
|
+
const ZHIHU_SYNC_CREDENTIAL_REFS = ["ZHIHU_OPENAPI_APP_KEY", "ZHIHU_OPENAPI_APP_SECRET"];
|
|
2465
|
+
const draftSchema = z.object({
|
|
2466
|
+
type: z.enum([
|
|
2467
|
+
"article",
|
|
2468
|
+
"question",
|
|
2469
|
+
"pin"
|
|
2470
|
+
]),
|
|
2471
|
+
title: z.string().max(1e4),
|
|
2472
|
+
body: z.string().max(2e6),
|
|
2473
|
+
format: z.enum(["markdown", "html"]),
|
|
2474
|
+
images: z.array(z.object({
|
|
2475
|
+
url: z.string(),
|
|
2476
|
+
originalUrl: z.string().optional(),
|
|
2477
|
+
width: z.number().positive().optional(),
|
|
2478
|
+
height: z.number().positive().optional(),
|
|
2479
|
+
watermark: z.boolean().optional(),
|
|
2480
|
+
mediaId: z.string().optional()
|
|
2481
|
+
}).strict()).max(200).optional(),
|
|
2482
|
+
linkCard: z.object({
|
|
2483
|
+
url: z.string(),
|
|
2484
|
+
title: z.string().optional(),
|
|
2485
|
+
cover: z.string().optional(),
|
|
2486
|
+
contentType: z.string().optional(),
|
|
2487
|
+
contentId: z.string().optional(),
|
|
2488
|
+
isFromForward: z.boolean().optional()
|
|
2489
|
+
}).strict().nullable().optional(),
|
|
2490
|
+
settings: z.object({
|
|
2491
|
+
commentPermission: z.enum([
|
|
2492
|
+
"all",
|
|
2493
|
+
"nobody",
|
|
2494
|
+
"followee",
|
|
2495
|
+
"censor",
|
|
2496
|
+
"follower",
|
|
2497
|
+
"follower_n_days"
|
|
2498
|
+
]).optional(),
|
|
2499
|
+
creationStatement: z.enum([
|
|
2500
|
+
"spoiler",
|
|
2501
|
+
"medical_advice",
|
|
2502
|
+
"fictional_creation",
|
|
2503
|
+
"contain_finance",
|
|
2504
|
+
"ai_creation"
|
|
2505
|
+
]).optional(),
|
|
2506
|
+
tableOfContentsEnabled: z.boolean().optional(),
|
|
2507
|
+
topics: z.array(z.string()).max(100).optional(),
|
|
2508
|
+
ring: z.string().optional()
|
|
2509
|
+
}).strict().optional()
|
|
2510
|
+
}).strict();
|
|
2511
|
+
const recordSchema = z.object({
|
|
2512
|
+
id: z.string(),
|
|
2513
|
+
revision: z.number().int().positive(),
|
|
2514
|
+
updatedAt: z.string(),
|
|
2515
|
+
draft: draftSchema,
|
|
2516
|
+
publishState: z.enum([
|
|
2517
|
+
"publishing",
|
|
2518
|
+
"published",
|
|
2519
|
+
"uncertain"
|
|
2520
|
+
]).optional(),
|
|
2521
|
+
published: z.object({
|
|
2522
|
+
url: z.string(),
|
|
2523
|
+
contentToken: z.string(),
|
|
2524
|
+
type: z.string()
|
|
2525
|
+
}).optional(),
|
|
2526
|
+
lastError: z.string().optional()
|
|
2527
|
+
});
|
|
2528
|
+
const zhihuSyncDomainSpec = defineDomain({
|
|
2529
|
+
name: "dsh_zhihu_sync",
|
|
2530
|
+
version: 1,
|
|
2531
|
+
tables: { drafts: domainTable(recordSchema) }
|
|
2532
|
+
});
|
|
2533
|
+
var ZhihuSyncServiceError = class extends Error {
|
|
2534
|
+
code;
|
|
2535
|
+
constructor(code, message) {
|
|
2536
|
+
super(message);
|
|
2537
|
+
this.code = code;
|
|
2538
|
+
}
|
|
2539
|
+
};
|
|
2540
|
+
function object(value) {
|
|
2541
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ZhihuSyncServiceError("bad-request", "请求参数必须为对象。");
|
|
2542
|
+
return value;
|
|
2543
|
+
}
|
|
2544
|
+
/** Review tickets bind an exact saved revision. Only the human UI receives them;
|
|
2545
|
+
* model tools can prepare content but cannot grant publishing authority. */
|
|
2546
|
+
function createZhihuSyncService(table, options) {
|
|
2547
|
+
const reviews = /* @__PURE__ */ new Map();
|
|
2548
|
+
const locks = /* @__PURE__ */ new Map();
|
|
2549
|
+
async function locked(id, operation) {
|
|
2550
|
+
const current = (locks.get(id) ?? Promise.resolve()).catch(() => {}).then(operation);
|
|
2551
|
+
locks.set(id, current);
|
|
2552
|
+
try {
|
|
2553
|
+
return await current;
|
|
2554
|
+
} finally {
|
|
2555
|
+
if (locks.get(id) === current) locks.delete(id);
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
function get(id) {
|
|
2559
|
+
if (typeof id !== "string" || !id) throw new ZhihuSyncServiceError("bad-request", "缺少草稿 ID。");
|
|
2560
|
+
const record = table.get(id);
|
|
2561
|
+
if (!record) throw new ZhihuSyncServiceError("not-found", "草稿不存在。");
|
|
2562
|
+
return structuredClone(record);
|
|
2563
|
+
}
|
|
2564
|
+
function revision(record, expected) {
|
|
2565
|
+
if (record.revision !== expected) throw new ZhihuSyncServiceError("conflict", "草稿已更新,请重新载入后再操作。");
|
|
2566
|
+
}
|
|
2567
|
+
function editable(record) {
|
|
2568
|
+
if (record.publishState) throw new ZhihuSyncServiceError("publish-locked", "此草稿已提交发布。请先检查知乎发布结果;修改内容请保存为新草稿。");
|
|
2569
|
+
}
|
|
2570
|
+
return { async call(endpoint, payload, signal, caller = "ui") {
|
|
2571
|
+
signal.throwIfAborted();
|
|
2572
|
+
const body = object(payload);
|
|
2573
|
+
if (endpoint === "sync.list") {
|
|
2574
|
+
const drafts = [...table.entries()].map(([, value]) => ({
|
|
2575
|
+
id: value.id,
|
|
2576
|
+
revision: value.revision,
|
|
2577
|
+
updatedAt: value.updatedAt,
|
|
2578
|
+
title: value.draft.title,
|
|
2579
|
+
type: value.draft.type,
|
|
2580
|
+
publishState: value.publishState,
|
|
2581
|
+
published: value.published
|
|
2582
|
+
})).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
2583
|
+
const offset = body.offset === void 0 ? 0 : body.offset;
|
|
2584
|
+
if (typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0) throw new ZhihuSyncServiceError("bad-request", "草稿分页位置无效。");
|
|
2585
|
+
const page = drafts.slice(offset, offset + 100);
|
|
2586
|
+
return {
|
|
2587
|
+
drafts: page,
|
|
2588
|
+
total: drafts.length,
|
|
2589
|
+
...offset + page.length < drafts.length ? { nextOffset: offset + page.length } : {}
|
|
2590
|
+
};
|
|
2591
|
+
}
|
|
2592
|
+
if (endpoint === "sync.get") return get(body.id);
|
|
2593
|
+
if (endpoint === "sync.media.upload") return uploadZhihuSyncMedia(body, options, signal);
|
|
2594
|
+
if (endpoint === "sync.publish" && caller !== "ui") throw new ZhihuSyncServiceError("confirmation-required", "Publishing requires a human-confirmed UI review; the agent tool can only save and validate drafts.");
|
|
2595
|
+
if (![
|
|
2596
|
+
"sync.save",
|
|
2597
|
+
"sync.prepare",
|
|
2598
|
+
"sync.publish"
|
|
2599
|
+
].includes(endpoint)) throw new ZhihuSyncServiceError("bad-request", "未知同步操作。");
|
|
2600
|
+
const id = endpoint === "sync.save" && body.id === void 0 ? randomUUID() : body.id;
|
|
2601
|
+
if (typeof id !== "string" || !id) throw new ZhihuSyncServiceError("bad-request", "缺少草稿 ID。");
|
|
2602
|
+
return locked(id, async () => {
|
|
2603
|
+
signal.throwIfAborted();
|
|
2604
|
+
if (endpoint === "sync.save") {
|
|
2605
|
+
const old = table.get(id);
|
|
2606
|
+
if (body.id !== void 0) {
|
|
2607
|
+
if (!old) throw new ZhihuSyncServiceError("not-found", "草稿不存在。");
|
|
2608
|
+
revision(old, body.expectedRevision);
|
|
2609
|
+
editable(old);
|
|
2610
|
+
}
|
|
2611
|
+
const parsed = draftSchema.safeParse(body.draft);
|
|
2612
|
+
if (!parsed.success) throw new ZhihuSyncServiceError("bad-request", "草稿字段格式无效或内容过大,请检查类型、正文、图片与发布设置。");
|
|
2613
|
+
const record = {
|
|
2614
|
+
id,
|
|
2615
|
+
revision: (old?.revision ?? 0) + 1,
|
|
2616
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2617
|
+
draft: parsed.data
|
|
2618
|
+
};
|
|
2619
|
+
await table.put(id, record);
|
|
2620
|
+
reviews.delete(id);
|
|
2621
|
+
return structuredClone(record);
|
|
2622
|
+
}
|
|
2623
|
+
const record = get(id);
|
|
2624
|
+
revision(record, body.expectedRevision);
|
|
2625
|
+
editable(record);
|
|
2626
|
+
const prepared = prepareZhihuSyncDraft(record.draft);
|
|
2627
|
+
if (endpoint === "sync.prepare") {
|
|
2628
|
+
if (caller === "tool") return {
|
|
2629
|
+
record,
|
|
2630
|
+
prepared
|
|
2631
|
+
};
|
|
2632
|
+
for (const [key, review] of reviews) if (review.expires < Date.now()) reviews.delete(key);
|
|
2633
|
+
const reviewToken = randomUUID();
|
|
2634
|
+
if (prepared.canPublish) reviews.set(id, {
|
|
2635
|
+
revision: record.revision,
|
|
2636
|
+
token: reviewToken,
|
|
2637
|
+
expires: Date.now() + 18e5
|
|
2638
|
+
});
|
|
2639
|
+
return {
|
|
2640
|
+
record,
|
|
2641
|
+
prepared,
|
|
2642
|
+
reviewToken: prepared.canPublish ? reviewToken : ""
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
const review = reviews.get(id);
|
|
2646
|
+
if (body.confirmed !== true || !review || review.token !== body.reviewToken || review.revision !== record.revision || review.expires < Date.now()) throw new ZhihuSyncServiceError("confirmation-required", "请重新预览当前草稿并确认发布。");
|
|
2647
|
+
if (!prepared.canPublish) throw new ZhihuSyncServiceError("validation-failed", "请先修正校验错误。");
|
|
2648
|
+
const credentials = await options.resolveCredentials();
|
|
2649
|
+
signal.throwIfAborted();
|
|
2650
|
+
record.publishState = "publishing";
|
|
2651
|
+
record.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2652
|
+
await table.put(id, record);
|
|
2653
|
+
reviews.delete(id);
|
|
2654
|
+
try {
|
|
2655
|
+
record.published = await publishZhihuSync(prepared, {
|
|
2656
|
+
...options,
|
|
2657
|
+
resolveCredentials: async () => credentials
|
|
2658
|
+
}, signal);
|
|
2659
|
+
record.publishState = "published";
|
|
2660
|
+
} catch (error) {
|
|
2661
|
+
if (error instanceof ZhihuSyncApiError && error.definite) {
|
|
2662
|
+
delete record.publishState;
|
|
2663
|
+
record.lastError = error.message;
|
|
2664
|
+
} else {
|
|
2665
|
+
record.publishState = "uncertain";
|
|
2666
|
+
record.lastError = "未能确认发布结果,请检查知乎主页,避免重复发布。";
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
record.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2670
|
+
await table.put(id, record);
|
|
2671
|
+
return structuredClone(record);
|
|
2672
|
+
});
|
|
2673
|
+
} };
|
|
2674
|
+
}
|
|
2675
|
+
//#endregion
|
|
276
2676
|
//#region src/index.ts
|
|
277
2677
|
const name = "@klarkxy/dsh-zhihu";
|
|
278
2678
|
const inject = [
|
|
@@ -456,8 +2856,33 @@ async function apply(ctx, config = {}) {
|
|
|
456
2856
|
await service.dispose();
|
|
457
2857
|
await domain.close();
|
|
458
2858
|
}, "zhihu.usageDomainClose");
|
|
2859
|
+
const syncDomain = await ctx.storageDomain.open(zhihuSyncDomainSpec);
|
|
2860
|
+
const sync = createZhihuSyncService(syncDomain.table("drafts"), { resolveCredentials: async () => {
|
|
2861
|
+
const [key, secret] = await Promise.all(ZHIHU_SYNC_CREDENTIAL_REFS.map((ref) => host.credentials.resolve(credentialRef(ref))));
|
|
2862
|
+
if (!key?.value.trim() || !secret?.value.trim()) throw new ZhihuSyncServiceError("sync-credentials-missing", "请在知乎插件设置中填写发布 App Key 和 App Secret。");
|
|
2863
|
+
return {
|
|
2864
|
+
appKey: key.value.trim(),
|
|
2865
|
+
appSecret: secret.value.trim()
|
|
2866
|
+
};
|
|
2867
|
+
} });
|
|
2868
|
+
service.syncCall = (endpoint, payload, signal, caller) => service.run((combined) => sync.call(endpoint, payload, combined, caller), signal);
|
|
2869
|
+
ctx.effect(() => async () => {
|
|
2870
|
+
await service.dispose();
|
|
2871
|
+
await syncDomain.close();
|
|
2872
|
+
}, "zhihu.syncDomainClose");
|
|
459
2873
|
ctx.provide("zhihu", service);
|
|
460
|
-
ctx.effect(() => registerHostRpc(host, ZHIHU_RPC_CHANNEL,
|
|
2874
|
+
ctx.effect(() => registerHostRpc(host, ZHIHU_RPC_CHANNEL, async (endpoint, payload, signal) => {
|
|
2875
|
+
if (!endpoint.startsWith("sync.")) return service.call(endpoint, payload, signal);
|
|
2876
|
+
try {
|
|
2877
|
+
return {
|
|
2878
|
+
ok: true,
|
|
2879
|
+
value: await service.syncCall(endpoint, payload, signal, "ui")
|
|
2880
|
+
};
|
|
2881
|
+
} catch (error) {
|
|
2882
|
+
if (signal.aborted) return fail("cancelled", "请求已取消");
|
|
2883
|
+
return fail(error instanceof ZhihuSyncServiceError ? error.code : "sync-failed", error instanceof Error ? error.message : "知乎同步失败。");
|
|
2884
|
+
}
|
|
2885
|
+
}));
|
|
461
2886
|
bindZhihuWebSearch(ctx, service);
|
|
462
2887
|
}
|
|
463
2888
|
//#endregion
|