@gobing-ai/knowledge-kit 0.1.7 → 0.1.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/.claude-plugin/marketplace.json +1 -1
- package/dist/board/collect/index.js +744 -1107
- package/dist/board/daily-voice/index.js +741 -107
- package/dist/board/itc/index.js +32 -6
- package/dist/index.js +1793 -103
- package/package.json +1 -1
- package/plugins/ingestions/horizon-ingest/dist/index.js +24 -1
- package/plugins/ingestions/horizon-ingest/src/index.ts +34 -0
- package/plugins/kk/config.example.yaml +8 -0
- package/plugins/kk/decisions/extract/ai-news.yaml +7 -1
- 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 +664 -83
- package/plugins/kk/scripts/kk-workflow-stages.ts +11 -1
- 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 +82 -8
- package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +842 -0
- package/plugins/kk/workflows/kk-daily-publish3.yaml +282 -0
- package/plugins/publishings/podcast-pub/README.md +65 -0
- package/plugins/publishings/podcast-pub/dist/index.js +1709 -274
- 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 +1081 -0
- package/plugins/publishings/podcast-pub/src/show-notes.ts +71 -6
- package/plugins/publishings/surfdash-pub/dist/index.js +66 -5
- package/plugins/publishings/surfdash-pub/src/index.ts +94 -10
- package/plugins/publishings/wechatmp-pub/dist/index.js +54 -30
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +8 -3
- package/plugins/publishings/wechatmp-pub/src/enrich.ts +58 -38
- package/plugins/publishings/wechatmp-pub/src/flow.ts +18 -1
- package/plugins/publishings/wechatmp-pub/src/profile-check.ts +7 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"kind": "publish",
|
|
4
4
|
"entry": "./dist/index.js",
|
|
5
5
|
"version": "1.0.0",
|
|
6
|
-
"description": "Publish plugin for a self-hosted microfeed instance: podcast episodes via the Bearer-authenticated /api/v1/ REST API
|
|
6
|
+
"description": "Publish plugin for a self-hosted microfeed instance: podcast episodes via the Bearer-authenticated /api/v1/ REST API, per-episode SEO, and a dry-run backfill-seo operation",
|
|
7
7
|
"operations": {
|
|
8
8
|
"show-notes": {
|
|
9
9
|
"input": "doc-list",
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `backfill-seo` (design D9 / R10). Dry-run is the default. The op never calls a
|
|
3
|
+
* host other than the one in `MICROFEED_BASE_URL`, and tests stub that client.
|
|
4
|
+
*
|
|
5
|
+
* Default patch fills `_microfeed.seo` and missing iTunes numbers only.
|
|
6
|
+
* `--retitle`, `--rewrite-body` and `--apply-slug` are separate opt-ins.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Result } from '@gobing-ai/kk-core';
|
|
10
|
+
import { ResultSchema } from '@gobing-ai/kk-core';
|
|
11
|
+
import { ensureDirForFile, readJsonFile, writeJsonFile } from '@gobing-ai/ts-runtime';
|
|
12
|
+
import { echo } from '@gobing-ai/ts-utils';
|
|
13
|
+
import {
|
|
14
|
+
type MicrofeedClient,
|
|
15
|
+
type MicrofeedItem,
|
|
16
|
+
type MicrofeedItemInput,
|
|
17
|
+
MicrofeedRequestError,
|
|
18
|
+
} from './microfeed-client';
|
|
19
|
+
import {
|
|
20
|
+
clauseFromTitle,
|
|
21
|
+
codePointLength,
|
|
22
|
+
dateFromFeedItem,
|
|
23
|
+
isValidItemSlug,
|
|
24
|
+
type PodcastSeoConfig,
|
|
25
|
+
prepareEpisodeFields,
|
|
26
|
+
renderSlug,
|
|
27
|
+
rewriteEpisodeHtml,
|
|
28
|
+
type SeoBlock,
|
|
29
|
+
summaryParagraphHtml,
|
|
30
|
+
titlesFromContentHtml,
|
|
31
|
+
warn,
|
|
32
|
+
} from './seo';
|
|
33
|
+
|
|
34
|
+
const TARGET = 'podcast-pub';
|
|
35
|
+
|
|
36
|
+
export interface BackfillOverride {
|
|
37
|
+
id: string;
|
|
38
|
+
title?: string;
|
|
39
|
+
titleKeywords?: string[];
|
|
40
|
+
summaryClauses?: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BackfillOptions {
|
|
44
|
+
since?: unknown;
|
|
45
|
+
until?: unknown;
|
|
46
|
+
ids?: unknown;
|
|
47
|
+
out?: unknown;
|
|
48
|
+
/** Reviewed plan JSON. When `apply` is set, these patches are sent instead of a fresh plan. */
|
|
49
|
+
plan?: unknown;
|
|
50
|
+
apply?: unknown;
|
|
51
|
+
retitle?: unknown;
|
|
52
|
+
rewriteBody?: unknown;
|
|
53
|
+
applySlug?: unknown;
|
|
54
|
+
overrides?: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface BackfillEntry {
|
|
58
|
+
id: string;
|
|
59
|
+
date?: string;
|
|
60
|
+
patch: MicrofeedItemInput;
|
|
61
|
+
counts: { title?: number; seoTitle?: number; seoDescription?: number };
|
|
62
|
+
violations: string[];
|
|
63
|
+
current: {
|
|
64
|
+
title?: string;
|
|
65
|
+
seoTitle?: string;
|
|
66
|
+
seoDescription?: string;
|
|
67
|
+
season?: number;
|
|
68
|
+
episode?: number;
|
|
69
|
+
slug?: string;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface BackfillFlags {
|
|
74
|
+
apply: boolean;
|
|
75
|
+
retitle: boolean;
|
|
76
|
+
rewriteBody: boolean;
|
|
77
|
+
applySlug: boolean;
|
|
78
|
+
since?: string;
|
|
79
|
+
until?: string;
|
|
80
|
+
ids?: Set<string>;
|
|
81
|
+
out?: string;
|
|
82
|
+
planPath?: string;
|
|
83
|
+
overrides: Map<string, BackfillOverride>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function fail(error: string): Result {
|
|
87
|
+
return ResultSchema.parse({ ok: false, target: TARGET, error });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
91
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>;
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function flag(value: unknown): boolean {
|
|
96
|
+
return value === true || value === 'true' || value === '1';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function positiveInt(value: unknown): number | undefined {
|
|
100
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 1) return value;
|
|
101
|
+
if (typeof value === 'string' && /^\d+$/.test(value)) {
|
|
102
|
+
const parsed = Number(value);
|
|
103
|
+
if (Number.isInteger(parsed) && parsed >= 1) return parsed;
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseOverrides(raw: unknown): { overrides: Map<string, BackfillOverride> } | { error: string } {
|
|
109
|
+
if (raw === undefined) return { overrides: new Map() };
|
|
110
|
+
if (!Array.isArray(raw))
|
|
111
|
+
return {
|
|
112
|
+
error: 'invalid options.overrides: expected an array of {id, title?, titleKeywords?, summaryClauses?}',
|
|
113
|
+
};
|
|
114
|
+
const overrides = new Map<string, BackfillOverride>();
|
|
115
|
+
for (const entry of raw) {
|
|
116
|
+
const record = asRecord(entry);
|
|
117
|
+
if (!record || typeof record.id !== 'string' || record.id.trim().length === 0) {
|
|
118
|
+
return { error: 'invalid options.overrides: each entry needs a string id' };
|
|
119
|
+
}
|
|
120
|
+
const keywords = record.titleKeywords;
|
|
121
|
+
if (
|
|
122
|
+
keywords !== undefined &&
|
|
123
|
+
(!Array.isArray(keywords) || keywords.some((keyword) => typeof keyword !== 'string'))
|
|
124
|
+
) {
|
|
125
|
+
return { error: `invalid options.overrides titleKeywords for "${record.id}": expected string[]` };
|
|
126
|
+
}
|
|
127
|
+
const clauses = record.summaryClauses;
|
|
128
|
+
if (
|
|
129
|
+
clauses !== undefined &&
|
|
130
|
+
(!Array.isArray(clauses) || clauses.some((clause) => typeof clause !== 'string'))
|
|
131
|
+
) {
|
|
132
|
+
return { error: `invalid options.overrides summaryClauses for "${record.id}": expected string[]` };
|
|
133
|
+
}
|
|
134
|
+
if (record.title !== undefined && typeof record.title !== 'string') {
|
|
135
|
+
return { error: `invalid options.overrides title for "${record.id}": expected a string` };
|
|
136
|
+
}
|
|
137
|
+
overrides.set(record.id, {
|
|
138
|
+
id: record.id,
|
|
139
|
+
...(typeof record.title === 'string' ? { title: record.title } : {}),
|
|
140
|
+
...(Array.isArray(keywords)
|
|
141
|
+
? { titleKeywords: keywords.filter((keyword): keyword is string => typeof keyword === 'string') }
|
|
142
|
+
: {}),
|
|
143
|
+
...(Array.isArray(clauses)
|
|
144
|
+
? { summaryClauses: clauses.filter((clause): clause is string => typeof clause === 'string') }
|
|
145
|
+
: {}),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return { overrides };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseFlags(options: BackfillOptions): { flags: BackfillFlags } | { error: string } {
|
|
152
|
+
const overrides = parseOverrides(options.overrides);
|
|
153
|
+
if ('error' in overrides) return overrides;
|
|
154
|
+
const since = options.since === undefined ? undefined : String(options.since);
|
|
155
|
+
const until = options.until === undefined ? undefined : String(options.until);
|
|
156
|
+
if (since !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(since)) {
|
|
157
|
+
return { error: `invalid options.since "${since}": expected YYYY-MM-DD` };
|
|
158
|
+
}
|
|
159
|
+
if (until !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(until)) {
|
|
160
|
+
return { error: `invalid options.until "${until}": expected YYYY-MM-DD` };
|
|
161
|
+
}
|
|
162
|
+
let ids: Set<string> | undefined;
|
|
163
|
+
if (options.ids !== undefined) {
|
|
164
|
+
if (!Array.isArray(options.ids) || options.ids.some((id) => typeof id !== 'string')) {
|
|
165
|
+
return { error: 'invalid options.ids: expected an array of item id strings' };
|
|
166
|
+
}
|
|
167
|
+
ids = new Set(options.ids);
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
flags: {
|
|
171
|
+
apply: flag(options.apply),
|
|
172
|
+
retitle: flag(options.retitle),
|
|
173
|
+
rewriteBody: flag(options.rewriteBody),
|
|
174
|
+
applySlug: flag(options.applySlug),
|
|
175
|
+
...(since ? { since } : {}),
|
|
176
|
+
...(until ? { until } : {}),
|
|
177
|
+
...(ids ? { ids } : {}),
|
|
178
|
+
...(typeof options.out === 'string' && options.out.length > 0 ? { out: options.out } : {}),
|
|
179
|
+
...(typeof options.plan === 'string' && options.plan.length > 0 ? { planPath: options.plan } : {}),
|
|
180
|
+
overrides: overrides.overrides,
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function microfeedOf(item: MicrofeedItem): Record<string, unknown> {
|
|
186
|
+
return asRecord(item._microfeed) ?? {};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function durationOf(item: MicrofeedItem): number | undefined {
|
|
190
|
+
const attachments = item.attachments;
|
|
191
|
+
if (!Array.isArray(attachments)) return undefined;
|
|
192
|
+
const first = asRecord(attachments[0]);
|
|
193
|
+
const seconds = first?.duration_in_seconds;
|
|
194
|
+
return typeof seconds === 'number' && Number.isFinite(seconds) ? seconds : undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function articleUrlOf(html: string): string | undefined {
|
|
198
|
+
const marked = /完整图文版:<a href="([^"]+)"/.exec(html);
|
|
199
|
+
if (marked?.[1]) return marked[1];
|
|
200
|
+
const any = /href="(https?:\/\/[^"]+)"/.exec(html);
|
|
201
|
+
return any?.[1];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function inWindow(date: string | undefined, flags: BackfillFlags): boolean {
|
|
205
|
+
if (!flags.since && !flags.until) return true;
|
|
206
|
+
if (!date) return false;
|
|
207
|
+
if (flags.since && date < flags.since) return false;
|
|
208
|
+
if (flags.until && date > flags.until) return false;
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One item's minimal patch. SEO uses headline mode even when publish's title mode
|
|
214
|
+
* is `as-is`, so existing RSS titles stay put unless `retitle` is set.
|
|
215
|
+
*/
|
|
216
|
+
export function planBackfillItem(
|
|
217
|
+
item: MicrofeedItem,
|
|
218
|
+
config: PodcastSeoConfig,
|
|
219
|
+
flags: Pick<BackfillFlags, 'retitle' | 'rewriteBody' | 'applySlug'>,
|
|
220
|
+
override: BackfillOverride | undefined,
|
|
221
|
+
): BackfillEntry | { error: string } {
|
|
222
|
+
const id = typeof item.id === 'string' ? item.id : '';
|
|
223
|
+
if (id.length === 0) return { error: 'backfill-seo: a feed item is missing id' };
|
|
224
|
+
const currentTitle = typeof item.title === 'string' ? item.title : '';
|
|
225
|
+
const micro = microfeedOf(item);
|
|
226
|
+
const currentSeo = asRecord(micro.seo);
|
|
227
|
+
const currentSeoTitle = typeof currentSeo?.title === 'string' ? currentSeo.title : undefined;
|
|
228
|
+
const currentSeoDescription = typeof currentSeo?.description === 'string' ? currentSeo.description : undefined;
|
|
229
|
+
const currentSeason = positiveInt(micro['itunes:season']);
|
|
230
|
+
const currentEpisode = positiveInt(micro['itunes:episode']);
|
|
231
|
+
const currentSlug = typeof micro.slug === 'string' ? micro.slug : undefined;
|
|
232
|
+
const date = dateFromFeedItem(item);
|
|
233
|
+
const html = typeof item.content_html === 'string' ? item.content_html : '';
|
|
234
|
+
const titles = titlesFromContentHtml(html);
|
|
235
|
+
const clauses =
|
|
236
|
+
override?.summaryClauses ?? titles.map((title) => clauseFromTitle(title)).filter((clause) => clause.length > 0);
|
|
237
|
+
const keywords = override?.titleKeywords ?? titles.filter((title) => title.length > 0);
|
|
238
|
+
const metadata: Record<string, unknown> = {
|
|
239
|
+
...(date ? { runDate: date } : {}),
|
|
240
|
+
...(durationOf(item) !== undefined ? { durationSeconds: durationOf(item) } : {}),
|
|
241
|
+
podcast: {
|
|
242
|
+
titleMode: 'headline',
|
|
243
|
+
...(override?.title ? { title: override.title } : {}),
|
|
244
|
+
...(keywords.length > 0 ? { titleKeywords: keywords.slice(0, config.titleMaxKeywords) } : {}),
|
|
245
|
+
...(clauses.length > 0 ? { summaryClauses: clauses } : {}),
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
// The backfill op always proposes SEO fields and missing episode numbers.
|
|
249
|
+
// PODCAST_PUB_SEO / SLUG / LINK_FIELD gate new publishes, not this repair.
|
|
250
|
+
const prepared = prepareEpisodeFields(
|
|
251
|
+
{
|
|
252
|
+
title: currentTitle,
|
|
253
|
+
durationSeconds: durationOf(item),
|
|
254
|
+
itunes: {
|
|
255
|
+
...(currentSeason !== undefined ? { season: currentSeason } : {}),
|
|
256
|
+
...(currentEpisode !== undefined ? { episode: currentEpisode } : {}),
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
metadata,
|
|
260
|
+
{
|
|
261
|
+
...config,
|
|
262
|
+
seo: true,
|
|
263
|
+
titleMode: 'headline',
|
|
264
|
+
slugMode: 'auto',
|
|
265
|
+
linkField: 'none',
|
|
266
|
+
socialImage: false,
|
|
267
|
+
autoEpisodeNumber: true,
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
if (!prepared.ok) return { error: `${id}: ${prepared.error}` };
|
|
271
|
+
const violations: string[] = [];
|
|
272
|
+
if (!date) violations.push('episode date is unknown');
|
|
273
|
+
const patch: MicrofeedItemInput = {};
|
|
274
|
+
const nextMicro: Record<string, unknown> = {};
|
|
275
|
+
if (flags.retitle && prepared.fields.title !== currentTitle) patch.title = prepared.fields.title;
|
|
276
|
+
const seo = prepared.fields.seo;
|
|
277
|
+
if (seo) {
|
|
278
|
+
const seoPatch: SeoBlock = {};
|
|
279
|
+
if (seo.title && seo.title !== currentSeoTitle) seoPatch.title = seo.title;
|
|
280
|
+
if (seo.description && seo.description !== currentSeoDescription) seoPatch.description = seo.description;
|
|
281
|
+
if (seoPatch.title || seoPatch.description) nextMicro.seo = seoPatch;
|
|
282
|
+
if (seo.description && codePointLength(seo.description) < config.descriptionMin) {
|
|
283
|
+
violations.push(
|
|
284
|
+
`seo.description is ${codePointLength(seo.description)} code points, under ${config.descriptionMin}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
if (seo.description && /^\s*(\d|#|[-*])/.test(seo.description)) {
|
|
288
|
+
violations.push('seo.description starts with a forbidden character');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const numbered = prepared.fields.itunes;
|
|
292
|
+
if (numbered?.season !== undefined && currentSeason === undefined) nextMicro['itunes:season'] = numbered.season;
|
|
293
|
+
if (numbered?.episode !== undefined && currentEpisode === undefined) nextMicro['itunes:episode'] = numbered.episode;
|
|
294
|
+
if (flags.applySlug && date) {
|
|
295
|
+
const slug = renderSlug(config.slugPattern, date);
|
|
296
|
+
if (slug.slug && slug.slug !== currentSlug && isValidItemSlug(slug.slug)) nextMicro.slug = slug.slug;
|
|
297
|
+
if (slug.error) violations.push(slug.error);
|
|
298
|
+
}
|
|
299
|
+
if (flags.rewriteBody && html.length > 0 && seo?.description) {
|
|
300
|
+
const summary = summaryParagraphHtml(seo.description, config.showName, articleUrlOf(html), '本期完整文章');
|
|
301
|
+
const rewritten = rewriteEpisodeHtml(html, summary, config.timelineStyle !== 'omit');
|
|
302
|
+
if (rewritten !== html.trim()) patch.content_html = rewritten;
|
|
303
|
+
}
|
|
304
|
+
if (Object.keys(nextMicro).length > 0) patch._microfeed = nextMicro;
|
|
305
|
+
const proposedTitle = typeof patch.title === 'string' ? patch.title : currentTitle;
|
|
306
|
+
const proposedSeo = asRecord(nextMicro.seo);
|
|
307
|
+
return {
|
|
308
|
+
id,
|
|
309
|
+
...(date ? { date } : {}),
|
|
310
|
+
patch,
|
|
311
|
+
counts: {
|
|
312
|
+
...(proposedTitle ? { title: codePointLength(proposedTitle) } : {}),
|
|
313
|
+
...(typeof proposedSeo?.title === 'string' ? { seoTitle: codePointLength(proposedSeo.title) } : {}),
|
|
314
|
+
...(typeof proposedSeo?.description === 'string'
|
|
315
|
+
? { seoDescription: codePointLength(proposedSeo.description) }
|
|
316
|
+
: {}),
|
|
317
|
+
},
|
|
318
|
+
violations,
|
|
319
|
+
current: {
|
|
320
|
+
...(currentTitle ? { title: currentTitle } : {}),
|
|
321
|
+
...(currentSeoTitle ? { seoTitle: currentSeoTitle } : {}),
|
|
322
|
+
...(currentSeoDescription ? { seoDescription: currentSeoDescription } : {}),
|
|
323
|
+
...(currentSeason !== undefined ? { season: currentSeason } : {}),
|
|
324
|
+
...(currentEpisode !== undefined ? { episode: currentEpisode } : {}),
|
|
325
|
+
...(currentSlug ? { slug: currentSlug } : {}),
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function describeEntry(entry: BackfillEntry): string {
|
|
331
|
+
const seo = asRecord(asRecord(entry.patch._microfeed)?.seo);
|
|
332
|
+
const parts = [
|
|
333
|
+
`${entry.id}:`,
|
|
334
|
+
entry.date ? `date ${entry.date}` : 'date unknown',
|
|
335
|
+
`title ${JSON.stringify(entry.current.title ?? '')} → ${JSON.stringify(entry.patch.title ?? entry.current.title ?? '')} (${entry.counts.title ?? 0})`,
|
|
336
|
+
`seo.title ${JSON.stringify(entry.current.seoTitle ?? '')} → ${JSON.stringify(seo?.title ?? entry.current.seoTitle ?? '')} (${entry.counts.seoTitle ?? codePointLength(String(entry.current.seoTitle ?? ''))})`,
|
|
337
|
+
`seo.description (${entry.counts.seoDescription ?? codePointLength(String(entry.current.seoDescription ?? ''))})`,
|
|
338
|
+
];
|
|
339
|
+
if (entry.violations.length > 0) parts.push(`violations: ${entry.violations.join('; ')}`);
|
|
340
|
+
return parts.join(' | ');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function writePlan(path: string, entries: BackfillEntry[], apply: boolean): Promise<void> {
|
|
344
|
+
await ensureDirForFile(path);
|
|
345
|
+
await writeJsonFile(path, { dryRun: !apply, items: entries });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function loadPlan(path: string): Promise<{ entries: BackfillEntry[] } | { error: string }> {
|
|
349
|
+
try {
|
|
350
|
+
const parsed: unknown = await readJsonFile(path);
|
|
351
|
+
const record = asRecord(parsed);
|
|
352
|
+
const items = record?.items;
|
|
353
|
+
if (!Array.isArray(items)) return { error: `invalid backfill plan ${path}: expected { items: [...] }` };
|
|
354
|
+
const entries: BackfillEntry[] = [];
|
|
355
|
+
for (const item of items) {
|
|
356
|
+
const row = asRecord(item);
|
|
357
|
+
if (!row || typeof row.id !== 'string' || !asRecord(row.patch)) {
|
|
358
|
+
return { error: `invalid backfill plan ${path}: each item needs id and patch` };
|
|
359
|
+
}
|
|
360
|
+
entries.push(row as unknown as BackfillEntry);
|
|
361
|
+
}
|
|
362
|
+
return { entries };
|
|
363
|
+
} catch (error: unknown) {
|
|
364
|
+
return { error: `invalid backfill plan ${path}: ${error instanceof Error ? error.message : String(error)}` };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function withoutSlug(micro: Record<string, unknown>): Record<string, unknown> {
|
|
369
|
+
const rest: Record<string, unknown> = {};
|
|
370
|
+
for (const [key, value] of Object.entries(micro)) {
|
|
371
|
+
if (key !== 'slug') rest[key] = value;
|
|
372
|
+
}
|
|
373
|
+
return rest;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function putPatch(client: MicrofeedClient, entry: BackfillEntry, warnings: string[]): Promise<void> {
|
|
377
|
+
const micro = asRecord(entry.patch._microfeed);
|
|
378
|
+
const slug = typeof micro?.slug === 'string' ? micro.slug : undefined;
|
|
379
|
+
if (!slug || !micro) {
|
|
380
|
+
await client.updateItem(entry.id, entry.patch);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
await client.updateItem(entry.id, entry.patch);
|
|
385
|
+
} catch (error: unknown) {
|
|
386
|
+
if (!(error instanceof MicrofeedRequestError) || error.status !== 409) throw error;
|
|
387
|
+
const retry = `${slug}-2`;
|
|
388
|
+
try {
|
|
389
|
+
await client.updateItem(entry.id, { ...entry.patch, _microfeed: { ...micro, slug: retry } });
|
|
390
|
+
warn(`slug "${slug}" for ${entry.id} conflicted (HTTP 409); retried as ${retry}`, warnings);
|
|
391
|
+
} catch (retryError: unknown) {
|
|
392
|
+
if (!(retryError instanceof MicrofeedRequestError) || retryError.status !== 409) throw retryError;
|
|
393
|
+
const rest = withoutSlug(micro);
|
|
394
|
+
if (Object.keys(rest).length === 0) {
|
|
395
|
+
const { _microfeed: _dropped, ...other } = entry.patch;
|
|
396
|
+
if (Object.keys(other).length === 0) {
|
|
397
|
+
warn(
|
|
398
|
+
`slug "${slug}" for ${entry.id} conflicted twice (HTTP 409); skipped the slug and no other fields changed`,
|
|
399
|
+
warnings,
|
|
400
|
+
);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
await client.updateItem(entry.id, other);
|
|
404
|
+
} else {
|
|
405
|
+
await client.updateItem(entry.id, { ...entry.patch, _microfeed: rest });
|
|
406
|
+
}
|
|
407
|
+
warn(
|
|
408
|
+
`slug "${slug}" for ${entry.id} conflicted twice (HTTP 409); applied the patch without a slug`,
|
|
409
|
+
warnings,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Lists the feed and either prints a plan or PUTs the changed keys. */
|
|
416
|
+
export async function runBackfill(
|
|
417
|
+
client: MicrofeedClient,
|
|
418
|
+
options: BackfillOptions,
|
|
419
|
+
config: PodcastSeoConfig,
|
|
420
|
+
): Promise<Result> {
|
|
421
|
+
const parsed = parseFlags(options);
|
|
422
|
+
if ('error' in parsed) return fail(parsed.error);
|
|
423
|
+
const flags = parsed.flags;
|
|
424
|
+
const warnings: string[] = [];
|
|
425
|
+
let entries: BackfillEntry[];
|
|
426
|
+
if (flags.apply && flags.planPath) {
|
|
427
|
+
const loaded = await loadPlan(flags.planPath);
|
|
428
|
+
if ('error' in loaded) return fail(loaded.error);
|
|
429
|
+
entries = loaded.entries.filter((entry) => !flags.ids || flags.ids.has(entry.id));
|
|
430
|
+
} else {
|
|
431
|
+
const feed = await client.listItems();
|
|
432
|
+
entries = [];
|
|
433
|
+
for (const item of feed.items ?? []) {
|
|
434
|
+
const id = typeof item.id === 'string' ? item.id : '';
|
|
435
|
+
if (flags.ids && !flags.ids.has(id)) continue;
|
|
436
|
+
const date = dateFromFeedItem(item);
|
|
437
|
+
if (!inWindow(date, flags)) continue;
|
|
438
|
+
const planned = planBackfillItem(item, config, flags, flags.overrides.get(id));
|
|
439
|
+
if ('error' in planned) return fail(planned.error);
|
|
440
|
+
entries.push(planned);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
for (const entry of entries) echo(describeEntry(entry));
|
|
444
|
+
if (flags.out) await writePlan(flags.out, entries, flags.apply);
|
|
445
|
+
if (!flags.apply) {
|
|
446
|
+
return ResultSchema.parse({
|
|
447
|
+
ok: true,
|
|
448
|
+
target: TARGET,
|
|
449
|
+
message: `backfill-seo dry-run for ${entries.length} item(s); no writes`,
|
|
450
|
+
metadata: { dryRun: true, items: entries, warnings },
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
let written = 0;
|
|
454
|
+
for (const entry of entries) {
|
|
455
|
+
if (Object.keys(entry.patch).length === 0) continue;
|
|
456
|
+
await putPatch(client, entry, warnings);
|
|
457
|
+
written += 1;
|
|
458
|
+
}
|
|
459
|
+
return ResultSchema.parse({
|
|
460
|
+
ok: true,
|
|
461
|
+
target: TARGET,
|
|
462
|
+
message: `backfill-seo applied ${written} item(s)`,
|
|
463
|
+
metadata: { dryRun: false, applied: written, items: entries, warnings },
|
|
464
|
+
});
|
|
465
|
+
}
|