@alexkroman1/aai-cli 10.0.0 → 11.0.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.
Files changed (45) hide show
  1. package/dist/{_bundler-B31MqYaD.mjs → _bundler-C31rlcYz.mjs} +1 -1
  2. package/dist/{_dev-server-Bo5dSBzQ.mjs → _dev-server-DWvkNoIS.mjs} +3 -3
  3. package/dist/{_vite-env-BNveawd1.mjs → _vite-env-D9Qj6KPX.mjs} +4 -2
  4. package/dist/_workflow-determinism-f9J-5Zlj.mjs +206 -0
  5. package/dist/_workflow-determinism.d.ts +118 -0
  6. package/dist/{build-DKC2e9o_.mjs → build-BLlLOrng.mjs} +4 -2
  7. package/dist/cli.mjs +372 -353
  8. package/dist/{client-bundler-CWnG42cU.mjs → client-bundler-j5c46x0G.mjs} +3 -2
  9. package/dist/client-bundler.mjs +1 -1
  10. package/dist/{deploy-CchW5pmw.mjs → deploy-BzZ-r4iM.mjs} +6 -4
  11. package/dist/{dev-BfVl1uBA.mjs → dev-BDykFvcc.mjs} +1 -1
  12. package/dist/{eval-DwNwdqmS.mjs → eval-0qEOdm0l.mjs} +1 -1
  13. package/dist/{init-BRfoc2EK.mjs → init-BtJI306C.mjs} +1 -1
  14. package/dist/{login-BBuM1sxH.mjs → login-DeUETobb.mjs} +4 -1
  15. package/dist/scaffold/CLAUDE.md +190 -11
  16. package/dist/scaffold/package.json +4 -4
  17. package/dist/{studio-wVWNLREn.mjs → studio-BxAS_FQQ.mjs} +4 -1
  18. package/dist/templates/call-audit/agent.test.ts +105 -26
  19. package/dist/templates/call-audit/workflows/audit.ts +9 -26
  20. package/dist/templates/dispatch-center/client.tsx +36 -2
  21. package/dist/templates/infocom-adventure/client.tsx +37 -9
  22. package/dist/templates/link-digest/agent.eval.test.ts +1 -1
  23. package/dist/templates/link-digest/agent.test.ts +164 -8
  24. package/dist/templates/link-digest/workflows/digest.ts +63 -20
  25. package/dist/templates/podcast-digest/agent.eval.test.ts +7 -4
  26. package/dist/templates/podcast-digest/agent.test.ts +265 -24
  27. package/dist/templates/podcast-digest/workflows/digest.ts +2 -2
  28. package/dist/templates/podcast-digest/workflows/feeds.ts +78 -73
  29. package/dist/templates/recap-workflow/agent.test.ts +206 -20
  30. package/dist/templates/recap-workflow/workflows/recap.ts +17 -14
  31. package/dist/templates/redline/agent.test.ts +137 -11
  32. package/dist/templates/research-workflow/agent.eval.test.ts +1 -1
  33. package/dist/templates/research-workflow/agent.test.ts +131 -9
  34. package/dist/templates/research-workflow/workflows/research.ts +1 -1
  35. package/dist/templates/retail/client.tsx +30 -2
  36. package/dist/templates/spoken-summary/agent.eval.test.ts +4 -1
  37. package/dist/templates/spoken-summary/agent.test.ts +117 -8
  38. package/dist/templates/spoken-summary/workflows/summarize.ts +1 -1
  39. package/dist/templates/transcription-workflow/agent.test.ts +127 -13
  40. package/dist/templates/transcription-workflow/workflows/batch.ts +6 -6
  41. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -3
  42. package/dist/templates/transcription-workflow/workflows/transcribe.ts +4 -23
  43. package/dist/{test-CvwgeVSQ.mjs → test-CiLab-AA.mjs} +2 -1
  44. package/dist/worker-bundler.mjs +1 -1
  45. package/package.json +25 -24
