@eto-press/engine-sports 2.20260816.2

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 (2) hide show
  1. package/package.json +35 -0
  2. package/src/engine.ts +167 -0
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@eto-press/engine-sports",
3
+ "version": "2.20260816.2",
4
+ "license": "AGPL-3.0-only",
5
+ "type": "module",
6
+ "description": "The sports engine: signed columns from the desk, the day's results and stories from the leagues' own feeds — one paper, two corpus origins, zero models.",
7
+ "exports": {
8
+ "./*": "./src/*.ts"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "scripts": {
18
+ "test": "vitest run",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "dependencies": {
22
+ "@effect/platform": "^0.97.0",
23
+ "@effect/sql": "^0.52.0",
24
+ "effect": "^3.22.0",
25
+ "@eto-press/platform": "^2.20260816.2"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^24.13.3",
29
+ "typescript": "^5.9.3",
30
+ "vitest": "^3.2.7"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The sports engine — the first paper with two corpus origins.
3
+ *
4
+ * From the desk: signed columns. A desk/*.md entry opens with its
5
+ * headline and an optional byline line ("by: Mark"), and prints once as
6
+ * a bylined story — the human voice, leading the edition.
7
+ *
8
+ * From the world: the leagues. The masthead's sources are leagues (the
9
+ * `side` field names the section — "NBA", "Serie A"); their feeds are
10
+ * the leagues' own front doors, and each morning's first-seen links
11
+ * print as one link-list story per league. Results link to the record.
12
+ *
13
+ * Deliberately v0: no score tables, no figures, no email images — the
14
+ * blocks this engine will eventually force are deferred until its
15
+ * papers demand them (docs/CHANGELOG-GEN2.md). No models anywhere.
16
+ */
17
+ import { SqlClient } from "@effect/sql"
18
+ import { Effect } from "effect"
19
+ import { createHash } from "node:crypto"
20
+ import { Desk, type DeskEntry } from "@eto-press/platform/desk"
21
+ import { editionStoryFrom, type EditionStory, type LinkItem } from "@eto-press/platform/edition"
22
+ import type { Day, EngineOutcome } from "@eto-press/platform/engine"
23
+ import { ingestAllFeeds } from "@eto-press/platform/feeds"
24
+ import { capText } from "@eto-press/platform/frontdoor"
25
+ import type { Classifier, Item } from "@eto-press/platform/normalize"
26
+
27
+ const everythingIsNews: Classifier = () => "news"
28
+ const LINKS_PER_LEAGUE = 6
29
+ const BLURB_CAP = 140
30
+
31
+ const sha256 = (s: string): string => createHash("sha256").update(s).digest("hex")
32
+
33
+ /** A column: "# headline", an optional "by: Name" line, then the body. */
34
+ export const parseColumn = (
35
+ entry: DeskEntry
36
+ ): { headline: string; byline: string | null; body: string } => {
37
+ const lines = entry.content.split(/\r?\n/)
38
+ const headingAt = lines.findIndex((l) => /^#\s+\S/.test(l))
39
+ const headline =
40
+ headingAt >= 0
41
+ ? lines[headingAt]!.replace(/^#\s+/, "").trim()
42
+ : entry.file.replace(/\.md$/, "").replace(/[-_]+/g, " ").trim()
43
+ const rest = headingAt >= 0 ? lines.slice(headingAt + 1) : lines
44
+ const firstContent = rest.findIndex((l) => l.trim() !== "")
45
+ const bylineMatch =
46
+ firstContent >= 0 ? rest[firstContent]!.trim().match(/^by:?\s+(.+)$/i) : null
47
+ const byline = bylineMatch?.[1]?.trim() ?? null
48
+ const body = (byline !== null ? rest.slice(firstContent + 1) : rest).join("\n").trim()
49
+ return { headline, byline, body }
50
+ }
51
+
52
+ export const toLeagueLink = (item: Item): LinkItem => {
53
+ const blurb = item.summary.trim() === "" ? null : capText(item.summary, BLURB_CAP)
54
+ return {
55
+ title: item.title,
56
+ href: item.link,
57
+ note: blurb === null ? item.outlet : `${item.outlet} · ${blurb}`
58
+ }
59
+ }
60
+
61
+ const edition = (day: Day) =>
62
+ Effect.gen(function* () {
63
+ const sql = yield* SqlClient.SqlClient
64
+ const desk = yield* Desk
65
+
66
+ const stories: Array<EditionStory> = []
67
+
68
+ // -- the columns lead: the signed human voice -----------------------------
69
+ const entries = yield* desk.entries
70
+ for (const entry of entries) {
71
+ const hash = sha256(entry.content)
72
+ const ref = `sports:col:${hash}`
73
+ const printed = yield* sql<{ one: number }>`
74
+ SELECT 1 AS one FROM published_stories WHERE engine_ref = ${ref} LIMIT 1
75
+ `
76
+ if (printed.length > 0) continue
77
+ const { headline, byline, body } = parseColumn(entry)
78
+ stories.push(
79
+ editionStoryFrom({
80
+ headline,
81
+ body,
82
+ differ: "",
83
+ sourcesLine: "",
84
+ balanceNote: null,
85
+ foldReason: null,
86
+ linkByOutlet: new Map(),
87
+ engineRef: ref,
88
+ byline
89
+ })
90
+ )
91
+ yield* Effect.logInfo(
92
+ ` column: ${headline.slice(0, 60)}${byline !== null ? ` (by ${byline})` : ""}`
93
+ )
94
+ }
95
+
96
+ // -- the leagues follow: first-seen links per section ---------------------
97
+ const { items, outcomes } = yield* ingestAllFeeds(day.masthead, day.runId, everythingIsNews)
98
+ const firstSeen = yield* sql<{ link: string }>`
99
+ SELECT link FROM items WHERE run_id = ${day.runId}
100
+ `
101
+ const freshLinks = new Set(firstSeen.map((r) => r.link))
102
+ const fresh = items.filter((i) => freshLinks.has(i.link))
103
+
104
+ const seenLeague = new Set<string>()
105
+ for (const league of day.masthead.source.map((s) => s.side)) {
106
+ if (seenLeague.has(league)) continue
107
+ seenLeague.add(league)
108
+ const links = fresh
109
+ .filter((i) => i.side === league)
110
+ .slice(0, LINKS_PER_LEAGUE)
111
+ .map(toLeagueLink)
112
+ if (links.length === 0) continue
113
+ const ref = `sports:${day.runId}:${league}`
114
+ const printed = yield* sql<{ one: number }>`
115
+ SELECT 1 AS one FROM published_stories WHERE engine_ref = ${ref} LIMIT 1
116
+ `
117
+ if (printed.length > 0) continue
118
+ stories.push(
119
+ editionStoryFrom({
120
+ headline: league,
121
+ body: "",
122
+ differ: "",
123
+ sourcesLine: "",
124
+ balanceNote: null,
125
+ foldReason: null,
126
+ linkByOutlet: new Map(),
127
+ engineRef: ref,
128
+ links
129
+ })
130
+ )
131
+ yield* Effect.logInfo(` league ${league}: ${links.length} link(s)`)
132
+ }
133
+
134
+ if (stories.length === 0) {
135
+ return {
136
+ _tag: "NoEdition",
137
+ reason: `no new columns and no new league links (${outcomes.length} feeds read)`
138
+ } satisfies EngineOutcome
139
+ }
140
+
141
+ const columns = stories.filter((s) => s.byline !== null).length
142
+ return {
143
+ _tag: "Edition",
144
+ stories,
145
+ report: {
146
+ feedOutcomes: outcomes,
147
+ dropped: [],
148
+ healthLines: [
149
+ `${columns} column(s) from the desk; ${stories.length - columns} league section(s) from the world.`
150
+ ]
151
+ },
152
+ advisoryLines: []
153
+ } satisfies EngineOutcome
154
+ }).pipe(Effect.withSpan("sports.edition"))
155
+
156
+ /** The sports engine: signed columns, the leagues' own links, no models. */
157
+ export const sportsEngine = {
158
+ name: "sports",
159
+ doctrine: [
160
+ "The results are facts; the columns are signed.",
161
+ "A byline means a human. The press never fakes one.",
162
+ "Results link to the record; opinions link to their author.",
163
+ "Nothing new, no edition. The off-season is quiet."
164
+ ],
165
+ models: [],
166
+ edition
167
+ } as const