@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.
- package/package.json +35 -0
- 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
|