@@ -2,18 +2,21 @@
2
2
  /**
3
3
  * What this template promises, checked without a network.
4
4
  *
5
- * Three kinds of thing are checked, and the workflow BODY is none of them: it is
6
- * only durable once the Workflow DevKit's build has transformed it, so a unit
7
- * test of it would exercise a plain async function and prove nothing about
8
- * replay. What IS here:
5
+ * Four kinds of thing are checked:
9
6
  *
10
7
  * - The DECLARATION — the config a deploy validates and the schema a `start()`
11
8
  * is checked against.
12
9
  * - The PURE helpers, pulled out of the flow for exactly this reason.
13
- * - The STEPS, directly. A step is an ordinary exported async function, so one
14
- * function is an ordinary async function, so its HTTP handling, its
15
- * partial-failure policy and its `FatalError` guards are all reachable —
16
- * `installStubStepFetch` answers the network and `stubGateway` answers the model.
10
+ * - The STEPS, directly. A step is an ordinary exported async function, so its
11
+ * HTTP handling, its partial-failure policy and its `FatalError` guards are
12
+ * all reachable `installStubStepFetch` answers the network and
13
+ * `stubGateway` answers the model.
14
+ * - The BODY, twice. `createWorkflowCtx` records what it asked for (the digest
15
+ * loop, the shrinking pending set, the sleep BETWEEN digests and never after
16
+ * the last), and `runWorkflow` runs it on the real replay engine — which is
17
+ * what this file used to say a unit test could not do, on the ground that a
18
+ * body "is only durable once the Workflow DevKit's build has transformed it".
19
+ * That stopped being true when the DevKit was replaced.
17
20
  *
18
21
  * The cases worth having are the ones where a mistake is SILENT: a schema that
19
22
  * accepts a webhook pointing anywhere, a Slack payload in the shape the other
@@ -21,11 +24,18 @@
21
24
  * that fails to transcribe taking the whole digest down with it.
22
25
  */
23
26
 
24
- import { createWorkflowCtx, parseSchemaInput, schemaInputIssues } from "@alexkroman1/aai/testing";
27
+ import { type FeedItem, parseFeed } from "@alexkroman1/aai/html";
28
+ import {
29
+ createWorkflowCtx,
30
+ parseSchemaInput,
31
+ schemaInputIssues,
32
+ stubGatewayRoute,
33
+ } from "@alexkroman1/aai/testing";
25
34
  import {
26
35
  installStubStepFetch,
27
36
  installStubGateway as stubGateway,
28
37
  } from "@alexkroman1/aai/testing/vitest";
38
+ import { runWorkflow } from "@alexkroman1/aai-runtime/testing";
29
39
  import { beforeEach, describe, expect, test, vi } from "vitest";
30
40
  import agentDef, { dailyDigest } from "./agent.ts";
