@gobing-ai/knowledge-kit 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/board/collect/index.js +725 -1101
- package/dist/board/daily-voice/index.js +722 -101
- package/dist/board/itc/index.js +13 -0
- package/dist/index.js +1796 -102
- package/package.json +1 -1
- package/plugins/generations/content-gen/dist/index.js +4 -0
- package/plugins/generations/core-facts-gen/dist/index.js +4 -0
- package/plugins/generations/daily-article-gen/dist/index.js +4 -0
- package/plugins/generations/dailynews-gen/dist/index.js +4 -0
- package/plugins/generations/episode-plan-gen/dist/index.js +102 -1
- package/plugins/generations/episode-plan-gen/src/index.ts +2 -4
- package/plugins/generations/gemini-tts/dist/index.js +4 -0
- package/plugins/generations/image-gen/dist/index.js +4 -0
- package/plugins/generations/news-report-gen/dist/index.js +4 -0
- package/plugins/generations/voice-gen/dist/index.js +4 -0
- package/plugins/generations/voxcpm-tts/dist/index.js +4 -0
- package/plugins/ingestions/aihot-ingest/dist/index.js +4 -0
- package/plugins/ingestions/horizon-ingest/dist/index.js +28 -1
- package/plugins/ingestions/horizon-ingest/src/index.ts +34 -0
- package/plugins/ingestions/last30days-ingest/dist/index.js +11 -3
- package/plugins/ingestions/last30days-ingest/src/index.ts +14 -3
- package/plugins/ingestions/web-search/dist/index.js +4 -0
- package/plugins/kk/config.example.yaml +8 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/publish-presets/daily-ai-voice.json +123 -0
- package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +20 -0
- package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +17 -0
- package/plugins/kk/scripts/daily-stages.ts +665 -78
- package/plugins/kk/scripts/kk-workflow-stages.ts +11 -1
- package/plugins/kk/scripts/sentences.ts +134 -0
- package/plugins/kk/scripts/series-names.ts +124 -0
- package/plugins/kk/scripts/topic-boundary.json +18 -83
- package/plugins/kk/workflows/kk-collect.yaml +2 -0
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +112 -9
- package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +842 -0
- package/plugins/kk/workflows/kk-daily-publish3.yaml +282 -0
- package/plugins/kk/workflows/kk-daily-safety.yaml +50 -5
- package/plugins/publishings/bilibili-pub/dist/index.js +4 -0
- package/plugins/publishings/cnblogs-pub/dist/index.js +4 -0
- package/plugins/publishings/csdn-pub/dist/index.js +4 -0
- package/plugins/publishings/csdn-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/emdash-pub/dist/index.js +4 -0
- package/plugins/publishings/infoq-pub/dist/index.js +4 -0
- package/plugins/publishings/infoq-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/jike-pub/dist/index.js +4 -0
- package/plugins/publishings/jike-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/juejin-pub/dist/index.js +4 -0
- package/plugins/publishings/juejin-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/medium-pub/dist/index.js +4 -0
- package/plugins/publishings/podcast-pub/README.md +65 -0
- package/plugins/publishings/podcast-pub/dist/index.js +1906 -374
- package/plugins/publishings/podcast-pub/plugin.json +1 -1
- package/plugins/publishings/podcast-pub/src/backfill.ts +465 -0
- package/plugins/publishings/podcast-pub/src/index.ts +211 -34
- package/plugins/publishings/podcast-pub/src/microfeed-client.ts +32 -3
- package/plugins/publishings/podcast-pub/src/seo.ts +1079 -0
- package/plugins/publishings/podcast-pub/src/show-notes.ts +74 -14
- package/plugins/publishings/qiita-pub/dist/index.js +4 -0
- package/plugins/publishings/shipinhao-pub/dist/index.js +4 -0
- package/plugins/publishings/sspai-pub/dist/index.js +4 -0
- package/plugins/publishings/substack-pub/dist/index.js +4 -0
- package/plugins/publishings/substack-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +313 -32
- package/plugins/publishings/surfdash-pub/src/digest-check.ts +218 -0
- package/plugins/publishings/surfdash-pub/src/index.ts +170 -39
- package/plugins/publishings/surfdash-pub/src/patch.ts +44 -7
- package/plugins/publishings/threads-pub/dist/index.js +4 -0
- package/plugins/publishings/wechatmp-pub/dist/index.js +4 -0
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/weibo-pub/dist/index.js +4 -0
- package/plugins/publishings/weibo-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/x-pub/dist/index.js +4 -0
- package/plugins/publishings/x-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/xhs-pub/dist/index.js +4 -0
- package/plugins/publishings/xhs-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +4 -0
- package/plugins/publishings/zenn-pub/dist/index.js +4 -0
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-episode SEO for microfeed 1.0.13 (design D9).
|
|
3
|
+
*
|
|
4
|
+
* Pure helpers: title pattern, `_microfeed.seo`, iTunes numbering, slug checks,
|
|
5
|
+
* title sanitising, and show-notes summary text. Network stays in `index.ts`.
|
|
6
|
+
* Lengths are Unicode code points, matching microfeed's slug rule.
|
|
7
|
+
*
|
|
8
|
+
* The one IO here is a binary PNG/JPEG header probe (`readLocalBytes`); the
|
|
9
|
+
* ts-runtime FileSystem seam is text-only, so `node:fs` is allowlisted for this
|
|
10
|
+
* file in `.spur/rules/strict/runtime-boundaries.yaml` (same class as
|
|
11
|
+
* `podcast-pub/src/index.ts`). Diagnostics go through ts-utils `echoError`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { splitSentences } from '@gobing-ai/kk-core';
|
|
16
|
+
import { echoError } from '@gobing-ai/ts-utils';
|
|
17
|
+
|
|
18
|
+
/** Code-point length. Microfeed counts the same way for slugs and SEO limits. */
|
|
19
|
+
export function codePointLength(value: string): number {
|
|
20
|
+
return Array.from(value).length;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const WARN_PREFIX = 'WARN podcast-pub:';
|
|
24
|
+
|
|
25
|
+
/** Prints a stable stderr line and records it for result metadata. */
|
|
26
|
+
export function warn(message: string, sink?: string[]): void {
|
|
27
|
+
const line = message.startsWith(WARN_PREFIX) ? message : `${WARN_PREFIX} ${message}`;
|
|
28
|
+
echoError(line);
|
|
29
|
+
sink?.push(line);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type TitleMode = 'as-is' | 'headline';
|
|
33
|
+
export type SanitizeMode = 'normalize' | 'reject';
|
|
34
|
+
export type LinkField = 'none' | 'url';
|
|
35
|
+
export type SlugMode = 'auto' | 'pattern';
|
|
36
|
+
export type DescriptionSource = 'plan' | 'surfing';
|
|
37
|
+
export type TimelineStyle = 'after' | 'before' | 'omit';
|
|
38
|
+
|
|
39
|
+
export interface PodcastSeoConfig {
|
|
40
|
+
seo: boolean;
|
|
41
|
+
titleMode: TitleMode;
|
|
42
|
+
titlePattern: string;
|
|
43
|
+
seriesName: string;
|
|
44
|
+
showName: string;
|
|
45
|
+
titleMax: number;
|
|
46
|
+
titleMaxKeywords: number;
|
|
47
|
+
seoTitleSuffix: string;
|
|
48
|
+
seoTitleMax: number;
|
|
49
|
+
descriptionMin: number;
|
|
50
|
+
descriptionMax: number;
|
|
51
|
+
descriptionSource: DescriptionSource;
|
|
52
|
+
descriptionPattern: string;
|
|
53
|
+
summaryParagraph: boolean;
|
|
54
|
+
timelineStyle: TimelineStyle;
|
|
55
|
+
autoEpisodeNumber: boolean;
|
|
56
|
+
titleSanitize: SanitizeMode;
|
|
57
|
+
linkField: LinkField;
|
|
58
|
+
slugMode: SlugMode;
|
|
59
|
+
slugPattern: string;
|
|
60
|
+
socialImage: boolean;
|
|
61
|
+
socialImageAltPattern: string;
|
|
62
|
+
roundDuration: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Plugin defaults. Robin (2026-10-23): middle-dot title pattern, SEO and summary on. */
|
|
66
|
+
export const SEO_DEFAULTS: PodcastSeoConfig = {
|
|
67
|
+
seo: true,
|
|
68
|
+
titleMode: 'as-is',
|
|
69
|
+
titlePattern: '{series} · {date}:{keywords}',
|
|
70
|
+
seriesName: '每日AI乱炖',
|
|
71
|
+
showName: '咸话咸说',
|
|
72
|
+
titleMax: 55,
|
|
73
|
+
titleMaxKeywords: 2,
|
|
74
|
+
seoTitleSuffix: '|咸话咸说',
|
|
75
|
+
seoTitleMax: 60,
|
|
76
|
+
descriptionMin: 120,
|
|
77
|
+
descriptionMax: 155,
|
|
78
|
+
descriptionSource: 'plan',
|
|
79
|
+
descriptionPattern: '{show}·{series} {date}(约 {minutes} 分钟):{clauses}。',
|
|
80
|
+
summaryParagraph: true,
|
|
81
|
+
timelineStyle: 'after',
|
|
82
|
+
autoEpisodeNumber: true,
|
|
83
|
+
titleSanitize: 'normalize',
|
|
84
|
+
linkField: 'none',
|
|
85
|
+
slugMode: 'pattern',
|
|
86
|
+
slugPattern: 'daily-ai-{date}',
|
|
87
|
+
socialImage: false,
|
|
88
|
+
socialImageAltPattern: '{series} {date} 封面:{keywords}',
|
|
89
|
+
roundDuration: true,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export interface PlanItem {
|
|
93
|
+
title: string;
|
|
94
|
+
brief?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface SeoBlock {
|
|
98
|
+
title?: string;
|
|
99
|
+
description?: string;
|
|
100
|
+
social_image?: {
|
|
101
|
+
url: string;
|
|
102
|
+
width: 1200;
|
|
103
|
+
height: 630;
|
|
104
|
+
mime_type: 'image/jpeg' | 'image/png';
|
|
105
|
+
alt: string;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface PreparedEpisodeFields {
|
|
110
|
+
title: string;
|
|
111
|
+
itunes?: {
|
|
112
|
+
title?: string;
|
|
113
|
+
episodeType?: 'full' | 'trailer' | 'bonus';
|
|
114
|
+
season?: number;
|
|
115
|
+
episode?: number;
|
|
116
|
+
explicit?: boolean;
|
|
117
|
+
block?: boolean;
|
|
118
|
+
};
|
|
119
|
+
/** Attachment duration. Rounded when `PODCAST_PUB_ROUND_DURATION` is on. */
|
|
120
|
+
durationSeconds?: number;
|
|
121
|
+
/** Set only when `PODCAST_PUB_LINK_FIELD=url`. */
|
|
122
|
+
link?: string;
|
|
123
|
+
seo?: SeoBlock;
|
|
124
|
+
/** Custom slug for the first createItem. Absent in `auto` mode or without a date. */
|
|
125
|
+
slug?: string;
|
|
126
|
+
warnings: string[];
|
|
127
|
+
/** Alt text for a social image. Present only when a candidate image was accepted or still needs a remote size check. */
|
|
128
|
+
socialAlt?: string;
|
|
129
|
+
/** Local 1200×630 image still to upload. */
|
|
130
|
+
socialLocalPath?: string;
|
|
131
|
+
socialMime?: 'image/jpeg' | 'image/png';
|
|
132
|
+
/** Remote URL whose bytes still need a 1200×630 check before it is sent. */
|
|
133
|
+
socialRemoteUrl?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const ENV_KEYS = {
|
|
137
|
+
seo: 'PODCAST_PUB_SEO',
|
|
138
|
+
titleMode: 'PODCAST_PUB_EPISODE_TITLE_MODE',
|
|
139
|
+
titlePattern: 'PODCAST_PUB_EPISODE_TITLE_PATTERN',
|
|
140
|
+
seriesName: 'PODCAST_PUB_SERIES_NAME',
|
|
141
|
+
showName: 'PODCAST_PUB_SHOW_NAME',
|
|
142
|
+
titleMax: 'PODCAST_PUB_TITLE_MAX',
|
|
143
|
+
titleMaxKeywords: 'PODCAST_PUB_TITLE_MAX_KEYWORDS',
|
|
144
|
+
seoTitleSuffix: 'PODCAST_PUB_SEO_TITLE_SUFFIX',
|
|
145
|
+
seoTitleMax: 'PODCAST_PUB_SEO_TITLE_MAX',
|
|
146
|
+
descriptionMin: 'PODCAST_PUB_SEO_DESCRIPTION_MIN',
|
|
147
|
+
descriptionMax: 'PODCAST_PUB_SEO_DESCRIPTION_MAX',
|
|
148
|
+
descriptionSource: 'PODCAST_PUB_SEO_DESCRIPTION_SOURCE',
|
|
149
|
+
descriptionPattern: 'PODCAST_PUB_SEO_DESCRIPTION_PATTERN',
|
|
150
|
+
summaryParagraph: 'PODCAST_PUB_SUMMARY_PARAGRAPH',
|
|
151
|
+
timelineStyle: 'PODCAST_PUB_TIMELINE_INLINE_STYLE',
|
|
152
|
+
autoEpisodeNumber: 'PODCAST_PUB_AUTO_EPISODE_NUMBER',
|
|
153
|
+
titleSanitize: 'PODCAST_PUB_TITLE_SANITIZE',
|
|
154
|
+
linkField: 'PODCAST_PUB_LINK_FIELD',
|
|
155
|
+
slugMode: 'PODCAST_PUB_SLUG',
|
|
156
|
+
slugPattern: 'PODCAST_PUB_SLUG_PATTERN',
|
|
157
|
+
socialImage: 'PODCAST_PUB_SOCIAL_IMAGE',
|
|
158
|
+
socialImageAltPattern: 'PODCAST_PUB_SOCIAL_IMAGE_ALT_PATTERN',
|
|
159
|
+
roundDuration: 'PODCAST_PUB_ROUND_DURATION',
|
|
160
|
+
} as const;
|
|
161
|
+
|
|
162
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
163
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
164
|
+
return value as Record<string, unknown>;
|
|
165
|
+
}
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function podcastOf(metadata: Record<string, unknown> | undefined): Record<string, unknown> {
|
|
170
|
+
return asRecord(metadata?.podcast) ?? {};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function pick(
|
|
174
|
+
metadata: Record<string, unknown> | undefined,
|
|
175
|
+
podcastKey: string,
|
|
176
|
+
envKey: string,
|
|
177
|
+
env: NodeJS.ProcessEnv,
|
|
178
|
+
): { value: unknown; key: string } | undefined {
|
|
179
|
+
const podcast = podcastOf(metadata);
|
|
180
|
+
if (Object.hasOwn(podcast, podcastKey)) {
|
|
181
|
+
const value = podcast[podcastKey];
|
|
182
|
+
if (value !== undefined && value !== '') return { value, key: `metadata.podcast.${podcastKey}` };
|
|
183
|
+
}
|
|
184
|
+
const fromEnv = env[envKey];
|
|
185
|
+
if (fromEnv !== undefined && fromEnv !== '') return { value: fromEnv, key: envKey };
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function invalid(key: string, value: unknown, expected: string): string {
|
|
190
|
+
return `invalid ${key} "${String(value)}": expected ${expected}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseBool(raw: { value: unknown; key: string } | undefined, fallback: boolean): boolean | string {
|
|
194
|
+
if (!raw) return fallback;
|
|
195
|
+
const value = raw.value;
|
|
196
|
+
if (value === true || value === 'true' || value === '1') return true;
|
|
197
|
+
if (value === false || value === 'false' || value === '0') return false;
|
|
198
|
+
return invalid(raw.key, value, 'true or false');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parseIntIn(raw: { value: unknown; key: string } | undefined, fallback: number, min: number): number | string {
|
|
202
|
+
if (!raw) return fallback;
|
|
203
|
+
const value = typeof raw.value === 'number' ? raw.value : Number(raw.value);
|
|
204
|
+
if (!Number.isInteger(value) || value < min) return invalid(raw.key, raw.value, `an integer ≥ ${min}`);
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function parseEnum<T extends string>(
|
|
209
|
+
raw: { value: unknown; key: string } | undefined,
|
|
210
|
+
allowed: readonly T[],
|
|
211
|
+
fallback: T,
|
|
212
|
+
): T | string {
|
|
213
|
+
if (!raw) return fallback;
|
|
214
|
+
const value = String(raw.value);
|
|
215
|
+
if ((allowed as readonly string[]).includes(value)) return value as T;
|
|
216
|
+
return invalid(raw.key, raw.value, allowed.join(' or '));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Env, then `metadata.podcast.*` (the item wins). Unknown values name the key and fail. */
|
|
220
|
+
export function resolvePodcastSeoConfig(
|
|
221
|
+
metadata: Record<string, unknown> | undefined,
|
|
222
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
223
|
+
): { config: PodcastSeoConfig } | { error: string } {
|
|
224
|
+
const bool = (podcastKey: string, envKey: string, fallback: boolean): boolean | string =>
|
|
225
|
+
parseBool(pick(metadata, podcastKey, envKey, env), fallback);
|
|
226
|
+
const int = (podcastKey: string, envKey: string, fallback: number, min: number): number | string =>
|
|
227
|
+
parseIntIn(pick(metadata, podcastKey, envKey, env), fallback, min);
|
|
228
|
+
const text = (podcastKey: string, envKey: string, fallback: string): string => {
|
|
229
|
+
const raw = pick(metadata, podcastKey, envKey, env);
|
|
230
|
+
return raw ? String(raw.value) : fallback;
|
|
231
|
+
};
|
|
232
|
+
const seo = bool('seo', ENV_KEYS.seo, SEO_DEFAULTS.seo);
|
|
233
|
+
const titleMode = parseEnum(
|
|
234
|
+
pick(metadata, 'titleMode', ENV_KEYS.titleMode, env),
|
|
235
|
+
['as-is', 'headline'] as const,
|
|
236
|
+
SEO_DEFAULTS.titleMode,
|
|
237
|
+
);
|
|
238
|
+
const titleMax = int('titleMax', ENV_KEYS.titleMax, SEO_DEFAULTS.titleMax, 1);
|
|
239
|
+
const titleMaxKeywords = int('titleMaxKeywords', ENV_KEYS.titleMaxKeywords, SEO_DEFAULTS.titleMaxKeywords, 1);
|
|
240
|
+
const seoTitleMax = int('seoTitleMax', ENV_KEYS.seoTitleMax, SEO_DEFAULTS.seoTitleMax, 1);
|
|
241
|
+
const descriptionMin = int('descriptionMin', ENV_KEYS.descriptionMin, SEO_DEFAULTS.descriptionMin, 1);
|
|
242
|
+
const descriptionMax = int('descriptionMax', ENV_KEYS.descriptionMax, SEO_DEFAULTS.descriptionMax, 1);
|
|
243
|
+
const descriptionSource = parseEnum(
|
|
244
|
+
pick(metadata, 'descriptionSource', ENV_KEYS.descriptionSource, env),
|
|
245
|
+
['plan', 'surfing'] as const,
|
|
246
|
+
SEO_DEFAULTS.descriptionSource,
|
|
247
|
+
);
|
|
248
|
+
const summaryParagraph = bool('summaryParagraph', ENV_KEYS.summaryParagraph, SEO_DEFAULTS.summaryParagraph);
|
|
249
|
+
const timelineStyle = parseEnum(
|
|
250
|
+
pick(metadata, 'timelineInlineStyle', ENV_KEYS.timelineStyle, env),
|
|
251
|
+
['after', 'before', 'omit', 'true', 'false'] as const,
|
|
252
|
+
'after',
|
|
253
|
+
);
|
|
254
|
+
const autoEpisodeNumber = bool('autoEpisodeNumber', ENV_KEYS.autoEpisodeNumber, SEO_DEFAULTS.autoEpisodeNumber);
|
|
255
|
+
const titleSanitize = parseEnum(
|
|
256
|
+
pick(metadata, 'titleSanitize', ENV_KEYS.titleSanitize, env),
|
|
257
|
+
['normalize', 'reject'] as const,
|
|
258
|
+
SEO_DEFAULTS.titleSanitize,
|
|
259
|
+
);
|
|
260
|
+
const linkField = parseEnum(
|
|
261
|
+
pick(metadata, 'linkField', ENV_KEYS.linkField, env),
|
|
262
|
+
['none', 'url'] as const,
|
|
263
|
+
SEO_DEFAULTS.linkField,
|
|
264
|
+
);
|
|
265
|
+
const slugMode = parseEnum(
|
|
266
|
+
pick(metadata, 'slug', ENV_KEYS.slugMode, env),
|
|
267
|
+
['auto', 'pattern'] as const,
|
|
268
|
+
SEO_DEFAULTS.slugMode,
|
|
269
|
+
);
|
|
270
|
+
const socialImage = bool('socialImageEnabled', ENV_KEYS.socialImage, SEO_DEFAULTS.socialImage);
|
|
271
|
+
const roundDuration = bool('roundDuration', ENV_KEYS.roundDuration, SEO_DEFAULTS.roundDuration);
|
|
272
|
+
const parsed = [
|
|
273
|
+
seo,
|
|
274
|
+
titleMode,
|
|
275
|
+
titleMax,
|
|
276
|
+
titleMaxKeywords,
|
|
277
|
+
seoTitleMax,
|
|
278
|
+
descriptionMin,
|
|
279
|
+
descriptionMax,
|
|
280
|
+
descriptionSource,
|
|
281
|
+
summaryParagraph,
|
|
282
|
+
timelineStyle,
|
|
283
|
+
autoEpisodeNumber,
|
|
284
|
+
titleSanitize,
|
|
285
|
+
linkField,
|
|
286
|
+
slugMode,
|
|
287
|
+
socialImage,
|
|
288
|
+
roundDuration,
|
|
289
|
+
];
|
|
290
|
+
const failure = parsed.find((value) => typeof value === 'string' && value.startsWith('invalid '));
|
|
291
|
+
if (typeof failure === 'string') return { error: failure };
|
|
292
|
+
if ((descriptionMin as number) > (descriptionMax as number)) {
|
|
293
|
+
return {
|
|
294
|
+
error: `invalid ${ENV_KEYS.descriptionMin}: ${String(descriptionMin)} is greater than ${ENV_KEYS.descriptionMax} ${String(descriptionMax)}`,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const seoTitleSuffix = text('seoTitleSuffix', ENV_KEYS.seoTitleSuffix, SEO_DEFAULTS.seoTitleSuffix);
|
|
298
|
+
if (codePointLength(seoTitleSuffix) >= (seoTitleMax as number)) {
|
|
299
|
+
return {
|
|
300
|
+
error: `invalid ${ENV_KEYS.seoTitleSuffix}: suffix is longer than ${ENV_KEYS.seoTitleMax} (${String(seoTitleMax)})`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const styleRaw = timelineStyle as string;
|
|
304
|
+
const timeline: TimelineStyle =
|
|
305
|
+
styleRaw === 'true' || styleRaw === 'after'
|
|
306
|
+
? 'after'
|
|
307
|
+
: styleRaw === 'false' || styleRaw === 'omit'
|
|
308
|
+
? 'omit'
|
|
309
|
+
: 'before';
|
|
310
|
+
return {
|
|
311
|
+
config: {
|
|
312
|
+
seo: seo as boolean,
|
|
313
|
+
titleMode: titleMode as TitleMode,
|
|
314
|
+
titlePattern: text('titlePattern', ENV_KEYS.titlePattern, SEO_DEFAULTS.titlePattern),
|
|
315
|
+
seriesName: text('seriesName', ENV_KEYS.seriesName, SEO_DEFAULTS.seriesName),
|
|
316
|
+
showName: text('showName', ENV_KEYS.showName, SEO_DEFAULTS.showName),
|
|
317
|
+
titleMax: titleMax as number,
|
|
318
|
+
titleMaxKeywords: titleMaxKeywords as number,
|
|
319
|
+
seoTitleSuffix,
|
|
320
|
+
seoTitleMax: seoTitleMax as number,
|
|
321
|
+
descriptionMin: descriptionMin as number,
|
|
322
|
+
descriptionMax: descriptionMax as number,
|
|
323
|
+
descriptionSource: descriptionSource as DescriptionSource,
|
|
324
|
+
descriptionPattern: text(
|
|
325
|
+
'descriptionPattern',
|
|
326
|
+
ENV_KEYS.descriptionPattern,
|
|
327
|
+
SEO_DEFAULTS.descriptionPattern,
|
|
328
|
+
),
|
|
329
|
+
summaryParagraph: summaryParagraph as boolean,
|
|
330
|
+
timelineStyle: timeline,
|
|
331
|
+
autoEpisodeNumber: autoEpisodeNumber as boolean,
|
|
332
|
+
titleSanitize: titleSanitize as SanitizeMode,
|
|
333
|
+
linkField: linkField as LinkField,
|
|
334
|
+
slugMode: slugMode as SlugMode,
|
|
335
|
+
slugPattern: text('slugPattern', ENV_KEYS.slugPattern, SEO_DEFAULTS.slugPattern),
|
|
336
|
+
socialImage: socialImage as boolean,
|
|
337
|
+
socialImageAltPattern: text(
|
|
338
|
+
'socialImageAltPattern',
|
|
339
|
+
ENV_KEYS.socialImageAltPattern,
|
|
340
|
+
SEO_DEFAULTS.socialImageAltPattern,
|
|
341
|
+
),
|
|
342
|
+
roundDuration: roundDuration as boolean,
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function hasControlChar(value: string): boolean {
|
|
348
|
+
for (const ch of value) {
|
|
349
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
350
|
+
if (cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f) || cp === 0x2028 || cp === 0x2029) return true;
|
|
351
|
+
}
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Characters that would break the theme's unescaped JSON-LD string. */
|
|
356
|
+
function forbiddenChars(value: string): string[] {
|
|
357
|
+
const found: string[] = [];
|
|
358
|
+
if (value.includes('"')) found.push('"');
|
|
359
|
+
if (value.includes('\\')) found.push('\\');
|
|
360
|
+
if (value.includes('<')) found.push('<');
|
|
361
|
+
if (value.includes('>')) found.push('>');
|
|
362
|
+
if (hasControlChar(value)) found.push('control');
|
|
363
|
+
return found;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* `"` becomes paired “ ”, a lone quote becomes ”, `\` becomes \, `<>` become 〈〉,
|
|
368
|
+
* and C0/C1 plus U+2028/U+2029 are removed. `reject` names the offending characters.
|
|
369
|
+
*/
|
|
370
|
+
export function sanitizeTitle(
|
|
371
|
+
value: string,
|
|
372
|
+
mode: SanitizeMode,
|
|
373
|
+
): { ok: true; value: string } | { ok: false; error: string } {
|
|
374
|
+
const forbidden = forbiddenChars(value);
|
|
375
|
+
if (mode === 'reject' && forbidden.length > 0) {
|
|
376
|
+
return { ok: false, error: `title contains forbidden characters: ${forbidden.join(', ')}` };
|
|
377
|
+
}
|
|
378
|
+
const chars = Array.from(value);
|
|
379
|
+
const quoteAt: number[] = [];
|
|
380
|
+
chars.forEach((ch, index) => {
|
|
381
|
+
if (ch === '"') quoteAt.push(index);
|
|
382
|
+
});
|
|
383
|
+
const odd = quoteAt.length % 2 === 1;
|
|
384
|
+
for (let n = 0; n < quoteAt.length; n++) {
|
|
385
|
+
const index = quoteAt[n] ?? 0;
|
|
386
|
+
const last = n === quoteAt.length - 1;
|
|
387
|
+
chars[index] = odd && last ? '”' : n % 2 === 0 ? '“' : '”';
|
|
388
|
+
}
|
|
389
|
+
let out = '';
|
|
390
|
+
for (const ch of chars) {
|
|
391
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
392
|
+
if (cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f) || cp === 0x2028 || cp === 0x2029) continue;
|
|
393
|
+
if (ch === '\\') out += '\';
|
|
394
|
+
else if (ch === '<') out += '〈';
|
|
395
|
+
else if (ch === '>') out += '〉';
|
|
396
|
+
else out += ch;
|
|
397
|
+
}
|
|
398
|
+
return { ok: true, value: out.trim() };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function spaceCjkLatin(text: string): string {
|
|
402
|
+
return text.replace(/(\p{Script=Han})([A-Za-z0-9])/gu, '$1 $2').replace(/([A-Za-z0-9])(\p{Script=Han})/gu, '$1 $2');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Legacy series names, a duplicated trailing date, whitespace, and CJK/Latin spacing outside the series token. */
|
|
406
|
+
export function normalizeEpisodeTitle(title: string, seriesName: string): string {
|
|
407
|
+
let out = title.replace(/\s+/g, ' ').trim();
|
|
408
|
+
const legacy = /^(?:每日 AI 资讯|每日AI资讯|AI 乱炖|AI乱炖)/;
|
|
409
|
+
if (legacy.test(out)) out = out.replace(legacy, seriesName);
|
|
410
|
+
const trailing = /\s·\s*(\d{4}-\d{2}-\d{2})\s*$/;
|
|
411
|
+
const trail = trailing.exec(out);
|
|
412
|
+
if (trail?.[1] && out.slice(0, trail.index).includes(trail[1])) out = out.slice(0, trail.index).trim();
|
|
413
|
+
if (seriesName.length > 0 && out.startsWith(seriesName)) {
|
|
414
|
+
let rest = spaceCjkLatin(out.slice(seriesName.length));
|
|
415
|
+
const seriesEndsCjk = /\p{Script=Han}$/u.test(seriesName);
|
|
416
|
+
if (seriesEndsCjk && /^[A-Za-z0-9]/.test(rest)) rest = ` ${rest}`;
|
|
417
|
+
out = seriesName + rest;
|
|
418
|
+
} else {
|
|
419
|
+
out = spaceCjkLatin(out);
|
|
420
|
+
}
|
|
421
|
+
return out.replace(/\s+/g, ' ').trim();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function isRealDate(year: number, month: number, day: number): boolean {
|
|
425
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) return false;
|
|
426
|
+
const utc = new Date(Date.UTC(year, month - 1, day));
|
|
427
|
+
return utc.getUTCFullYear() === year && utc.getUTCMonth() === month - 1 && utc.getUTCDate() === day;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** `YYYYMMDD` or `YYYY-MM-DD` (extra time text is ignored) → ISO date, or undefined. */
|
|
431
|
+
export function isoFromRunDate(raw: string): string | undefined {
|
|
432
|
+
const digits = raw.replaceAll(/\D/g, '');
|
|
433
|
+
if (digits.length < 8) return undefined;
|
|
434
|
+
const year = Number(digits.slice(0, 4));
|
|
435
|
+
const month = Number(digits.slice(4, 6));
|
|
436
|
+
const day = Number(digits.slice(6, 8));
|
|
437
|
+
if (!isRealDate(year, month, day)) return undefined;
|
|
438
|
+
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Calendar date in America/Los_Angeles. Episode identity follows the news day, not UTC. */
|
|
442
|
+
export function isoFromLosAngeles(ms: number): string | undefined {
|
|
443
|
+
if (!Number.isFinite(ms)) return undefined;
|
|
444
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
445
|
+
timeZone: 'America/Los_Angeles',
|
|
446
|
+
year: 'numeric',
|
|
447
|
+
month: '2-digit',
|
|
448
|
+
day: '2-digit',
|
|
449
|
+
}).formatToParts(new Date(ms));
|
|
450
|
+
const year = parts.find((part) => part.type === 'year')?.value;
|
|
451
|
+
const month = parts.find((part) => part.type === 'month')?.value;
|
|
452
|
+
const day = parts.find((part) => part.type === 'day')?.value;
|
|
453
|
+
if (!year || !month || !day) return undefined;
|
|
454
|
+
return `${year}-${month}-${day}`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function resolveEpisodeDate(metadata: Record<string, unknown>, publishedAtMs?: number): string | undefined {
|
|
458
|
+
if (typeof metadata.runDate === 'string') {
|
|
459
|
+
const fromRun = isoFromRunDate(metadata.runDate);
|
|
460
|
+
if (fromRun) return fromRun;
|
|
461
|
+
}
|
|
462
|
+
if (publishedAtMs !== undefined) return isoFromLosAngeles(publishedAtMs);
|
|
463
|
+
const publishedAt = metadata.publishedAt;
|
|
464
|
+
if (typeof publishedAt === 'number') return isoFromLosAngeles(publishedAt);
|
|
465
|
+
if (typeof publishedAt === 'string' && publishedAt.length > 0) {
|
|
466
|
+
const parsed = Date.parse(publishedAt);
|
|
467
|
+
if (!Number.isNaN(parsed)) return isoFromLosAngeles(parsed);
|
|
468
|
+
}
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Season = year, episode = 1-based day-of-year. UTC calendar math, so the zone cannot shift the day. */
|
|
473
|
+
export function seasonAndEpisode(isoDate: string): { season: number; episode: number } | undefined {
|
|
474
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
|
|
475
|
+
if (!match) return undefined;
|
|
476
|
+
const year = Number(match[1]);
|
|
477
|
+
const month = Number(match[2]);
|
|
478
|
+
const day = Number(match[3]);
|
|
479
|
+
if (!isRealDate(year, month, day)) return undefined;
|
|
480
|
+
const episode = Math.round((Date.UTC(year, month - 1, day) - Date.UTC(year, 0, 1)) / 86_400_000) + 1;
|
|
481
|
+
return { season: year, episode };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function isPositiveInt(value: unknown): value is number {
|
|
485
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 1;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function keywordsFromRefinedTitle(title: string): string[] {
|
|
489
|
+
const index = title.indexOf(':');
|
|
490
|
+
if (index < 0) return [];
|
|
491
|
+
return title
|
|
492
|
+
.slice(index + 1)
|
|
493
|
+
.split('、')
|
|
494
|
+
.map((part) => part.trim())
|
|
495
|
+
.filter((part) => part.length > 0);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Plan-doc fallback: cut at the first clause break, then keep at most 15 code points. */
|
|
499
|
+
export function keywordFromDocTitle(title: string): string {
|
|
500
|
+
const cut = title.split(/,|,|:|;|——/)[0]?.trim() ?? '';
|
|
501
|
+
return Array.from(cut).slice(0, 15).join('');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function clauseFromTitle(title: string): string {
|
|
505
|
+
const cut = (title.split(/,|,/)[0] ?? title).replaceAll('「', '').replaceAll('」', '');
|
|
506
|
+
return cut.replace(/\s+/g, ' ').trim();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function firstSentence(text: string): string {
|
|
510
|
+
return splitSentences(text.replace(/\s+/g, ' ').trim())[0] ?? '';
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function planItemsFromMetadata(metadata: Record<string, unknown> | undefined): PlanItem[] {
|
|
514
|
+
const raw = podcastOf(metadata).planItems;
|
|
515
|
+
if (!Array.isArray(raw)) return [];
|
|
516
|
+
const items: PlanItem[] = [];
|
|
517
|
+
for (const entry of raw) {
|
|
518
|
+
const record = asRecord(entry);
|
|
519
|
+
if (!record) continue;
|
|
520
|
+
const title = typeof record.title === 'string' ? record.title.trim() : '';
|
|
521
|
+
const brief = typeof record.brief === 'string' ? record.brief.trim() : '';
|
|
522
|
+
if (title.length === 0 && brief.length === 0) continue;
|
|
523
|
+
items.push(brief.length > 0 ? { title, brief } : { title });
|
|
524
|
+
}
|
|
525
|
+
return items;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function stringList(value: unknown): string[] | undefined {
|
|
529
|
+
if (!Array.isArray(value)) return undefined;
|
|
530
|
+
const items = value.filter((entry): entry is string => typeof entry === 'string').map((entry) => entry.trim());
|
|
531
|
+
return items.filter((entry) => entry.length > 0);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function cutAtBoundary(value: string, room: number): string {
|
|
535
|
+
if (room <= 0) return '';
|
|
536
|
+
const chars = Array.from(value);
|
|
537
|
+
if (chars.length <= room) return value;
|
|
538
|
+
const sliced = chars.slice(0, room);
|
|
539
|
+
let cut = sliced.length;
|
|
540
|
+
for (let index = sliced.length - 1; index >= 0; index--) {
|
|
541
|
+
if (/[,。!?、;:,.\s]/.test(sliced[index] ?? '')) {
|
|
542
|
+
cut = index;
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const trimmed = sliced.slice(0, cut).join('').trim();
|
|
547
|
+
return trimmed.length > 0 ? trimmed : sliced.join('').trim();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function headlineFromKeywords(config: PodcastSeoConfig, date: string, keywords: string[], limit: number): string {
|
|
551
|
+
const head = normalizeEpisodeTitle(
|
|
552
|
+
config.titlePattern
|
|
553
|
+
.replaceAll('{series}', config.seriesName)
|
|
554
|
+
.replaceAll('{date}', date)
|
|
555
|
+
.replaceAll('{keywords}', ''),
|
|
556
|
+
config.seriesName,
|
|
557
|
+
);
|
|
558
|
+
const chosen: string[] = [];
|
|
559
|
+
for (const keyword of keywords) {
|
|
560
|
+
const title = normalizeEpisodeTitle(`${head}${[...chosen, keyword].join('、')}`, config.seriesName);
|
|
561
|
+
if (codePointLength(title) <= limit) chosen.push(keyword);
|
|
562
|
+
else break;
|
|
563
|
+
}
|
|
564
|
+
if (chosen.length === 0 && keywords[0]) {
|
|
565
|
+
const room = limit - codePointLength(head);
|
|
566
|
+
const cut = cutAtBoundary(keywords[0], room);
|
|
567
|
+
if (cut.length > 0) chosen.push(cut);
|
|
568
|
+
}
|
|
569
|
+
return normalizeEpisodeTitle(chosen.length > 0 ? `${head}${chosen.join('、')}` : head, config.seriesName);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
interface KeywordPick {
|
|
573
|
+
explicitTitle?: string;
|
|
574
|
+
keywords: string[];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function pickKeywords(metadata: Record<string, unknown>, config: PodcastSeoConfig): KeywordPick {
|
|
578
|
+
const podcast = podcastOf(metadata);
|
|
579
|
+
if (typeof podcast.title === 'string' && podcast.title.trim().length > 0) {
|
|
580
|
+
return { explicitTitle: podcast.title.trim(), keywords: [] };
|
|
581
|
+
}
|
|
582
|
+
const explicit = stringList(podcast.titleKeywords);
|
|
583
|
+
if (explicit && explicit.length > 0) return { keywords: explicit.slice(0, config.titleMaxKeywords) };
|
|
584
|
+
const refined = typeof podcast.refinedTitle === 'string' ? keywordsFromRefinedTitle(podcast.refinedTitle) : [];
|
|
585
|
+
if (refined.length > 0) return { keywords: refined.slice(0, config.titleMaxKeywords) };
|
|
586
|
+
const fromPlan = planItemsFromMetadata(metadata)
|
|
587
|
+
.map((item) => keywordFromDocTitle(item.title))
|
|
588
|
+
.filter((keyword) => keyword.length > 0);
|
|
589
|
+
if (fromPlan.length > 0) return { keywords: fromPlan.slice(0, config.titleMaxKeywords) };
|
|
590
|
+
return { keywords: [] };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function buildItemTitle(
|
|
594
|
+
asIs: string,
|
|
595
|
+
metadata: Record<string, unknown>,
|
|
596
|
+
config: PodcastSeoConfig,
|
|
597
|
+
date: string | undefined,
|
|
598
|
+
warnings: string[],
|
|
599
|
+
): { title: string; keywords: string[]; explicit: boolean } {
|
|
600
|
+
const picked = pickKeywords(metadata, config);
|
|
601
|
+
if (picked.explicitTitle) return { title: picked.explicitTitle, keywords: [], explicit: true };
|
|
602
|
+
if (config.titleMode !== 'headline') return { title: asIs, keywords: picked.keywords, explicit: false };
|
|
603
|
+
if (!date) {
|
|
604
|
+
warnings.push(`${WARN_PREFIX} headline title skipped: episode date is unknown`);
|
|
605
|
+
return { title: asIs, keywords: [], explicit: false };
|
|
606
|
+
}
|
|
607
|
+
if (picked.keywords.length === 0) {
|
|
608
|
+
warnings.push(`${WARN_PREFIX} no keywords for the headline title; keeping the as-is title`);
|
|
609
|
+
return { title: asIs, keywords: [], explicit: false };
|
|
610
|
+
}
|
|
611
|
+
return {
|
|
612
|
+
title: headlineFromKeywords(config, date, picked.keywords, config.titleMax),
|
|
613
|
+
keywords: picked.keywords,
|
|
614
|
+
explicit: false,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function fitSeoTitle(
|
|
619
|
+
episodeTitle: string,
|
|
620
|
+
keywords: string[],
|
|
621
|
+
config: PodcastSeoConfig,
|
|
622
|
+
date: string | undefined,
|
|
623
|
+
explicit: boolean,
|
|
624
|
+
): { stem: string; seo: string } {
|
|
625
|
+
const suffix = config.seoTitleSuffix;
|
|
626
|
+
let stem = episodeTitle;
|
|
627
|
+
let remaining = keywords;
|
|
628
|
+
let seo = `${stem}${suffix}`;
|
|
629
|
+
while (codePointLength(seo) > config.seoTitleMax && remaining.length > 0 && date && !explicit) {
|
|
630
|
+
remaining = remaining.slice(0, -1);
|
|
631
|
+
stem = headlineFromKeywords(config, date, remaining, config.titleMax);
|
|
632
|
+
seo = `${stem}${suffix}`;
|
|
633
|
+
}
|
|
634
|
+
if (codePointLength(seo) > config.seoTitleMax) {
|
|
635
|
+
const room = config.seoTitleMax - codePointLength(suffix);
|
|
636
|
+
stem = cutAtBoundary(stem, room);
|
|
637
|
+
seo = `${stem}${suffix}`;
|
|
638
|
+
}
|
|
639
|
+
return { stem, seo };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function cleanClause(value: string): string {
|
|
643
|
+
return value
|
|
644
|
+
.replace(/<[^>]*>/g, '')
|
|
645
|
+
.replace(/[*#`[\]]/g, '')
|
|
646
|
+
.replace(/[。!?.!?]+$/u, '')
|
|
647
|
+
.replace(/\s+/g, ' ')
|
|
648
|
+
.trim();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function descriptionHead(config: PodcastSeoConfig, date: string | undefined, minutes: number | undefined): string {
|
|
652
|
+
const pattern = config.descriptionPattern.includes('{clauses}')
|
|
653
|
+
? (config.descriptionPattern.split('{clauses}')[0] ?? config.descriptionPattern)
|
|
654
|
+
: config.descriptionPattern;
|
|
655
|
+
let head = pattern.replaceAll('{show}', config.showName).replaceAll('{series}', config.seriesName);
|
|
656
|
+
if (date) head = head.replaceAll('{date}', date);
|
|
657
|
+
else head = head.replaceAll(' {date}', '').replaceAll('{date}', '');
|
|
658
|
+
if (minutes !== undefined && minutes > 0) head = head.replaceAll('{minutes}', String(minutes));
|
|
659
|
+
else head = head.replaceAll('(约 {minutes} 分钟)', '').replaceAll('{minutes}', '');
|
|
660
|
+
return head.replace(/。\s*$/, '');
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export function durationMinutes(seconds: number | undefined): number | undefined {
|
|
664
|
+
if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) return undefined;
|
|
665
|
+
return Math.round(seconds / 60);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export function buildSeoDescription(input: {
|
|
669
|
+
config: PodcastSeoConfig;
|
|
670
|
+
date?: string;
|
|
671
|
+
minutes?: number;
|
|
672
|
+
clauses: string[];
|
|
673
|
+
extraSentences: string[];
|
|
674
|
+
}): { description: string; short: boolean } {
|
|
675
|
+
const head = descriptionHead(input.config, input.date, input.minutes);
|
|
676
|
+
const render = (parts: string[]): string => (parts.length === 0 ? head : `${head}${parts.join(';')}。`);
|
|
677
|
+
const picked: string[] = [];
|
|
678
|
+
const pushWhile = (candidates: string[]) => {
|
|
679
|
+
for (const candidate of candidates) {
|
|
680
|
+
const clause = cleanClause(candidate);
|
|
681
|
+
if (clause.length === 0 || picked.includes(clause)) continue;
|
|
682
|
+
const next = render([...picked, clause]);
|
|
683
|
+
if (codePointLength(next) <= input.config.descriptionMax) picked.push(clause);
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
pushWhile(input.clauses);
|
|
687
|
+
if (codePointLength(render(picked)) < input.config.descriptionMin) pushWhile(input.extraSentences);
|
|
688
|
+
const description = render(picked);
|
|
689
|
+
return { description, short: codePointLength(description) < input.config.descriptionMin };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function descriptionInputs(
|
|
693
|
+
metadata: Record<string, unknown>,
|
|
694
|
+
config: PodcastSeoConfig,
|
|
695
|
+
date: string | undefined,
|
|
696
|
+
minutes: number | undefined,
|
|
697
|
+
): { description: string; short: boolean } {
|
|
698
|
+
const podcast = podcastOf(metadata);
|
|
699
|
+
const plan = planItemsFromMetadata(metadata);
|
|
700
|
+
const summary = stringList(podcast.summaryClauses);
|
|
701
|
+
const surfing =
|
|
702
|
+
config.descriptionSource === 'surfing' && typeof podcast.refinedDescription === 'string'
|
|
703
|
+
? podcast.refinedDescription.trim()
|
|
704
|
+
: '';
|
|
705
|
+
const clauses = surfing
|
|
706
|
+
? [surfing]
|
|
707
|
+
: summary && summary.length > 0
|
|
708
|
+
? summary
|
|
709
|
+
: plan.map((item) => clauseFromTitle(item.title)).filter((clause) => clause.length > 0);
|
|
710
|
+
const extras = plan
|
|
711
|
+
.map((item) => (item.brief ? firstSentence(item.brief) : ''))
|
|
712
|
+
.filter((sentence) => sentence.length > 0);
|
|
713
|
+
return buildSeoDescription({ config, date, minutes, clauses, extraSentences: extras });
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** microfeed slug rule: lowercase letters, digits and hyphens, ≤120, no trailing hyphen. */
|
|
717
|
+
export function isValidItemSlug(slug: string): boolean {
|
|
718
|
+
return slug.length >= 1 && slug.length <= 120 && !slug.endsWith('-') && /^[a-z0-9][a-z0-9-]*$/.test(slug);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export function renderSlug(
|
|
722
|
+
pattern: string,
|
|
723
|
+
date: string | undefined,
|
|
724
|
+
): { slug?: string; error?: string; warning?: string } {
|
|
725
|
+
if (!date) return { warning: 'skipping slug: episode date is unknown' };
|
|
726
|
+
const slug = pattern.replaceAll('{date}', date);
|
|
727
|
+
if (!isValidItemSlug(slug)) {
|
|
728
|
+
return {
|
|
729
|
+
error: `invalid PODCAST_PUB_SLUG_PATTERN output "${slug}": expected lowercase letters, digits and hyphens (^[a-z0-9][a-z0-9-]*$), at most 120 characters, and no trailing hyphen`,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
return { slug };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export function roundDurationSeconds(seconds: number | undefined, enabled: boolean): number | undefined {
|
|
736
|
+
if (seconds === undefined) return undefined;
|
|
737
|
+
return enabled ? Math.round(seconds) : seconds;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function imageMimeFromBytes(bytes: Uint8Array): 'image/png' | 'image/jpeg' | undefined {
|
|
741
|
+
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
|
|
742
|
+
return 'image/png';
|
|
743
|
+
}
|
|
744
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) return 'image/jpeg';
|
|
745
|
+
return undefined;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** PNG IHDR or JPEG SOF. Returns undefined when the header is not a PNG or JPEG. */
|
|
749
|
+
export function readImageDimensions(
|
|
750
|
+
bytes: Uint8Array,
|
|
751
|
+
): { width: number; height: number; mime: 'image/png' | 'image/jpeg' } | undefined {
|
|
752
|
+
const mime = imageMimeFromBytes(bytes);
|
|
753
|
+
if (mime === 'image/png') {
|
|
754
|
+
if (bytes.length < 24) return undefined;
|
|
755
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
756
|
+
const width = view.getUint32(16);
|
|
757
|
+
const height = view.getUint32(20);
|
|
758
|
+
return { width, height, mime };
|
|
759
|
+
}
|
|
760
|
+
if (mime === 'image/jpeg') {
|
|
761
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
762
|
+
let offset = 2;
|
|
763
|
+
while (offset + 3 < bytes.length) {
|
|
764
|
+
if (bytes[offset] !== 0xff) return undefined;
|
|
765
|
+
const marker = bytes[offset + 1] ?? 0;
|
|
766
|
+
if (marker === 0xd8 || marker === 0xd9) {
|
|
767
|
+
offset += 2;
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
if (marker >= 0xd0 && marker <= 0xd7) {
|
|
771
|
+
offset += 2;
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
if (offset + 4 > bytes.length) return undefined;
|
|
775
|
+
const length = view.getUint16(offset + 2);
|
|
776
|
+
if (length < 2) return undefined;
|
|
777
|
+
const isSof =
|
|
778
|
+
marker === 0xc0 ||
|
|
779
|
+
marker === 0xc1 ||
|
|
780
|
+
marker === 0xc2 ||
|
|
781
|
+
(marker >= 0xc5 && marker <= 0xc7) ||
|
|
782
|
+
(marker >= 0xc9 && marker <= 0xcb) ||
|
|
783
|
+
(marker >= 0xcd && marker <= 0xcf);
|
|
784
|
+
if (isSof) {
|
|
785
|
+
if (offset + 9 > bytes.length) return undefined;
|
|
786
|
+
return { height: view.getUint16(offset + 5), width: view.getUint16(offset + 7), mime };
|
|
787
|
+
}
|
|
788
|
+
offset += 2 + length;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return undefined;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function readLocalBytes(path: string): Uint8Array | undefined {
|
|
795
|
+
try {
|
|
796
|
+
return new Uint8Array(readFileSync(path));
|
|
797
|
+
} catch {
|
|
798
|
+
return undefined;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function altText(config: PodcastSeoConfig, date: string | undefined, keywords: string[]): string {
|
|
803
|
+
const rendered = config.socialImageAltPattern
|
|
804
|
+
.replaceAll('{series}', config.seriesName)
|
|
805
|
+
.replaceAll('{date}', date ?? '')
|
|
806
|
+
.replaceAll('{keywords}', keywords.join('、'));
|
|
807
|
+
const sanitized = sanitizeTitle(rendered, 'normalize');
|
|
808
|
+
const value = sanitized.ok ? sanitized.value : rendered;
|
|
809
|
+
if (codePointLength(value) <= 200) return value;
|
|
810
|
+
return cutAtBoundary(value, 200);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function mustSanitize(
|
|
814
|
+
value: string,
|
|
815
|
+
mode: SanitizeMode,
|
|
816
|
+
label: string,
|
|
817
|
+
): { ok: true; value: string } | { ok: false; error: string } {
|
|
818
|
+
const sanitized = sanitizeTitle(value, mode);
|
|
819
|
+
if (!sanitized.ok) return { ok: false, error: `${label}: ${sanitized.error}` };
|
|
820
|
+
return sanitized;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Applies R1–R9 to a mapped episode. Does not touch the network.
|
|
825
|
+
* A `reject` sanitise failure or an invalid slug pattern returns `ok: false` before any request.
|
|
826
|
+
*/
|
|
827
|
+
export function prepareEpisodeFields(
|
|
828
|
+
episode: {
|
|
829
|
+
title: string;
|
|
830
|
+
durationSeconds?: number;
|
|
831
|
+
link?: string;
|
|
832
|
+
itunes?: PreparedEpisodeFields['itunes'];
|
|
833
|
+
datePublishedMs?: number;
|
|
834
|
+
},
|
|
835
|
+
metadata: Record<string, unknown>,
|
|
836
|
+
config: PodcastSeoConfig,
|
|
837
|
+
): { ok: true; fields: PreparedEpisodeFields } | { ok: false; error: string } {
|
|
838
|
+
const warnings: string[] = [];
|
|
839
|
+
const date = resolveEpisodeDate(metadata, episode.datePublishedMs);
|
|
840
|
+
const built = buildItemTitle(episode.title, metadata, config, date, warnings);
|
|
841
|
+
const sanitizedTitle = mustSanitize(built.title, config.titleSanitize, 'item title');
|
|
842
|
+
if (!sanitizedTitle.ok) return sanitizedTitle;
|
|
843
|
+
// Explicit titles are an escape hatch: sanitise them, and do not rebuild the RSS title.
|
|
844
|
+
// Headline titles are normalised (legacy series names, CJK/Latin spacing). as-is stays byte-stable.
|
|
845
|
+
let title = built.explicit
|
|
846
|
+
? sanitizedTitle.value
|
|
847
|
+
: config.titleMode === 'headline'
|
|
848
|
+
? normalizeEpisodeTitle(sanitizedTitle.value, config.seriesName)
|
|
849
|
+
: sanitizedTitle.value;
|
|
850
|
+
const itunes = { ...(episode.itunes ?? {}) };
|
|
851
|
+
if (itunes.season !== undefined && !isPositiveInt(itunes.season)) {
|
|
852
|
+
return {
|
|
853
|
+
ok: false,
|
|
854
|
+
error: `invalid metadata.itunes.season: expected an integer ≥ 1, got ${String(itunes.season)}`,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
if (itunes.episode !== undefined && !isPositiveInt(itunes.episode)) {
|
|
858
|
+
return {
|
|
859
|
+
ok: false,
|
|
860
|
+
error: `invalid metadata.itunes.episode: expected an integer ≥ 1, got ${String(itunes.episode)}`,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
if (config.autoEpisodeNumber) {
|
|
864
|
+
const derived = date ? seasonAndEpisode(date) : undefined;
|
|
865
|
+
if (derived) {
|
|
866
|
+
if (itunes.season === undefined) itunes.season = derived.season;
|
|
867
|
+
if (itunes.episode === undefined) itunes.episode = derived.episode;
|
|
868
|
+
} else if (itunes.season === undefined || itunes.episode === undefined) {
|
|
869
|
+
warnings.push(`${WARN_PREFIX} itunes season/episode left unset: episode date is unknown`);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (itunes.title) {
|
|
873
|
+
const sanitizedItunes = mustSanitize(itunes.title, config.titleSanitize, 'itunes:title');
|
|
874
|
+
if (!sanitizedItunes.ok) return sanitizedItunes;
|
|
875
|
+
itunes.title = sanitizedItunes.value;
|
|
876
|
+
}
|
|
877
|
+
const minutes = durationMinutes(episode.durationSeconds);
|
|
878
|
+
let seo: SeoBlock | undefined;
|
|
879
|
+
if (config.seo) {
|
|
880
|
+
const fitted = fitSeoTitle(title, built.keywords, config, date, built.explicit);
|
|
881
|
+
if (!built.explicit && config.titleMode === 'headline' && fitted.stem.length > 0) title = fitted.stem;
|
|
882
|
+
const seoTitle = mustSanitize(fitted.seo, config.titleSanitize, 'seo.title');
|
|
883
|
+
if (!seoTitle.ok) return seoTitle;
|
|
884
|
+
const described = descriptionInputs(metadata, config, date, minutes);
|
|
885
|
+
if (described.short) {
|
|
886
|
+
warnings.push(
|
|
887
|
+
`${WARN_PREFIX} seo.description is ${codePointLength(described.description)} code points, under the ${config.descriptionMin} minimum; left unpadded`,
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
seo = { title: seoTitle.value, description: described.description };
|
|
891
|
+
}
|
|
892
|
+
let slug: string | undefined;
|
|
893
|
+
if (config.slugMode === 'pattern') {
|
|
894
|
+
const rendered = renderSlug(config.slugPattern, date);
|
|
895
|
+
if (rendered.error) return { ok: false, error: rendered.error };
|
|
896
|
+
if (rendered.warning) warnings.push(`${WARN_PREFIX} ${rendered.warning}`);
|
|
897
|
+
slug = rendered.slug;
|
|
898
|
+
}
|
|
899
|
+
if (config.linkField === 'url' && episode.link) {
|
|
900
|
+
warnings.push(
|
|
901
|
+
`${WARN_PREFIX} PODCAST_PUB_LINK_FIELD=url sends metadata.articleUrl as the item url; microfeed uses that URL as the canonical and drops the episode from the podcast sitemap`,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
let socialAlt: string | undefined;
|
|
905
|
+
let socialLocalPath: string | undefined;
|
|
906
|
+
let socialMime: PreparedEpisodeFields['socialMime'];
|
|
907
|
+
let socialRemoteUrl: string | undefined;
|
|
908
|
+
if (config.socialImage) {
|
|
909
|
+
const source = podcastOf(metadata).socialImage;
|
|
910
|
+
const alt = altText(config, date, built.keywords);
|
|
911
|
+
if (typeof source !== 'string' || source.trim().length === 0) {
|
|
912
|
+
warnings.push(
|
|
913
|
+
`${WARN_PREFIX} PODCAST_PUB_SOCIAL_IMAGE is on but metadata.podcast.socialImage is empty; skipping alt text`,
|
|
914
|
+
);
|
|
915
|
+
} else if (source.trim().startsWith('http')) {
|
|
916
|
+
socialAlt = alt;
|
|
917
|
+
socialRemoteUrl = source.trim();
|
|
918
|
+
} else {
|
|
919
|
+
const bytes = readLocalBytes(source.trim());
|
|
920
|
+
const dims = bytes ? readImageDimensions(bytes) : undefined;
|
|
921
|
+
if (dims?.width !== 1200 || dims?.height !== 630) {
|
|
922
|
+
warnings.push(
|
|
923
|
+
`${WARN_PREFIX} social image skipped: expected a 1200×630 PNG or JPEG, got ${dims ? `${dims.width}×${dims.height}` : 'an unreadable file'}`,
|
|
924
|
+
);
|
|
925
|
+
} else {
|
|
926
|
+
socialAlt = alt;
|
|
927
|
+
socialLocalPath = source.trim();
|
|
928
|
+
socialMime = dims.mime;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
for (const line of warnings) echoError(line);
|
|
933
|
+
return {
|
|
934
|
+
ok: true,
|
|
935
|
+
fields: {
|
|
936
|
+
title,
|
|
937
|
+
itunes,
|
|
938
|
+
durationSeconds: roundDurationSeconds(episode.durationSeconds, config.roundDuration),
|
|
939
|
+
...(config.linkField === 'url' && episode.link ? { link: episode.link } : {}),
|
|
940
|
+
...(seo ? { seo } : {}),
|
|
941
|
+
...(slug ? { slug } : {}),
|
|
942
|
+
warnings,
|
|
943
|
+
...(socialAlt ? { socialAlt } : {}),
|
|
944
|
+
...(socialLocalPath ? { socialLocalPath, socialMime } : {}),
|
|
945
|
+
...(socialRemoteUrl ? { socialRemoteUrl } : {}),
|
|
946
|
+
},
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Markdown summary paragraph: R3 text without the `{show}·` prefix, then the Surfing cross-link. */
|
|
951
|
+
export function showNotesSummaryParagraph(input: {
|
|
952
|
+
config: PodcastSeoConfig;
|
|
953
|
+
runDate: string;
|
|
954
|
+
durationSeconds?: number;
|
|
955
|
+
plan: PlanItem[];
|
|
956
|
+
refinedDescription?: string;
|
|
957
|
+
summaryClauses?: string[];
|
|
958
|
+
articleUrl: string;
|
|
959
|
+
linkText: string;
|
|
960
|
+
}): { markdown: string; description: string; warnings: string[] } {
|
|
961
|
+
const date = isoFromRunDate(input.runDate);
|
|
962
|
+
const metadata: Record<string, unknown> = {
|
|
963
|
+
...(date ? { runDate: date } : { runDate: input.runDate }),
|
|
964
|
+
podcast: {
|
|
965
|
+
...(input.refinedDescription ? { refinedDescription: input.refinedDescription } : {}),
|
|
966
|
+
...(input.summaryClauses ? { summaryClauses: input.summaryClauses } : {}),
|
|
967
|
+
planItems: input.plan,
|
|
968
|
+
},
|
|
969
|
+
};
|
|
970
|
+
const described = descriptionInputs(metadata, input.config, date, durationMinutes(input.durationSeconds));
|
|
971
|
+
const warnings: string[] = [];
|
|
972
|
+
if (described.short) {
|
|
973
|
+
warnings.push(
|
|
974
|
+
`${WARN_PREFIX} seo.description is ${codePointLength(described.description)} code points, under the ${input.config.descriptionMin} minimum; left unpadded`,
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
const prefix = `${input.config.showName}·`;
|
|
978
|
+
const stripped = described.description.startsWith(prefix)
|
|
979
|
+
? described.description.slice(prefix.length)
|
|
980
|
+
: described.description;
|
|
981
|
+
const safeLabel = input.linkText.replaceAll('[', '').replaceAll(']', '').replaceAll('(', '').replaceAll(')', '');
|
|
982
|
+
const markdown = `${stripped} 完整图文版:[${safeLabel}](${input.articleUrl})`;
|
|
983
|
+
return { markdown, description: described.description, warnings };
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
export function summaryParagraphHtml(
|
|
987
|
+
description: string,
|
|
988
|
+
showName: string,
|
|
989
|
+
articleUrl: string | undefined,
|
|
990
|
+
linkText: string,
|
|
991
|
+
): string {
|
|
992
|
+
const prefix = `${showName}·`;
|
|
993
|
+
const stripped = description.startsWith(prefix) ? description.slice(prefix.length) : description;
|
|
994
|
+
const text = stripped.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
995
|
+
if (!articleUrl) return `<p>${text}</p>`;
|
|
996
|
+
const href = articleUrl.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '');
|
|
997
|
+
const label = linkText.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
998
|
+
return `<p>${text} 完整图文版:<a href="${href}" target="_blank" rel="noopener">${label}</a></p>`;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function stripMarkupTitle(raw: string): string {
|
|
1002
|
+
return raw
|
|
1003
|
+
.replace(/<[^>]+>/g, ' ')
|
|
1004
|
+
.replace(/来源/g, '')
|
|
1005
|
+
.replace(/^\s*\d+\s*[.、]\s*/, '')
|
|
1006
|
+
.replace(/\s+/g, ' ')
|
|
1007
|
+
.trim();
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** Chapter titles from a timeline (`.kk-tl-title`) or, failing that, legacy `<h2>` sections. */
|
|
1011
|
+
export function titlesFromContentHtml(html: string): string[] {
|
|
1012
|
+
const timeline = [...html.matchAll(/class="kk-tl-title">([\s\S]*?)<\/div>/gi)]
|
|
1013
|
+
.map((match) => stripMarkupTitle(match[1] ?? ''))
|
|
1014
|
+
.filter((title) => title.length > 0);
|
|
1015
|
+
if (timeline.length > 0) return timeline;
|
|
1016
|
+
return [...html.matchAll(/<h2[^>]*>([\s\S]*?)<\/h2>/gi)]
|
|
1017
|
+
.map((match) => stripMarkupTitle(match[1] ?? ''))
|
|
1018
|
+
.filter((title) => title.length > 0);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function insertAfterTimeline(html: string, insertion: string): string {
|
|
1022
|
+
const start = html.indexOf('<div class="kk-tl">');
|
|
1023
|
+
if (start < 0) return `${html.trim()}\n${insertion}`;
|
|
1024
|
+
let depth = 0;
|
|
1025
|
+
const tags = /<\/?div\b[^>]*>/gi;
|
|
1026
|
+
tags.lastIndex = start;
|
|
1027
|
+
let match = tags.exec(html);
|
|
1028
|
+
while (match) {
|
|
1029
|
+
depth += match[0].startsWith('</') ? -1 : 1;
|
|
1030
|
+
if (depth === 0) {
|
|
1031
|
+
const at = match.index + match[0].length;
|
|
1032
|
+
const head = html.slice(0, at).replace(/\s*$/, '');
|
|
1033
|
+
const tail = html.slice(at).replace(/^\s*/, '');
|
|
1034
|
+
return tail.length > 0 ? `${head}\n${insertion}\n${tail}` : `${head}\n${insertion}`;
|
|
1035
|
+
}
|
|
1036
|
+
match = tags.exec(html);
|
|
1037
|
+
}
|
|
1038
|
+
return `${html.trim()}\n${insertion}`;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Summary `<p>` first, and the timeline `<style>` moved to after the timeline `</div>`.
|
|
1043
|
+
* A second pass on already-rewritten HTML does not duplicate the paragraph or the style.
|
|
1044
|
+
*/
|
|
1045
|
+
export function rewriteEpisodeHtml(html: string, summaryHtml: string, moveStyle: boolean): string {
|
|
1046
|
+
let body = html;
|
|
1047
|
+
if (moveStyle) {
|
|
1048
|
+
const styleMatch = body.match(/<style>[\s\S]*?<\/style>/i);
|
|
1049
|
+
if (styleMatch && styleMatch.index !== undefined) {
|
|
1050
|
+
const style = styleMatch[0];
|
|
1051
|
+
const without = `${body.slice(0, styleMatch.index)}${body.slice(styleMatch.index + style.length)}`;
|
|
1052
|
+
body = insertAfterTimeline(without, style);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
const trimmed = body.trim();
|
|
1056
|
+
if (
|
|
1057
|
+
summaryHtml.length > 0 &&
|
|
1058
|
+
(trimmed === summaryHtml || trimmed.startsWith(`${summaryHtml}\n`) || trimmed.startsWith(`${summaryHtml}<`))
|
|
1059
|
+
) {
|
|
1060
|
+
return trimmed;
|
|
1061
|
+
}
|
|
1062
|
+
return `${summaryHtml}\n${trimmed}`;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** ISO date from an existing feed item: title `YYYY-MM-DD`, else `date_published` in America/Los_Angeles. */
|
|
1066
|
+
export function dateFromFeedItem(item: Record<string, unknown>): string | undefined {
|
|
1067
|
+
const title = typeof item.title === 'string' ? item.title : '';
|
|
1068
|
+
const inTitle = /(\d{4}-\d{2}-\d{2})/.exec(title);
|
|
1069
|
+
if (inTitle?.[1] && isoFromRunDate(inTitle[1])) return inTitle[1];
|
|
1070
|
+
const msField = item.date_published_ms;
|
|
1071
|
+
if (typeof msField === 'number') return isoFromLosAngeles(msField);
|
|
1072
|
+
const published = item.date_published;
|
|
1073
|
+
if (typeof published === 'number') return isoFromLosAngeles(published);
|
|
1074
|
+
if (typeof published === 'string') {
|
|
1075
|
+
const parsed = Date.parse(published);
|
|
1076
|
+
if (!Number.isNaN(parsed)) return isoFromLosAngeles(parsed);
|
|
1077
|
+
}
|
|
1078
|
+
return undefined;
|
|
1079
|
+
}
|