@zzclub/pipeline 0.4.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -3
- package/package.json +1 -1
- package/src/adapter-types.ts +6 -0
- package/src/adapters/builtin-image-renderer.ts +7 -1
- package/src/adapters/builtin-markdown-renderer.ts +1 -0
- package/src/commands/analytics.test.ts +155 -0
- package/src/commands/analytics.ts +223 -0
- package/src/commands/init.ts +42 -3
- package/src/commands/publish.ts +44 -67
- package/src/commands/republish.ts +146 -0
- package/src/commands/sync-blog.ts +19 -3
- package/src/commands/topic.test.ts +146 -0
- package/src/commands/topic.ts +717 -0
- package/src/commands/wechat-export.ts +2 -0
- package/src/commands/wx-draft-delete.ts +36 -0
- package/src/commands/wx-drafts.ts +47 -0
- package/src/config.ts +0 -0
- package/src/db.test.ts +47 -0
- package/src/db.ts +95 -0
- package/src/imgx/assets/icons/ancientone-logo.png +0 -0
- package/src/imgx/assets/templates/wechat-cover-split.html +1 -1
- package/src/imgx/render-card.ts +1 -5
- package/src/plugins.ts +11 -1
- package/src/providers/blog.ts +9 -3
- package/src/providers/index.test.ts +1 -1
- package/src/providers/index.ts +29 -5
- package/src/providers/publish-core.ts +124 -0
- package/src/providers/wechat.ts +260 -28
- package/src/routes.ts +1 -1
- package/src/schema/analytics.test.ts +54 -0
- package/src/schema/analytics.ts +19 -0
- package/src/schema/config.ts +25 -1
- package/src/schema/state.ts +10 -0
- package/src/schema/topic.test.ts +69 -0
- package/src/schema/topic.ts +55 -0
- package/src/state.ts +1 -0
- package/src/text.test.ts +11 -6
- package/src/text.ts +27 -9
- package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +2 -7
- package/src/wechat-preview/assets/browser-dist/assets/{style-BYNXnUvc.css → style-vhHYSP8T.css} +1 -1
- package/src/wechat-preview/assets/browser-dist/editor-export.js +27 -27
- package/src/wechat-preview/browser/editor-export.css +248 -159
- package/src/wechat-preview/index.ts +28 -11
- package/src/wechat-preview/themes.ts +70 -24
- package/src/wechat-preview/wechat-formatter.test.ts +0 -1
- package/src/wechat-preview/wechat-formatter.ts +48 -23
- package/src/workflow.test.ts +294 -1
- package/src/wechat-preview/assets/browser-dist/assets/SweiCurveLegCJKsc-Regular-B3Ut5bwH.woff +0 -0
package/src/providers/wechat.ts
CHANGED
|
@@ -4,7 +4,11 @@ import { PipelineConfig, WxAccountConfig } from "../config";
|
|
|
4
4
|
|
|
5
5
|
const TOKEN_PATH = "/api/v1/wx/cgi-bin/token";
|
|
6
6
|
const MATERIAL_PATH = "/api/v1/wx/cgi-bin/material/add_material";
|
|
7
|
-
const
|
|
7
|
+
const DRAFT_ADD_PATH = "/api/v1/wx/cgi-bin/draft/add";
|
|
8
|
+
const DRAFT_UPDATE_PATH = "/api/v1/wx/cgi-bin/draft/update";
|
|
9
|
+
const DRAFT_GET_PATH = "/api/v1/wx/cgi-bin/draft/get";
|
|
10
|
+
const DRAFT_DELETE_PATH = "/api/v1/wx/cgi-bin/draft/delete";
|
|
11
|
+
const BATCH_GET_PATH = "/api/v1/wx/cgi-bin/draft/batchget";
|
|
8
12
|
const TOKEN_TIMEOUT = 10000;
|
|
9
13
|
const UPLOAD_TIMEOUT = 60000;
|
|
10
14
|
const DRAFT_TIMEOUT = 30000;
|
|
@@ -78,14 +82,19 @@ interface BaseWechatPublishInput {
|
|
|
78
82
|
photos?: string[];
|
|
79
83
|
timeout?: number;
|
|
80
84
|
config: PipelineConfig;
|
|
85
|
+
noteId?: string | null;
|
|
86
|
+
nezusBaseUrl?: string | null;
|
|
87
|
+
nezusPat?: string | null;
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
export interface WechatDraftInput extends BaseWechatPublishInput {
|
|
84
91
|
html: string;
|
|
92
|
+
existingDraftMediaId?: string | null;
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
export interface WechatNewspicInput extends BaseWechatPublishInput {
|
|
88
96
|
content: string;
|
|
97
|
+
existingDraftMediaId?: string | null;
|
|
89
98
|
}
|
|
90
99
|
|
|
91
100
|
function requireValue(name: string, value: string | undefined): string {
|
|
@@ -112,7 +121,8 @@ function fillWxAccountConfig(source?: WxAccountConfig): WxAccountConfig {
|
|
|
112
121
|
appId: source?.appId ?? "",
|
|
113
122
|
appSecret: source?.appSecret ?? "",
|
|
114
123
|
customCss: source?.customCss ?? null,
|
|
115
|
-
|
|
124
|
+
theme: source?.theme ?? { editorVars: {}, exportTheme: {} },
|
|
125
|
+
} as WxAccountConfig;
|
|
116
126
|
}
|
|
117
127
|
|
|
118
128
|
function trimTrailingSlash(value: string): string {
|
|
@@ -178,12 +188,17 @@ export function mergePhotoLists(...lists: string[][]): string[] {
|
|
|
178
188
|
|
|
179
189
|
export function extractImageUrls(input: string): string[] {
|
|
180
190
|
const urls = new Set<string>();
|
|
181
|
-
|
|
191
|
+
// Handle both standard  and angle-bracket  syntax.
|
|
192
|
+
// Angle brackets allow URLs with spaces per CommonMark spec.
|
|
193
|
+
// Match markdown images:  and 
|
|
194
|
+
// Second alternative uses lazy match so titles like  still work.
|
|
195
|
+
const markdownImageRegex = /!\[[^\]]*\]\((?:<([^>]+)>|([^)]+?))(?:\s+"[^"]*")?\)/g;
|
|
182
196
|
const htmlImageRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/g;
|
|
183
197
|
let match = markdownImageRegex.exec(input);
|
|
184
198
|
while (match) {
|
|
185
|
-
|
|
186
|
-
|
|
199
|
+
const url = match[1] || match[2];
|
|
200
|
+
if (url) {
|
|
201
|
+
urls.add(url);
|
|
187
202
|
}
|
|
188
203
|
match = markdownImageRegex.exec(input);
|
|
189
204
|
}
|
|
@@ -194,7 +209,12 @@ export function extractImageUrls(input: string): string[] {
|
|
|
194
209
|
}
|
|
195
210
|
match = htmlImageRegex.exec(input);
|
|
196
211
|
}
|
|
197
|
-
|
|
212
|
+
// Filter out URLs that look like HTML fragments (unescaped code block content
|
|
213
|
+
// that was incorrectly matched by the image regex patterns).
|
|
214
|
+
return [...urls].filter((url) => {
|
|
215
|
+
if (url.startsWith('<') || url.includes('/>')) return false;
|
|
216
|
+
return true;
|
|
217
|
+
});
|
|
198
218
|
}
|
|
199
219
|
|
|
200
220
|
export function replaceImageUrls(html: string, imageUrlMap: Record<string, string>): string {
|
|
@@ -326,7 +346,8 @@ async function resolvePhotoPayload(
|
|
|
326
346
|
}
|
|
327
347
|
|
|
328
348
|
if (photoUrl.startsWith("http://") || photoUrl.startsWith("https://")) {
|
|
329
|
-
const
|
|
349
|
+
const safeUrl = photoUrl.includes(" ") ? photoUrl.replace(/ /g, "%20") : photoUrl;
|
|
350
|
+
const response = await fetch(safeUrl);
|
|
330
351
|
if (!response.ok) {
|
|
331
352
|
throw new Error(`Failed to download image: HTTP ${response.status}`);
|
|
332
353
|
}
|
|
@@ -339,8 +360,9 @@ async function resolvePhotoPayload(
|
|
|
339
360
|
}
|
|
340
361
|
|
|
341
362
|
if (photoUrl.startsWith("file://")) {
|
|
342
|
-
const
|
|
343
|
-
|
|
363
|
+
const safeUrl = photoUrl.replace(/ /g, "%20");
|
|
364
|
+
const url = new URL(safeUrl);
|
|
365
|
+
return resolveFilePayload(decodeURIComponent(url.pathname), index);
|
|
344
366
|
}
|
|
345
367
|
|
|
346
368
|
return resolveFilePayload(photoUrl, index);
|
|
@@ -350,6 +372,14 @@ function resolveFilePayload(
|
|
|
350
372
|
filePath: string,
|
|
351
373
|
index: number,
|
|
352
374
|
): { blob: Blob; filename: string } {
|
|
375
|
+
// Detect HTML fragments incorrectly extracted as image URLs (defense-in-depth)
|
|
376
|
+
if (filePath.startsWith("<") && (filePath.includes("style=") || filePath.includes("</") || filePath.includes("/>"))) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`Image URL appears to be HTML content, not a valid file path or URL. ` +
|
|
379
|
+
`This usually means extractImageUrls matched unescaped HTML from a code block. ` +
|
|
380
|
+
`Got: ${filePath.slice(0, 120)}`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
353
383
|
if (!existsSync(filePath)) {
|
|
354
384
|
throw new Error(`File not found: ${filePath}`);
|
|
355
385
|
}
|
|
@@ -459,15 +489,21 @@ export async function createWechatDraft(input: WechatDraftInput): Promise<Record
|
|
|
459
489
|
const uploadResult = await uploadPhotos(runtime, accessToken, finalPhotos, uploadTimeout);
|
|
460
490
|
const replacedHtml = replaceImageUrls(input.html, uploadResult.imageUrlMap);
|
|
461
491
|
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
492
|
+
const isUpdate = !!input.existingDraftMediaId;
|
|
493
|
+
const draftPath = isUpdate ? DRAFT_UPDATE_PATH : DRAFT_ADD_PATH;
|
|
494
|
+
const draftBody = isUpdate
|
|
495
|
+
? {
|
|
496
|
+
access_token: accessToken,
|
|
497
|
+
media_id: input.existingDraftMediaId,
|
|
498
|
+
index: 0,
|
|
499
|
+
articles: {
|
|
500
|
+
article_type: "news",
|
|
501
|
+
title: input.title,
|
|
502
|
+
content: replacedHtml,
|
|
503
|
+
thumb_media_id: uploadResult.coverMediaId,
|
|
504
|
+
},
|
|
505
|
+
}
|
|
506
|
+
: {
|
|
471
507
|
access_token: accessToken,
|
|
472
508
|
articles: [
|
|
473
509
|
{
|
|
@@ -477,11 +513,34 @@ export async function createWechatDraft(input: WechatDraftInput): Promise<Record
|
|
|
477
513
|
thumb_media_id: uploadResult.coverMediaId,
|
|
478
514
|
},
|
|
479
515
|
],
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const response = await requestJson(
|
|
519
|
+
`${runtime.baseUrl}${draftPath}`,
|
|
520
|
+
{
|
|
521
|
+
method: "POST",
|
|
522
|
+
headers: {
|
|
523
|
+
"Content-Type": "application/json",
|
|
524
|
+
Authorization: `Bearer ${runtime.pat}`,
|
|
480
525
|
},
|
|
526
|
+
body: draftBody,
|
|
481
527
|
},
|
|
482
528
|
draftTimeout,
|
|
483
529
|
);
|
|
484
530
|
|
|
531
|
+
const draftMediaId = isUpdate ? input.existingDraftMediaId : (response as any)?.data?.media_id || (response as any)?.media_id || null;
|
|
532
|
+
|
|
533
|
+
// ── Callback to Nezus to persist media_id ──
|
|
534
|
+
if (draftMediaId && input.noteId && input.nezusBaseUrl && input.nezusPat) {
|
|
535
|
+
await notifyNezusPublishResult({
|
|
536
|
+
nezusBaseUrl: input.nezusBaseUrl,
|
|
537
|
+
nezusPat: input.nezusPat,
|
|
538
|
+
noteId: input.noteId,
|
|
539
|
+
wechatMediaId: draftMediaId,
|
|
540
|
+
publishType: "article",
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
485
544
|
return {
|
|
486
545
|
account: runtime.accountName,
|
|
487
546
|
articleType: "news",
|
|
@@ -489,6 +548,8 @@ export async function createWechatDraft(input: WechatDraftInput): Promise<Record
|
|
|
489
548
|
photosCount: finalPhotos.length,
|
|
490
549
|
totalUploaded: uploadResult.totalUploaded,
|
|
491
550
|
coverMediaId: uploadResult.coverMediaId,
|
|
551
|
+
isUpdate,
|
|
552
|
+
draftMediaId,
|
|
492
553
|
response,
|
|
493
554
|
};
|
|
494
555
|
}
|
|
@@ -509,18 +570,26 @@ export async function createWechatNewspic(input: WechatNewspicInput): Promise<Re
|
|
|
509
570
|
const uploadResult = await uploadPhotos(runtime, accessToken, finalPhotos, uploadTimeout);
|
|
510
571
|
|
|
511
572
|
const imageInfo = {
|
|
512
|
-
image_list: uploadResult.uploadedMedia
|
|
573
|
+
image_list: uploadResult.uploadedMedia
|
|
574
|
+
.map((item) => ({ image_media_id: item.mediaId })),
|
|
513
575
|
};
|
|
514
576
|
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
577
|
+
const isUpdate = !!input.existingDraftMediaId;
|
|
578
|
+
const draftPath = isUpdate ? DRAFT_UPDATE_PATH : DRAFT_ADD_PATH;
|
|
579
|
+
const draftBody = isUpdate
|
|
580
|
+
? {
|
|
581
|
+
access_token: accessToken,
|
|
582
|
+
media_id: input.existingDraftMediaId,
|
|
583
|
+
index: 0,
|
|
584
|
+
articles: {
|
|
585
|
+
article_type: "newspic",
|
|
586
|
+
title: input.title,
|
|
587
|
+
content: input.content,
|
|
588
|
+
thumb_media_id: uploadResult.coverMediaId,
|
|
589
|
+
image_info: imageInfo,
|
|
590
|
+
},
|
|
591
|
+
}
|
|
592
|
+
: {
|
|
524
593
|
access_token: accessToken,
|
|
525
594
|
articles: [
|
|
526
595
|
{
|
|
@@ -531,11 +600,34 @@ export async function createWechatNewspic(input: WechatNewspicInput): Promise<Re
|
|
|
531
600
|
image_info: imageInfo,
|
|
532
601
|
},
|
|
533
602
|
],
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const response = await requestJson(
|
|
606
|
+
`${runtime.baseUrl}${draftPath}`,
|
|
607
|
+
{
|
|
608
|
+
method: "POST",
|
|
609
|
+
headers: {
|
|
610
|
+
"Content-Type": "application/json",
|
|
611
|
+
Authorization: `Bearer ${runtime.pat}`,
|
|
534
612
|
},
|
|
613
|
+
body: draftBody,
|
|
535
614
|
},
|
|
536
615
|
draftTimeout,
|
|
537
616
|
);
|
|
538
617
|
|
|
618
|
+
const draftMediaId = isUpdate ? input.existingDraftMediaId : (response as any)?.data?.media_id || (response as any)?.media_id || null;
|
|
619
|
+
|
|
620
|
+
// ── Callback to Nezus to persist media_id ──
|
|
621
|
+
if (draftMediaId && input.noteId && input.nezusBaseUrl && input.nezusPat) {
|
|
622
|
+
await notifyNezusPublishResult({
|
|
623
|
+
nezusBaseUrl: input.nezusBaseUrl,
|
|
624
|
+
nezusPat: input.nezusPat,
|
|
625
|
+
noteId: input.noteId,
|
|
626
|
+
wechatMediaId: draftMediaId,
|
|
627
|
+
publishType: "newspic",
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
539
631
|
return {
|
|
540
632
|
account: runtime.accountName,
|
|
541
633
|
articleType: "newspic",
|
|
@@ -543,6 +635,146 @@ export async function createWechatNewspic(input: WechatNewspicInput): Promise<Re
|
|
|
543
635
|
photosCount: finalPhotos.length,
|
|
544
636
|
totalUploaded: uploadResult.totalUploaded,
|
|
545
637
|
coverMediaId: uploadResult.coverMediaId,
|
|
638
|
+
isUpdate,
|
|
639
|
+
draftMediaId,
|
|
640
|
+
response,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ── Nezus callback ────────────────────────────────────────────────
|
|
645
|
+
|
|
646
|
+
async function notifyNezusPublishResult(params: {
|
|
647
|
+
nezusBaseUrl: string;
|
|
648
|
+
nezusPat: string;
|
|
649
|
+
noteId: string;
|
|
650
|
+
wechatMediaId: string;
|
|
651
|
+
publishType: string;
|
|
652
|
+
}): Promise<void> {
|
|
653
|
+
try {
|
|
654
|
+
await requestJson(
|
|
655
|
+
`${params.nezusBaseUrl}/api/v1/notes/${params.noteId}/publish-result`,
|
|
656
|
+
{
|
|
657
|
+
method: "POST",
|
|
658
|
+
headers: {
|
|
659
|
+
"Content-Type": "application/json",
|
|
660
|
+
Authorization: `Bearer ${params.nezusPat}`,
|
|
661
|
+
},
|
|
662
|
+
body: {
|
|
663
|
+
wechatMediaId: params.wechatMediaId,
|
|
664
|
+
publishType: params.publishType,
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
10000,
|
|
668
|
+
);
|
|
669
|
+
console.error(`[nezus] publish-result callback OK for note ${params.noteId}`);
|
|
670
|
+
} catch (err) {
|
|
671
|
+
// Non-fatal: the draft was created successfully even if the callback failed
|
|
672
|
+
console.error(`[nezus] publish-result callback failed for note ${params.noteId}:`, err);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ── Draft management ──────────────────────────────────────────────
|
|
677
|
+
|
|
678
|
+
export interface WxDraftListInput {
|
|
679
|
+
account?: string;
|
|
680
|
+
limit?: number;
|
|
681
|
+
offset?: number;
|
|
682
|
+
config: PipelineConfig;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export interface WxDraftGetInput {
|
|
686
|
+
account?: string;
|
|
687
|
+
mediaId: string;
|
|
688
|
+
config: PipelineConfig;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export interface WxDraftDeleteInput {
|
|
692
|
+
account?: string;
|
|
693
|
+
mediaId: string;
|
|
694
|
+
config: PipelineConfig;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export async function getWxDraftList(input: WxDraftListInput): Promise<Record<string, unknown>> {
|
|
698
|
+
const runtime = getWxRuntimeConfig(input.config, input.account);
|
|
699
|
+
const tokenTimeout = resolveTimeout(TOKEN_TIMEOUT, runtime.timeout);
|
|
700
|
+
const accessToken = await fetchAccessToken(runtime, tokenTimeout);
|
|
701
|
+
|
|
702
|
+
const response = await requestJson(
|
|
703
|
+
`${runtime.baseUrl}${BATCH_GET_PATH}`,
|
|
704
|
+
{
|
|
705
|
+
method: "POST",
|
|
706
|
+
headers: {
|
|
707
|
+
"Content-Type": "application/json",
|
|
708
|
+
Authorization: `Bearer ${runtime.pat}`,
|
|
709
|
+
},
|
|
710
|
+
body: {
|
|
711
|
+
access_token: accessToken,
|
|
712
|
+
offset: input.offset ?? 0,
|
|
713
|
+
count: input.limit ?? 20,
|
|
714
|
+
no_content: true,
|
|
715
|
+
},
|
|
716
|
+
},
|
|
717
|
+
resolveTimeout(30000, runtime.timeout),
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
return {
|
|
721
|
+
account: runtime.accountName,
|
|
722
|
+
response,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export async function getWxDraft(input: WxDraftGetInput): Promise<Record<string, unknown>> {
|
|
727
|
+
const runtime = getWxRuntimeConfig(input.config, input.account);
|
|
728
|
+
const tokenTimeout = resolveTimeout(TOKEN_TIMEOUT, runtime.timeout);
|
|
729
|
+
const accessToken = await fetchAccessToken(runtime, tokenTimeout);
|
|
730
|
+
|
|
731
|
+
const response = await requestJson(
|
|
732
|
+
`${runtime.baseUrl}${DRAFT_GET_PATH}`,
|
|
733
|
+
{
|
|
734
|
+
method: "POST",
|
|
735
|
+
headers: {
|
|
736
|
+
"Content-Type": "application/json",
|
|
737
|
+
Authorization: `Bearer ${runtime.pat}`,
|
|
738
|
+
},
|
|
739
|
+
body: {
|
|
740
|
+
access_token: accessToken,
|
|
741
|
+
media_id: input.mediaId,
|
|
742
|
+
},
|
|
743
|
+
},
|
|
744
|
+
resolveTimeout(30000, runtime.timeout),
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
account: runtime.accountName,
|
|
749
|
+
mediaId: input.mediaId,
|
|
750
|
+
response,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
export async function deleteWxDraft(input: WxDraftDeleteInput): Promise<Record<string, unknown>> {
|
|
755
|
+
const runtime = getWxRuntimeConfig(input.config, input.account);
|
|
756
|
+
const tokenTimeout = resolveTimeout(TOKEN_TIMEOUT, runtime.timeout);
|
|
757
|
+
const accessToken = await fetchAccessToken(runtime, tokenTimeout);
|
|
758
|
+
|
|
759
|
+
const response = await requestJson(
|
|
760
|
+
`${runtime.baseUrl}${DRAFT_DELETE_PATH}`,
|
|
761
|
+
{
|
|
762
|
+
method: "POST",
|
|
763
|
+
headers: {
|
|
764
|
+
"Content-Type": "application/json",
|
|
765
|
+
Authorization: `Bearer ${runtime.pat}`,
|
|
766
|
+
},
|
|
767
|
+
body: {
|
|
768
|
+
access_token: accessToken,
|
|
769
|
+
media_id: input.mediaId,
|
|
770
|
+
},
|
|
771
|
+
},
|
|
772
|
+
resolveTimeout(30000, runtime.timeout),
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
return {
|
|
776
|
+
account: runtime.accountName,
|
|
777
|
+
mediaId: input.mediaId,
|
|
546
778
|
response,
|
|
547
779
|
};
|
|
548
780
|
}
|
package/src/routes.ts
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { AnalyticsSchema } from "./analytics";
|
|
3
|
+
|
|
4
|
+
describe("AnalyticsSchema", () => {
|
|
5
|
+
test("parses minimal analytics", () => {
|
|
6
|
+
const input = {
|
|
7
|
+
run_id: "run_001",
|
|
8
|
+
title: "Test Article",
|
|
9
|
+
publish_date: "2026-06-11",
|
|
10
|
+
recorded_at: "2026-06-11T10:00:00Z",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const result = AnalyticsSchema.parse(input);
|
|
14
|
+
expect(result.run_id).toBe("run_001");
|
|
15
|
+
expect(result.reads).toBe(0);
|
|
16
|
+
expect(result.likes).toBe(0);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("parses full analytics", () => {
|
|
20
|
+
const input = {
|
|
21
|
+
run_id: "run_002",
|
|
22
|
+
topic_id: "topic_001",
|
|
23
|
+
title: "AI Tools",
|
|
24
|
+
publish_date: "2026-06-11",
|
|
25
|
+
reads: 1500,
|
|
26
|
+
likes: 45,
|
|
27
|
+
favorites: 23,
|
|
28
|
+
shares: 12,
|
|
29
|
+
comments: 8,
|
|
30
|
+
notes: "Good performance",
|
|
31
|
+
recorded_at: "2026-06-11T10:00:00Z",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const result = AnalyticsSchema.parse(input);
|
|
35
|
+
expect(result.reads).toBe(1500);
|
|
36
|
+
expect(result.topic_id).toBe("topic_001");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("defaults metrics to 0", () => {
|
|
40
|
+
const input = {
|
|
41
|
+
run_id: "run_003",
|
|
42
|
+
title: "Test",
|
|
43
|
+
publish_date: "2026-06-11",
|
|
44
|
+
recorded_at: "2026-06-11T10:00:00Z",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const result = AnalyticsSchema.parse(input);
|
|
48
|
+
expect(result.reads).toBe(0);
|
|
49
|
+
expect(result.likes).toBe(0);
|
|
50
|
+
expect(result.favorites).toBe(0);
|
|
51
|
+
expect(result.shares).toBe(0);
|
|
52
|
+
expect(result.comments).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const AnalyticsSchema = z.object({
|
|
4
|
+
run_id: z.string(),
|
|
5
|
+
topic_id: z.string().nullable().default(null),
|
|
6
|
+
title: z.string(),
|
|
7
|
+
publish_date: z.string(),
|
|
8
|
+
|
|
9
|
+
reads: z.number().int().default(0),
|
|
10
|
+
likes: z.number().int().default(0),
|
|
11
|
+
favorites: z.number().int().default(0),
|
|
12
|
+
shares: z.number().int().default(0),
|
|
13
|
+
comments: z.number().int().default(0),
|
|
14
|
+
|
|
15
|
+
notes: z.string().nullable().default(null),
|
|
16
|
+
recorded_at: z.string(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type Analytics = z.infer<typeof AnalyticsSchema>;
|
package/src/schema/config.ts
CHANGED
|
@@ -32,11 +32,33 @@ function withObjectDefault<T extends z.ZodObject<any>>(schema: T) {
|
|
|
32
32
|
|
|
33
33
|
// ── Sub-schemas ───────────────────────────────────────────────────
|
|
34
34
|
|
|
35
|
+
const WechatExportThemeOverridesSchema = z.object({
|
|
36
|
+
containerStyle: z.string().optional(),
|
|
37
|
+
footerText: z.string().optional(),
|
|
38
|
+
footerStyle: z.string().optional(),
|
|
39
|
+
fontFamily: z.string().optional(),
|
|
40
|
+
bodyColor: z.string().optional(),
|
|
41
|
+
mutedColor: z.string().optional(),
|
|
42
|
+
primaryColor: z.string().optional(),
|
|
43
|
+
dividerColor: z.string().optional(),
|
|
44
|
+
blockquoteBorderColor: z.string().optional(),
|
|
45
|
+
bodyLineHeight: z.string().optional(),
|
|
46
|
+
bodyLetterSpacing: z.string().optional(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const WechatThemeOverridesSchema = withObjectDefault(
|
|
50
|
+
z.object({
|
|
51
|
+
editorVars: z.record(z.string(), z.string()).default({}),
|
|
52
|
+
exportTheme: withObjectDefault(WechatExportThemeOverridesSchema),
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
|
|
35
56
|
const WxAccountConfigSchema = z.object({
|
|
36
57
|
pat: z.string().default(""),
|
|
37
58
|
appId: z.string().default(""),
|
|
38
59
|
appSecret: z.string().default(""),
|
|
39
60
|
customCss: z.string().nullable().default(null),
|
|
61
|
+
theme: WechatThemeOverridesSchema,
|
|
40
62
|
});
|
|
41
63
|
|
|
42
64
|
const WxConfigSchema = withObjectDefault(
|
|
@@ -52,7 +74,7 @@ const WxConfigSchema = withObjectDefault(
|
|
|
52
74
|
defaultAccount: trimmedNonEmptyString("default"),
|
|
53
75
|
accounts: z
|
|
54
76
|
.record(z.string(), WxAccountConfigSchema)
|
|
55
|
-
.default({ default: { pat: "", appId: "", appSecret: "", customCss: null } }),
|
|
77
|
+
.default({ default: { pat: "", appId: "", appSecret: "", customCss: null, theme: { editorVars: {}, exportTheme: {} } } }),
|
|
56
78
|
}),
|
|
57
79
|
);
|
|
58
80
|
|
|
@@ -127,6 +149,8 @@ export type PipelineCommandConfig = z.infer<typeof PipelineCommandConfigSchema>;
|
|
|
127
149
|
export type CosConfig = z.infer<typeof CosConfigSchema>;
|
|
128
150
|
export type PluginsConfig = z.infer<typeof PluginsConfigSchema>;
|
|
129
151
|
export type ImgxConfig = z.infer<typeof ImgxConfigSchema>;
|
|
152
|
+
export type WechatExportThemeOverrides = z.infer<typeof WechatExportThemeOverridesSchema>;
|
|
153
|
+
export type WechatThemeOverrides = z.infer<typeof WechatThemeOverridesSchema>;
|
|
130
154
|
|
|
131
155
|
// ── Schema instances for reuse ────────────────────────────────────
|
|
132
156
|
|
package/src/schema/state.ts
CHANGED
|
@@ -260,6 +260,7 @@ const ImagesSchema = withObjectDefault(
|
|
|
260
260
|
|
|
261
261
|
const PublishResultSchema = z.object({
|
|
262
262
|
route: RoutePrimarySchema,
|
|
263
|
+
account: trimmedString("default"),
|
|
263
264
|
status: PublishResultStatusSchema,
|
|
264
265
|
detail: z.string().nullable().default(null),
|
|
265
266
|
published_at: z.string().nullable().default(null),
|
|
@@ -273,6 +274,11 @@ const PublishResultsSchema = withObjectDefault(
|
|
|
273
274
|
}),
|
|
274
275
|
);
|
|
275
276
|
|
|
277
|
+
const PublishTargetSchema = z.object({
|
|
278
|
+
route: RoutePrimarySchema,
|
|
279
|
+
account: trimmedString("default"),
|
|
280
|
+
});
|
|
281
|
+
|
|
276
282
|
const ContentReviewSchema = withObjectDefault(
|
|
277
283
|
z.object({
|
|
278
284
|
status: ContentReviewStatusSchema,
|
|
@@ -303,6 +309,8 @@ const IntentSchema = withObjectDefault(
|
|
|
303
309
|
style_hint: z.string().nullable().default(null),
|
|
304
310
|
newspic_render: NewspicRenderSpecSchema.nullable().default(null),
|
|
305
311
|
requires: IntentRequiresSchema,
|
|
312
|
+
existing_draft_media_id: z.string().nullable().default(null),
|
|
313
|
+
note_id: z.string().nullable().default(null),
|
|
306
314
|
}),
|
|
307
315
|
);
|
|
308
316
|
|
|
@@ -327,6 +335,7 @@ export const WorkflowStateSchema = z.object({
|
|
|
327
335
|
artifacts: ArtifactsSchema,
|
|
328
336
|
images: ImagesSchema,
|
|
329
337
|
publish: PublishResultsSchema,
|
|
338
|
+
publish_targets: z.array(PublishTargetSchema).default([]),
|
|
330
339
|
content_review: ContentReviewSchema,
|
|
331
340
|
redo_hint: z.string().nullable().default(null),
|
|
332
341
|
});
|
|
@@ -368,6 +377,7 @@ export type Images = z.infer<typeof ImagesSchema>;
|
|
|
368
377
|
export type PublishResultStatus = z.infer<typeof PublishResultStatusSchema>;
|
|
369
378
|
export type PublishResult = z.infer<typeof PublishResultSchema>;
|
|
370
379
|
export type PublishResults = z.infer<typeof PublishResultsSchema>;
|
|
380
|
+
export type PublishTarget = z.infer<typeof PublishTargetSchema>;
|
|
371
381
|
export type ContentReviewStatus = z.infer<typeof ContentReviewStatusSchema>;
|
|
372
382
|
export type ContentReview = z.infer<typeof ContentReviewSchema>;
|
|
373
383
|
export type HandoffResearchPolicy = z.infer<typeof HandoffResearchPolicySchema>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { TopicSchema } from "./topic";
|
|
3
|
+
|
|
4
|
+
describe("TopicSchema", () => {
|
|
5
|
+
test("parses minimal topic", () => {
|
|
6
|
+
const input = {
|
|
7
|
+
topic_id: "topic_001",
|
|
8
|
+
title: "Test Topic",
|
|
9
|
+
created_at: "2026-06-11T00:00:00Z",
|
|
10
|
+
updated_at: "2026-06-11T00:00:00Z",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const result = TopicSchema.parse(input);
|
|
14
|
+
expect(result.topic_id).toBe("topic_001");
|
|
15
|
+
expect(result.status).toBe("backlog");
|
|
16
|
+
expect(result.priority).toBe("medium");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("parses full topic", () => {
|
|
20
|
+
const input = {
|
|
21
|
+
topic_id: "topic_002",
|
|
22
|
+
title: "AI Tools",
|
|
23
|
+
description: "About AI tools",
|
|
24
|
+
priority: "high",
|
|
25
|
+
tags: ["AI", "tools"],
|
|
26
|
+
notes: "Some notes",
|
|
27
|
+
status: "scheduled",
|
|
28
|
+
ai_score: 85,
|
|
29
|
+
ai_reason: "Good topic",
|
|
30
|
+
scheduled_date: "2026-06-15",
|
|
31
|
+
target_account: "default",
|
|
32
|
+
run_id: "run_001",
|
|
33
|
+
retro_performance: "good",
|
|
34
|
+
retro_lessons: "Learned something",
|
|
35
|
+
retro_metrics_snapshot: { reads: 1500 },
|
|
36
|
+
created_at: "2026-06-11T00:00:00Z",
|
|
37
|
+
updated_at: "2026-06-11T00:00:00Z",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const result = TopicSchema.parse(input);
|
|
41
|
+
expect(result.priority).toBe("high");
|
|
42
|
+
expect(result.tags).toEqual(["AI", "tools"]);
|
|
43
|
+
expect(result.ai_score).toBe(85);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("rejects invalid priority", () => {
|
|
47
|
+
const input = {
|
|
48
|
+
topic_id: "topic_003",
|
|
49
|
+
title: "Test",
|
|
50
|
+
priority: "urgent",
|
|
51
|
+
created_at: "2026-06-11T00:00:00Z",
|
|
52
|
+
updated_at: "2026-06-11T00:00:00Z",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
expect(() => TopicSchema.parse(input)).toThrow();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("rejects invalid ai_score", () => {
|
|
59
|
+
const input = {
|
|
60
|
+
topic_id: "topic_004",
|
|
61
|
+
title: "Test",
|
|
62
|
+
ai_score: 150,
|
|
63
|
+
created_at: "2026-06-11T00:00:00Z",
|
|
64
|
+
updated_at: "2026-06-11T00:00:00Z",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
expect(() => TopicSchema.parse(input)).toThrow();
|
|
68
|
+
});
|
|
69
|
+
});
|