31
41
  import {
@@ -199,23 +209,53 @@ describe("podcast links", () => {
199
209
  });
200
210
 
201
211
  describe("reading a feed", () => {
202
- const ITEM =
212
+ /**
213
+ * One entry, through the REAL parse.
214
+ *
215
+ * `episodeFromItem` takes a `FeedItem` now rather than a slice of XML, and
216
+ * building one by hand would test the mapping against a literal somebody
217
+ * typed. Going through `parseFeed` keeps the fixture what a feed actually
218
+ * says, which is where the interesting cases (CDATA, date formats, a missing
219
+ * enclosure) live.
220
+ */
221
+ const itemOf = (itemXml: string): FeedItem => {
222
+ const item = parseFeed(`<rss><channel>${itemXml}</channel></rss>`)?.items[0];
223
+ if (!item) throw new Error("fixture did not parse as one feed item");
224
+ return item;
225
+ };
226
+
227
+ const ITEM = itemOf(
203
228
  "<item><title>An Episode</title><link>https://example.com/ep</link>" +
204
- '<guid isPermaLink="false">guid-1</guid><pubDate>Fri, 21 Aug 2026 00:00:00 GMT</pubDate>' +
205
- '<enclosure url="https://example.com/a.mp3" type="audio/mpeg"/></item>';
229
+ '<guid isPermaLink="false">guid-1</guid><pubDate>Fri, 21 Aug 2026 00:00:00 GMT</pubDate>' +
230
+ '<enclosure url="https://example.com/a.mp3" type="audio/mpeg"/></item>',
231
+ );
206
232
 
207
233
  test("reads the fields a digest needs off an item", () => {
208
234
  expect(episodeFromItem(ITEM, "https://example.com/feed.xml", "Show", 0)).toMatchObject({
209
235
  title: "An Episode",
210
236
  url: "https://example.com/ep",
211
237
  audioUrl: "https://example.com/a.mp3",
212
- published: "Fri, 21 Aug 2026 00:00:00 GMT",
238
+ // ISO, not the RFC 822 the feed wrote — so an RSS and an Atom feed sort
239
+ // against each other instead of against two formats.
240
+ published: "2026-08-21T00:00:00.000Z",
213
241
  });
214
242
  });
215
243
 
244
+ test("HTML inside CDATA arrives as text, not as markup", () => {
245
+ // The case `stripCdata` got wrong: it peeled the wrapper and left the
246
+ // entity and the tags for the model to read.
247
+ const item = itemOf(
248
+ "<item><title><![CDATA[Fish &amp; <b>Chips</b>]]></title>" +
249
+ '<enclosure url="https://example.com/a.mp3"/></item>',
250
+ );
251
+ expect(episodeFromItem(item, "https://example.com/feed.xml", "Show", 0)?.title).toBe(
252
+ "Fish & Chips",
253
+ );
254
+ });
255
+
216
256
  /** A feed legitimately mixes text posts in; there is nothing to transcribe. */
217
257
  test("drops an item with no audio rather than inventing a URL", () => {
218
- const noAudio = "<item><title>A Post</title><link>https://example.com/p</link></item>";
258
+ const noAudio = itemOf("<item><title>A Post</title><link>https://example.com/p</link></item>");
219
259
  expect(episodeFromItem(noAudio, "https://example.com/feed.xml", "Show", 0)).toBeUndefined();
220
260
  });
221
261
 
@@ -227,13 +267,24 @@ describe("reading a feed", () => {
227
267
  expect(stableEpisodeId("a", "b")).not.toBe(stableEpisodeId("a", "c"));
228
268
  });
229
269
 
230
- test("requires BOTH an rss root and an enclosure to call it a podcast feed", () => {
231
- expect(looksLikePodcastFeed('<rss><enclosure url="https://a.test/a.mp3"/></rss>')).toBe(true);
232
- // A blog feed: RSS, but nothing to transcribe.
233
- expect(looksLikePodcastFeed("<rss><item><title>Post</title></item></rss>")).toBe(false);
270
+ test("requires BOTH a parseable feed and an enclosure to call it a podcast", () => {
271
+ const withAudio =
272
+ '<rss><channel><item><title>E</title><enclosure url="https://a.test/a.mp3"/></item></channel></rss>';
273
+ expect(looksLikePodcastFeed(withAudio)).toBe(true);
274
+ // A blog feed: a real feed, but nothing to transcribe.
275
+ expect(
276
+ looksLikePodcastFeed("<rss><channel><item><title>Post</title></item></channel></rss>"),
277
+ ).toBe(false);
234
278
  expect(looksLikePodcastFeed("<html></html>")).toBe(false);
235
279
  });
236
280
 
281
+ test("an ATOM podcast feed counts, which the `<rss` test refused outright", () => {
282
+ const atom =
283
+ '<feed xmlns="http://www.w3.org/2005/Atom"><title>Show</title>' +
284
+ '<entry><title>E</title><enclosure url="https://a.test/a.mp3"/></entry></feed>';
285
+ expect(looksLikePodcastFeed(atom)).toBe(true);
286
+ });
287
+
237
288
  test("finds an advertised feed in either attribute order, and resolves it", () => {
238
289
  const hrefFirst = '<link href="/feed.xml" type="application/rss+xml">';
239
290
  const typeFirst = '<link type="application/rss+xml" href="/feed.xml">';
@@ -531,21 +582,43 @@ describe("discoverEpisodes", () => {
531
582
  });
532
583
 
533
584
  test("names the four accepted shapes when a feed carries no episode audio", async () => {
534
- // An `<enclosure>` outside any `<item>`: it looks like a podcast feed and
535
- // yields nothing, which is the case a bare "no episodes" would not explain.
585
+ // Reached through APPLE, which resolves a feed URL from the lookup API and
586
+ // does not run it past `looksLikePodcastFeed` so this is the one route
587
+ // left to a feed that resolves and yields nothing, and the case a bare "no
588
+ // episodes" would not explain. The plain-web route can no longer get here:
589
+ // the predicate asks whether some ITEM carries audio, which is the same
590
+ // question `readPodcastFeed` filters on, so a text-only feed is refused
591
+ // earlier with a message that names the URL.
536
592
  stub({
593
+ "itunes.apple.com/lookup": {
594
+ body: { results: [{ feedUrl: "https://show.test/feed.xml", collectionName: "T" }] },
595
+ },
537
596
  "show.test/feed.xml": {
538
- body:
539
- '<rss><channel><title>T</title><enclosure url="https://cdn.test/x.mp3"/>' +
540
- "<item><title>No audio</title></item></channel></rss>",
597
+ body: "<rss><channel><title>T</title><item><title>No audio</title></item></channel></rss>",
541
598
  },
542
599
  });
543
600
 
544
- await expect(discoverEpisodes("https://show.test/feed.xml", 5)).rejects.toThrow(
601
+ await expect(
602
+ discoverEpisodes("https://podcasts.apple.com/us/podcast/x/id123", 5),
603
+ ).rejects.toThrow(
545
604
  /Apple Podcasts show link, a Spotify show link, an RSS feed URL, or a podcast homepage/,
546
605
  );
547
606
  });
548
607
 
608
+ test("a text-only feed pasted directly is refused by NAME, not as an empty digest", async () => {
609
+ // The other half of the change above: this used to reach the generic "no
610
+ // episodes" message, and now names the URL the person actually pasted.
611
+ stub({
612
+ "show.test/feed.xml": {
613
+ body: "<rss><channel><title>T</title><item><title>No audio</title></item></channel></rss>",
614
+ },
615
+ });
616
+
617
+ await expect(discoverEpisodes("https://show.test/feed.xml", 5)).rejects.toThrow(
618
+ /Could not find a podcast RSS feed at https:\/\/show\.test\/feed\.xml/,
619
+ );
620
+ });
621
+
549
622
  test("propagates a feed that will not load, so the run can retry it", async () => {
550
623
  stub({ "show.test/feed.xml": { status: 503, body: "busy" } });
551
624
  await expect(discoverEpisodes("https://show.test/feed.xml", 1)).rejects.toThrow(/503/);
@@ -820,3 +893,171 @@ describe("the body — the run that IS the schedule", () => {
820
893
  for (const sleep of ctx.slept) expect(sleep.until).toBe(POLL_DELAY_MS);
821
894
  });
822
895
  });
896
+
897
+ /**
898
+ * The schedule, on the real replay engine.
899
+ *
900
+ * The block above drives the same body through `createWorkflowCtx`, which
901
+ * RECORDS a sleep rather than taking one and replays nothing — right for the
902
+ * loop's logic, and silent about the property this template is: a run that
903
+ * sleeps for a day between digests and comes back. `runWorkflow`
904
+ * (`@alexkroman1/aai-runtime/testing`) is the real engine over an in-memory
905
+ * journal, so the run genuinely parks, genuinely resumes off its journal, and
906
+ * genuinely does not redo the digest it already posted.
907
+ *
908
+ * Two sleeps are in play and they are different things, which is exactly what a
909
+ * recorded one cannot show: the POLL cadence inside `waitForTranscripts`, and
910
+ * the SCHEDULE interval between digests. Both are `ctx.sleep`, so both end with
911
+ * `advanceSleep()` — the same call `ctx.workflows.wakeUp` makes.
912
+ */
913
+ describe("the run is DURABLE", () => {
914
+ const FEED = feedXml(itemXml("newest", "Thu, 03 Jan 2030 00:00:00 GMT"));
915
+ const SUMMARY = '{"summary":"Otters are clever.","keyPoints":["They use tools","They float"]}';
916
+
917
+ const SCHEDULE = {
918
+ ...VALID,
919
+ slackWorkflowTextParam: "text",
920
+ maxEpisodesPerDigest: 1,
921
+ intervalEvery: 2,
922
+ intervalUnit: "hours" as const,
923
+ };
924
+
925
+ /**
926
+ * The feed, the provider, the model and Slack, behind ONE published
927
+ * `stepFetch`.
928
+ *
929
+ * Every step's HTTP goes through the slot, the model call included, so a
930
+ * gateway stub over `globalThis.fetch` beside these routes would be bypassed.
931
+ * `stubGatewayRoute` is the composition for it — the same note
932
+ * `link-digest/agent.test.ts` carries.
933
+ *
934
+ * `polls` is how many `GET /v2/transcript/:id` answers say "not yet" before
935
+ * the job is reported done, which is how a case decides whether the run has
936
+ * to sit through the poll cadence at all.
937
+ */
938
+ function stubWorld({ polls = 0 }: { polls?: number } = {}) {
939
+ const model = stubGatewayRoute(SUMMARY);
940
+ const seen = { feeds: 0, submits: 0, slack: 0, polls: 0 };
941
+ installStubStepFetch((request) => {
942
+ const routed = model.route(request);
943
+ if (routed) return routed;
944
+ if (request.url.includes("hooks.slack.com")) {
945
+ seen.slack += 1;
946
+ return { status: 200, body: "ok" };
947
+ }
948
+ if (request.url.includes("/v2/transcript")) {
949
+ if (request.method === "POST") {
950
+ seen.submits += 1;
951
+ return { status: 200, body: { id: "t_1", status: "queued" } };
952
+ }
953
+ seen.polls += 1;
954
+ return seen.polls <= polls
955
+ ? { status: 200, body: { id: "t_1", status: "processing" } }
956
+ : { status: 200, body: { id: "t_1", status: "completed", text: "Otters use tools." } };
957
+ }
958
+ seen.feeds += 1;
959
+ return { status: 200, body: FEED, headers: { "Content-Type": "application/xml" } };
960
+ });
961
+ return { model, seen };
962
+ }
963
+
964
+ beforeEach(() => vi.stubEnv("ASSEMBLYAI_API_KEY", "test-key"));
965
+
966
+ test("a one-digest run ends without sleeping, and posts once", async () => {
967
+ // The negative half of the schedule claim, and the cheap one: a run that has
968
+ // delivered everything it owes should END, not sleep for a day and then end.
969
+ const world = stubWorld();
970
+ const run = await runWorkflow(
971
+ dailyDigest,
972
+ { ...SCHEDULE, daysToRun: 1 },
973
+ { name: "dailyDigest" },
974
+ );
975
+
976
+ expect(run.status).toBe("completed");
977
+ expect(run.output?.digestsSent).toBe(1);
978
+ expect(run.deliveries).toBe(1);
979
+ expect(run.wakeAt).toBeUndefined();
980
+ expect(world.seen.slack).toBe(1);
981
+ });
982
+
983
+ test("parks on the poll cadence, and the resume does not re-submit the episode", async () => {
984
+ // The transcription is already paid for; a resume that submitted again would
985
+ // buy it twice and get a different job id.
986
+ const world = stubWorld({ polls: 1 });
987
+ const run = await runWorkflow(
988
+ dailyDigest,
989
+ { ...SCHEDULE, daysToRun: 1 },
990
+ { name: "dailyDigest" },
991
+ );
992
+
993
+ expect(run.status).toBe("running");
994
+ expect(run.wakeAt).toBeGreaterThan(Date.now());
995
+ expect(world.seen.submits).toBe(1);
996
+ expect(run.steps.map((step) => step.key)).toEqual([
997
+ "discoverEpisodes#0",
998
+ "pollTranscript#0",
999
+ "submitTranscript#0",
1000
+ ]);
1001
+
1002
+ await run.advanceSleep();
1003
+ expect(run.status).toBe("completed");
1004
+ expect(run.output?.digestsSent).toBe(1);
1005
+ // ONE submit across two walks — the first is read back out of the journal.
1006
+ expect(world.seen.submits).toBe(1);
1007
+ expect(world.seen.polls).toBe(2);
1008
+ });
1009
+
1010
+ test("sleeps the SCHEDULE between digests, then runs the second one", async () => {
1011
+ const world = stubWorld();
1012
+ const started = Date.now();
1013
+ const run = await runWorkflow(
1014
+ dailyDigest,
1015
+ { ...SCHEDULE, daysToRun: 2 },
1016
+ { name: "dailyDigest" },
1017
+ );
1018
+
1019
+ // Digest one is posted and the run is parked on the interval — two hours,
1020
+ // which nothing here waits for.
1021
+ expect(run.status).toBe("running");
1022
+ expect(run.wakeAt).toBeGreaterThanOrEqual(started + scheduleIntervalMs(2, "hours"));
1023
+ expect(world.seen.slack).toBe(1);
1024
+ expect(run.output).toBeUndefined();
1025
+
1026
+ await run.advanceSleep();
1027
+ expect(run.status).toBe("completed");
1028
+ expect(run.output?.digestsSent).toBe(2);
1029
+ expect(run.deliveries).toBe(2);
1030
+ // TWO posts, and the second digest did its own discovery — the loop's second
1031
+ // turn is `discoverEpisodes#1`, a different journal entry from the first, so
1032
+ // a schedule really re-reads the feed rather than replaying yesterday's.
1033
+ expect(world.seen.slack).toBe(2);
1034
+ expect(run.steps.map((step) => step.key)).toContain("discoverEpisodes#1");
1035
+ // And digest one's post is NOT made a second time: one `postDigest` entry
1036
+ // per digest, both journaled.
1037
+ expect(run.steps.filter((step) => step.name === "postDigest")).toHaveLength(2);
1038
+ });
1039
+
1040
+ test("a worker that dies before the digest is posted replays the transcription", async () => {
1041
+ // The expensive half. Transcribing and summarizing an episode is minutes of
1042
+ // provider time; a resume must reach Slack without paying for either again.
1043
+ const world = stubWorld();
1044
+ const run = await runWorkflow(
1045
+ dailyDigest,
1046
+ { ...SCHEDULE, daysToRun: 1 },
1047
+ { name: "dailyDigest", crashAt: "postDigest" },
1048
+ );
1049
+
1050
+ expect(run.crashed).toBe(true);
1051
+ expect(world.seen.slack).toBe(0);
1052
+ expect(world.seen.submits).toBe(1);
1053
+ expect(world.model.calls).toHaveLength(1);
1054
+
1055
+ await run.restart();
1056
+ expect(run.status).toBe("completed");
1057
+ expect(run.output?.digestsSent).toBe(1);
1058
+ expect(world.seen.slack).toBe(1);
1059
+ // Neither the provider nor the model was asked a second time.
1060
+ expect(world.seen.submits).toBe(1);
1061
+ expect(world.model.calls).toHaveLength(1);
1062
+ });
1063
+ });
@@ -227,7 +227,7 @@ export async function dailyDigestFlow(
227
227
 
228
228
  // Not after the last one: a run that has delivered everything it owes
229
229
  // should end, not sleep for a day and then end.
230
- if (digestNumber < totalDigests) await ctx.sleep(intervalMs);
230
+ if (digestNumber < totalDigests) await ctx.sleep("nextDigest", intervalMs);
231
231
  }
232
232
 
233
233
  return {
@@ -284,7 +284,7 @@ async function waitForTranscripts(
284
284
  if (state.transcriptStatus !== "submitted") settled.set(state.id, state);
285
285
  }
286
286
  pending = polled.filter((state) => state.transcriptStatus === "submitted");
287
- if (pending.length > 0) await ctx.sleep(POLL_DELAY_MS);
287
+ if (pending.length > 0) await ctx.sleep("poll", POLL_DELAY_MS);
288
288
  }
289
289
 
290
290
  for (const job of pending) settled.set(job.id, gaveUpOn(job));
@@ -33,20 +33,41 @@
33
33
  * somebody runs it. A template may import the SDK, `workflow`, `zod` and React.
34
34
  * Anything else has to earn a place in the scaffold manifest first.
35
35
  *
36
- * ## Regex over a real XML parser, deliberately
36
+ * ## A real parse, once the SDK could offer one
37
37
  *
38
- * There is no DOM in a step artifact and no XML parser in the scaffold, so the
39
- * feed reading below is regular expressions over the raw body. This is the
40
- * honest trade rather than a shortcut: a podcast feed is machine-generated and
41
- * the two fields that matter (`<enclosure url>` and `<item>`) are stable across
42
- * every generator in the wild, so the failure mode is a missing episode rather
43
- * than a corrupted one. {@link episodeFromItem} drops an item it cannot read a
44
- * media URL from instead of inventing one.
38
+ * The feed reading below used to be regular expressions over the raw body, on
39
+ * the argument that "there is no DOM in a step artifact and no XML parser in
40
+ * the scaffold". The second half was the load-bearing one and it stopped being
41
+ * true: `@alexkroman1/aai/html` publishes the parsers the SDK already carried
42
+ * for its own builtins, so a template can reach them under the same rule as
43
+ * `zod` it is the SDK.
44
+ *
45
+ * Three things the patterns got wrong, all of them on feeds that exist:
46
+ *
47
+ * - `textBetween(xml, "<title>", "</title>")` read the FIRST `<title>` at any
48
+ * depth. In an Atom feed that is the channel's only by luck of ordering; the
49
+ * same read on an entry-first document answers with an episode's title.
50
+ * - `stripCdata` peeled `<![CDATA[…]]>` and left the HTML inside it. Show notes
51
+ * are HTML in CDATA as a matter of course, so `&amp;` reached the model as an
52
+ * entity and `<p>` tags reached it as tags.
53
+ * - `<pubDate>` and `<published>` were tried in turn and the raw string handed
54
+ * on, so a caller had to parse a date whose format depended on the feed
55
+ * (RFC 822 from RSS, ISO from Atom).
56
+ *
57
+ * What the patterns got RIGHT is kept: {@link episodeFromItem} still drops an
58
+ * entry it cannot read a media URL from rather than inventing one, because a
59
+ * feed legitimately mixes text posts in with episodes.
60
+ *
61
+ * One regex remains, in {@link extractAppleSerializedFeed}, and it is a
62
+ * different kind of thing: it reaches into ONE vendor's `<script>` tag for a
63
+ * JSON blob, which is vendor knowledge with a short half-life rather than XML
64
+ * parsing. It is the section below's subject, not this one's.
45
65
  */
46
66
 
67
+ import { type FeedItem, pageMetadata, parseFeed } from "@alexkroman1/aai/html";
47
68
  import { report } from "@alexkroman1/aai/step";
48
69
  import { FatalError, stepFetchOk } from "@alexkroman1/aai/step-errors";
49
- import { decodeHtmlEntities, isRecord, omitUndefined, safeJsonParse } from "@alexkroman1/aai/utils";
70
+ import { isRecord, omitUndefined, safeJsonParse } from "@alexkroman1/aai/utils";
50
71
 
51
72
  /** How long any one of these lookups may take before it is a failure. */
52
73
  const REQUEST_TIMEOUT_MS = 30_000;
@@ -190,8 +211,11 @@ async function resolveApplePodcastFeed(url: string): Promise<PodcastFeed> {
190
211
  */
191
212
  async function resolveSpotifyPodcastFeed(url: string): Promise<PodcastFeed> {
192
213
  const html = await fetchText(url);
193
- const title = metaContent(html, "og:title") ?? textBetween(html, "<title>", "</title>") ?? "";
194
- const description = metaContent(html, "og:description") ?? "";
214
+ // `pageMetadata` already prefers `og:title` over the `<title>` element and
215
+ // falls back to it, which is exactly the two-step this spelled out.
216
+ const meta = pageMetadata(html);
217
+ const title = meta.title ?? "";
218
+ const description = meta.description ?? "";
195
219
  const query = `${title} ${description}`.replace(/\s+/g, " ").trim();
196
220
  if (!query) throw new FatalError(`Could not read podcast metadata from ${url}.`);
197
221
 
@@ -212,45 +236,42 @@ async function resolveSpotifyPodcastFeed(url: string): Promise<PodcastFeed> {
212
236
  /** Every item in the feed that has audio attached, newest first by the caller. */
213
237
  async function readPodcastFeed(feed: PodcastFeed): Promise<Episode[]> {
214
238
  const xml = feed.xml ?? (await fetchText(feed.feedUrl));
215
- const podcastTitle = decodeHtmlEntities(textBetween(xml, "<title>", "</title>") ?? feed.title);
239
+ const parsed = parseFeed(xml);
240
+ // `parsed.title` is the CHANNEL's, which is the whole reason to parse: the
241
+ // `indexOf` read this replaces took the first `<title>` at any depth.
242
+ const podcastTitle = parsed?.title ?? feed.title;
216
243
 
217
- return [...xml.matchAll(/<item[\s\S]*?<\/item>/g)]
218
- .map((match, index) => episodeFromItem(match[0], feed.feedUrl, podcastTitle, index))
244
+ return (parsed?.items ?? [])
245
+ .map((item, index) => episodeFromItem(item, feed.feedUrl, podcastTitle, index))
219
246
  .filter((episode): episode is Episode => episode !== undefined);
220
247
  }
221
248
 
222
- /** One `<item>`, or nothing when it carries no audio to transcribe. */
249
+ /** One feed entry, or nothing when it carries no audio to transcribe. */
223
250
  export function episodeFromItem(
224
- item: string,
251
+ item: FeedItem,
225
252
  feedUrl: string,
226
253
  podcastTitle: string,
227
254
  index: number,
228
255
  ): Episode | undefined {
229
- const audioUrl = enclosureUrl(item);
256
+ const audioUrl = item.enclosureUrl;
230
257
  // Not an error: a feed legitimately mixes text posts in with episodes, and
231
258
  // there is nothing for this run to do with one.
232
- if (!audioUrl) return undefined;
259
+ if (audioUrl === undefined) return undefined;
233
260
 
234
- const guid = stripCdata(
235
- textBetween(item, "<guid", "</guid>")?.replace(/^[^>]*>/, "") ?? "",
236
- ).trim();
237
- const link = decodeHtmlEntities(stripCdata(textBetween(item, "<link>", "</link>") ?? audioUrl));
261
+ const link = item.link ?? audioUrl;
238
262
  return {
239
- id: stableEpisodeId(feedUrl, guid || link || audioUrl),
263
+ // `item.id` is the feed's `<guid>` or Atom `<id>`, already falling back to
264
+ // the link and then the enclosure — which is the three-way fallback this
265
+ // function used to spell out.
266
+ id: stableEpisodeId(feedUrl, item.id ?? audioUrl),
240
267
  feedUrl,
241
268
  podcastTitle,
242
- title: decodeHtmlEntities(
243
- stripCdata(textBetween(item, "<title>", "</title>") ?? `Episode ${index + 1}`),
244
- ),
269
+ title: item.title ?? `Episode ${index + 1}`,
245
270
  url: link,
246
- audioUrl: decodeHtmlEntities(audioUrl),
247
- published: decodeHtmlEntities(
248
- stripCdata(
249
- textBetween(item, "<pubDate>", "</pubDate>") ??
250
- textBetween(item, "<published>", "</published>") ??
251
- "",
252
- ),
253
- ),
271
+ audioUrl,
272
+ // ISO whatever the feed wrote, so `publishedAt` below sorts RSS and Atom
273
+ // against each other rather than against two different formats.
274
+ published: item.published ?? "",
254
275
  };
255
276
  }
256
277
 
@@ -371,21 +392,32 @@ export function titleMatchesSpotify(
371
392
  );
372
393
  }
373
394
 
374
- /** An RSS document that actually carries audio — both halves are required. */
395
+ /** A feed document that actually carries audio — both halves are required. */
375
396
  export function looksLikePodcastFeed(xml: string): boolean {
376
- return /<rss\b/i.test(xml) && /<enclosure\b[^>]*\burl=/i.test(xml);
397
+ const parsed = parseFeed(xml);
398
+ // `parseFeed` answering at all is the "is this a feed" half, and it is
399
+ // stricter than the `<rss` test it replaces in the direction that matters:
400
+ // an HTML page mentioning `<rss` in prose is not a feed. It is also wider
401
+ // where being wide is right — an Atom podcast feed has no `<rss` root and
402
+ // was refused outright.
403
+ return parsed?.items.some((item) => item.enclosureUrl !== undefined) ?? false;
377
404
  }
378
405
 
379
- /** The feed a page advertises, in either attribute order. */
406
+ /** The first feed a page advertises, resolved against the page's own URL. */
380
407
  export function discoverFeedUrl(html: string, pageUrl: string): string | undefined {
381
- const match =
382
- /<link\b[^>]*type=["']application\/(?:rss|atom)\+xml["'][^>]*href=["']([^"']+)["'][^>]*>/i.exec(
383
- html,
384
- ) ??
385
- /<link\b[^>]*href=["']([^"']+)["'][^>]*type=["']application\/(?:rss|atom)\+xml["'][^>]*>/i.exec(
386
- html,
387
- );
388
- return match?.[1] ? new URL(decodeHtmlEntities(match[1]), pageUrl).toString() : undefined;
408
+ // `pageMetadata` reads `<link>` off a parse, so attribute ORDER stops
409
+ // mattering — this needed one regex per order and still could not see a `>`
410
+ // inside a quoted href. It hands hrefs back exactly as the page wrote them,
411
+ // which is why the resolution is here: only the caller knows `pageUrl`.
412
+ const href = pageMetadata(html).feedUrls[0];
413
+ if (href === undefined) return undefined;
414
+ try {
415
+ return new URL(href, pageUrl).toString();
416
+ } catch {
417
+ // A page can advertise an href that is not a URL under any base. That is
418
+ // "no feed found", which the caller already has a sentence for.
419
+ return undefined;
420
+ }
389
421
  }
390
422
 
391
423
  /**
@@ -411,7 +443,7 @@ function publishedAt(episode: Episode): number {
411
443
  function feedFrom(feedUrl: string, body: string): PodcastFeed {
412
444
  return {
413
445
  feedUrl,
414
- title: decodeHtmlEntities(textBetween(body, "<title>", "</title>") ?? hostOf(feedUrl)),
446
+ title: parseFeed(body)?.title ?? hostOf(feedUrl),
415
447
  xml: body,
416
448
  };
417
449
  }
@@ -424,10 +456,6 @@ function hostOf(url: string): string {
424
456
  }
425
457
  }
426
458
 
427
- function enclosureUrl(item: string): string | undefined {
428
- return /<enclosure\b[^>]*\burl=["']([^"']+)["'][^>]*>/i.exec(item)?.[1];
429
- }
430
-
431
459
  function normalizeTitle(value: string): string {
432
460
  return value
433
461
  .toLowerCase()
@@ -458,29 +486,6 @@ function asString(value: unknown): string | undefined {
458
486
  return typeof value === "string" ? value : undefined;
459
487
  }
460
488
 
461
- export function textBetween(text: string, start: string, end: string): string | undefined {
462
- const from = text.indexOf(start);
463
- if (from < 0) return undefined;
464
- const to = text.indexOf(end, from + start.length);
465
- return to < 0 ? undefined : text.slice(from + start.length, to);
466
- }
467
-
468
- export function metaContent(html: string, property: string): string | undefined {
469
- const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
470
- const match =
471
- new RegExp(`<meta[^>]+property=["']${escaped}["'][^>]+content=["']([^"']+)["']`, "i").exec(
472
- html,
473
- ) ??
474
- new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']${escaped}["']`, "i").exec(
475
- html,
476
- );
477
- return decodeHtmlEntities(match?.[1] ?? "").trim() || undefined;
478
- }
479
-
480
- function stripCdata(text: string): string {
481
- return text.replace(/^<!\[CDATA\[/, "").replace(/\]\]>$/, "");
482
- }
483
-
484
489
  // ---- HTTP -------------------------------------------------------------------
485
490
 
486
491
  /**