thinking-phrases 1.0.1 → 2.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 (41) hide show
  1. package/README.md +230 -142
  2. package/configs/hn-top.config.json +60 -27
  3. package/launchd/rss-update.error.log +3 -27
  4. package/launchd/rss-update.log +308 -0
  5. package/launchd/task-health.json +54 -0
  6. package/out/dwyl-quotes.json +1621 -0
  7. package/out/javascript-tips.json +107 -0
  8. package/out/league-loading-screen-tips.json +107 -0
  9. package/out/ruby-tips.json +115 -0
  10. package/out/settings-linux.json +87 -0
  11. package/out/settings-mac.json +87 -0
  12. package/out/settings-windows.json +87 -0
  13. package/out/typescript-tips.json +131 -0
  14. package/out/vscode-tips.json +87 -0
  15. package/out/wow-loading-screen-tips.json +116 -0
  16. package/package.json +19 -12
  17. package/scripts/build.ts +3 -3
  18. package/scripts/debug-hn-hydration.ts +33 -0
  19. package/scripts/run-rss-update.zsh +25 -3
  20. package/scripts/show-thinking-phrases-health.ts +74 -0
  21. package/scripts/trigger-thinking-phrases-scheduler.zsh +50 -0
  22. package/src/core/config.ts +65 -3
  23. package/src/core/githubModels.ts +200 -112
  24. package/src/core/interactive.ts +49 -67
  25. package/src/core/phraseCache.ts +242 -0
  26. package/src/core/phraseFormats.ts +243 -0
  27. package/src/core/presets.ts +1 -1
  28. package/src/core/runner.ts +246 -113
  29. package/src/core/scheduler.ts +1 -1
  30. package/src/core/taskHealth.ts +213 -0
  31. package/src/core/types.ts +32 -8
  32. package/src/core/utils.ts +27 -2
  33. package/src/sources/customJson.ts +28 -18
  34. package/src/sources/earthquakes.ts +4 -4
  35. package/src/sources/githubActivity.ts +120 -48
  36. package/src/sources/hackerNews.ts +19 -7
  37. package/src/sources/rss.ts +25 -11
  38. package/src/sources/stocks.ts +31 -10
  39. package/src/sources/weatherAlerts.ts +173 -7
  40. package/tsconfig.json +1 -1
  41. package/scripts/update-rss-settings.ts +0 -7
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import type { ArticleItem, Config, GitHubActivityConfig, GitHubFeedKind, PhraseSource } from '../core/types.js';
3
- import { fetchJson, fetchText, logDebug, logInfo, relativeTime, singleLine, truncate } from '../core/utils.js';
2
+ import { formatGitHubCommitPhrase, formatGitHubFeedPhrase } from '../core/phraseFormats.js';
3
+ import type { ArticleItem, Config, GitHubActivityConfig, GitHubFeedKind, PhraseFormatTemplates, PhraseSource } from '../core/types.js';
4
+ import { USER_AGENT, fetchJson, fetchText, hoursToMs, logDebug, logInfo, relativeTime, singleLine } from '../core/utils.js';
4
5
  import { hydrateArticleContent, parseFeedArticles } from './rss.js';
5
6
 
6
7
  interface GitHubCommitListItem {
@@ -24,14 +25,14 @@ interface GitHubCommitDetail extends GitHubCommitListItem {
24
25
  deletions?: number;
25
26
  total?: number;
26
27
  };
27
- files?: Array<{
28
+ files?: {
28
29
  filename?: string;
29
30
  status?: string;
30
31
  additions?: number;
31
32
  deletions?: number;
32
33
  changes?: number;
33
34
  patch?: string;
34
- }>;
35
+ }[];
35
36
  }
36
37
 
37
38
  interface GitHubCommitContext {
@@ -93,17 +94,30 @@ interface GitHubAuthenticatedUser {
93
94
 
94
95
  const GITHUB_ACCEPT = 'application/vnd.github+json';
95
96
  const GITHUB_API_VERSION = '2022-11-28';
97
+ let hasWarnedAboutRejectedGitHubToken = false;
98
+ let hasWarnedAboutInvalidConfiguredToken = false;
96
99
 
97
- function getGitHubToken(config: GitHubActivityConfig): string | undefined {
98
- const envToken = process.env[config.tokenEnvVar] ?? process.env.GITHUB_TOKEN;
99
- if (envToken && !envToken.includes('replace_me')) {
100
- return envToken;
101
- }
100
+ interface GitHubTokenCandidate {
101
+ source: string;
102
+ token: string;
103
+ }
102
104
 
105
+ function isPlaceholderToken(token?: string): boolean {
106
+ return !token || token.includes('replace_me');
107
+ }
108
+
109
+ function getGitHubCliToken(): string | undefined {
103
110
  try {
104
111
  const token = execFileSync('gh', ['auth', 'token'], {
105
112
  encoding: 'utf8',
106
113
  stdio: ['ignore', 'pipe', 'ignore'],
114
+ env: {
115
+ ...process.env,
116
+ GITHUB_TOKEN: '',
117
+ GH_TOKEN: '',
118
+ GITHUB_ENTERPRISE_TOKEN: '',
119
+ GH_ENTERPRISE_TOKEN: '',
120
+ },
107
121
  }).trim();
108
122
 
109
123
  return token || undefined;
@@ -112,6 +126,61 @@ function getGitHubToken(config: GitHubActivityConfig): string | undefined {
112
126
  }
113
127
  }
114
128
 
129
+ function getGitHubTokenCandidates(config: GitHubActivityConfig): GitHubTokenCandidate[] {
130
+ const candidates: GitHubTokenCandidate[] = [];
131
+ const seen = new Set<string>();
132
+
133
+ const addCandidate = (source: string, token?: string): void => {
134
+ if (isPlaceholderToken(token) || !token || seen.has(token)) {
135
+ return;
136
+ }
137
+
138
+ seen.add(token);
139
+ candidates.push({ source, token });
140
+ };
141
+
142
+ addCandidate(config.tokenEnvVar, process.env[config.tokenEnvVar]);
143
+ if (config.tokenEnvVar !== 'GITHUB_TOKEN') {
144
+ addCandidate('GITHUB_TOKEN', process.env.GITHUB_TOKEN);
145
+ }
146
+ addCandidate('gh auth token', getGitHubCliToken());
147
+
148
+ return candidates;
149
+ }
150
+
151
+ async function validateGitHubToken(token: string): Promise<boolean> {
152
+ try {
153
+ const response = await fetch('https://api.github.com/user', {
154
+ headers: {
155
+ ...buildGitHubHeaders(token),
156
+ 'user-agent': USER_AGENT,
157
+ },
158
+ signal: AbortSignal.timeout(15_000),
159
+ });
160
+
161
+ return response.ok;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ async function getGitHubToken(config: GitHubActivityConfig): Promise<string | undefined> {
168
+ const candidates = getGitHubTokenCandidates(config);
169
+
170
+ for (const candidate of candidates) {
171
+ if (await validateGitHubToken(candidate.token)) {
172
+ if (candidate.source === 'gh auth token' && candidates[0] && candidates[0].source !== 'gh auth token' && !hasWarnedAboutInvalidConfiguredToken) {
173
+ hasWarnedAboutInvalidConfiguredToken = true;
174
+ console.warn(`Configured ${candidates[0].source} was invalid; using GitHub CLI auth instead`);
175
+ }
176
+
177
+ return candidate.token;
178
+ }
179
+ }
180
+
181
+ return undefined;
182
+ }
183
+
115
184
  function buildGitHubHeaders(token?: string, accept = GITHUB_ACCEPT): Record<string, string> {
116
185
  return {
117
186
  accept,
@@ -121,7 +190,16 @@ function buildGitHubHeaders(token?: string, accept = GITHUB_ACCEPT): Record<stri
121
190
  }
122
191
 
123
192
  function isGitHubAuthFailure(error: unknown): boolean {
124
- return error instanceof Error && /\((401|403)\)/u.test(error.message);
193
+ return error instanceof Error && /\(401\)/u.test(error.message);
194
+ }
195
+
196
+ function warnRejectedGitHubTokenOnce(): void {
197
+ if (hasWarnedAboutRejectedGitHubToken) {
198
+ return;
199
+ }
200
+
201
+ hasWarnedAboutRejectedGitHubToken = true;
202
+ console.warn('GitHub token rejected, retrying without auth');
125
203
  }
126
204
 
127
205
  async function fetchGitHubJson<T>(url: string, token?: string): Promise<T> {
@@ -129,7 +207,7 @@ async function fetchGitHubJson<T>(url: string, token?: string): Promise<T> {
129
207
  return await fetchJson<T>(url, buildGitHubHeaders(token));
130
208
  } catch (error) {
131
209
  if (token && isGitHubAuthFailure(error)) {
132
- console.warn('GitHub token rejected, retrying without auth');
210
+ warnRejectedGitHubTokenOnce();
133
211
  return fetchJson<T>(url, buildGitHubHeaders(undefined));
134
212
  }
135
213
 
@@ -139,7 +217,7 @@ async function fetchGitHubJson<T>(url: string, token?: string): Promise<T> {
139
217
 
140
218
  async function fetchGitHubJsonWithHeaders<T>(url: string, headers: Record<string, string>): Promise<T> {
141
219
  return fetchJson<T>(url, {
142
- 'user-agent': 'thinking-phrases/1.0 (+https://github.com/austenstone/thinking-phrases)',
220
+ 'user-agent': USER_AGENT,
143
221
  ...headers,
144
222
  });
145
223
  }
@@ -149,7 +227,7 @@ async function fetchGitHubText(url: string, token?: string, accept = 'applicatio
149
227
  return await fetchText(url, buildGitHubHeaders(token, accept));
150
228
  } catch (error) {
151
229
  if (token && isGitHubAuthFailure(error)) {
152
- console.warn('GitHub token rejected, retrying without auth');
230
+ warnRejectedGitHubTokenOnce();
153
231
  return fetchText(url, buildGitHubHeaders(undefined, accept));
154
232
  }
155
233
 
@@ -207,11 +285,7 @@ function repoDisplayName(repoLabel: string): string {
207
285
  return parts.at(-1) ?? trimmed;
208
286
  }
209
287
 
210
- function buildCommitContent(detail: GitHubCommitDetail, config: Config): string | undefined {
211
- return buildCommitContentFromContext({ detail }, config);
212
- }
213
-
214
- function buildCommitContentFromContext(context: GitHubCommitContext, config: Config): string | undefined {
288
+ function buildCommitContentFromContext(context: GitHubCommitContext, _config: Config): string | undefined {
215
289
  const { detail, diffText } = context;
216
290
  const files = detail.files ?? [];
217
291
  const summaryBits = [
@@ -253,29 +327,22 @@ function buildShortShaLabel(detail: GitHubCommitDetail): string | undefined {
253
327
  return sha ? sha.slice(0, 7) : undefined;
254
328
  }
255
329
 
256
- function buildCommitDisplayPhrase(repoLabel: string, detail: GitHubCommitDetail): string {
257
- const repoName = repoDisplayName(repoLabel);
258
- const shortShaLabel = buildShortShaLabel(detail);
259
- const commitTitle = firstCommitLine(detail.commit?.message);
260
- const deltaLabel = buildCommitDeltaLabel(detail);
261
- const authorHandle = detail.author?.login?.trim();
262
- const time = relativeTime(detail.commit?.author?.date);
263
-
264
- const headline = `${repoName}${shortShaLabel ? `@${shortShaLabel}` : ''} ${commitTitle}`.trim();
265
- const metadata = [
266
- deltaLabel ? `(${deltaLabel})` : undefined,
267
- time,
268
- authorHandle ? `- @${authorHandle}` : undefined,
269
- ].filter(Boolean);
270
-
271
- return [headline, ...metadata].join(' ');
330
+ function buildCommitDisplayPhrase(repoLabel: string, detail: GitHubCommitDetail, templates?: PhraseFormatTemplates): string {
331
+ return formatGitHubCommitPhrase({
332
+ headline: firstCommitLine(detail.commit?.message),
333
+ delta: buildCommitDeltaLabel(detail),
334
+ repo: repoDisplayName(repoLabel),
335
+ sha: buildShortShaLabel(detail),
336
+ author: detail.author?.login?.trim(),
337
+ time: relativeTime(detail.commit?.author?.date),
338
+ }, { template: templates?.githubCommit });
272
339
  }
273
340
 
274
341
  function escapeRegExp(input: string): string {
275
342
  return input.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
276
343
  }
277
344
 
278
- function buildGitHubFeedDisplayPhrase(article: ArticleItem): string | undefined {
345
+ function buildGitHubFeedDisplayPhrase(article: ArticleItem, templates?: PhraseFormatTemplates): string | undefined {
279
346
  const title = article.title?.trim();
280
347
  if (!title) {
281
348
  return undefined;
@@ -286,16 +353,15 @@ function buildGitHubFeedDisplayPhrase(article: ArticleItem): string | undefined
286
353
  const maybeHandle = source && source !== 'GitHub' ? source : undefined;
287
354
 
288
355
  if (!maybeHandle) {
289
- return time ? `${title} ${time}` : title;
356
+ return formatGitHubFeedPhrase({ action: title, time }, { template: templates?.githubFeed });
290
357
  }
291
358
 
292
359
  // Strip the actor name from the start of the title if present (exact or case-insensitive)
293
360
  const handlePattern = new RegExp(`^${escapeRegExp(maybeHandle)}\\s*`, 'iu');
294
361
  const strippedTitle = title.replace(handlePattern, '').trim();
295
362
  const actionText = strippedTitle || title;
296
- const finalTitle = `@${maybeHandle} ${actionText}`;
297
363
 
298
- return time ? `${finalTitle} ${time}` : finalTitle;
364
+ return formatGitHubFeedPhrase({ handle: maybeHandle, action: actionText, time }, { template: templates?.githubFeed });
299
365
  }
300
366
 
301
367
  function repoNameFromEvent(repo?: string): string {
@@ -353,7 +419,7 @@ function buildOrganizationEventLink(event: GitHubOrgEvent): string | undefined {
353
419
  ?? event.payload?.release?.html_url;
354
420
  }
355
421
 
356
- function buildOrganizationEventArticle(event: GitHubOrgEvent): ArticleItem | null {
422
+ function buildOrganizationEventArticle(event: GitHubOrgEvent, templates?: PhraseFormatTemplates): ArticleItem | null {
357
423
  const title = buildOrganizationEventTitle(event);
358
424
  if (!title) {
359
425
  return null;
@@ -373,11 +439,11 @@ function buildOrganizationEventArticle(event: GitHubOrgEvent): ArticleItem | nul
373
439
 
374
440
  return {
375
441
  ...article,
376
- displayPhrase: buildGitHubFeedDisplayPhrase(article),
442
+ displayPhrase: buildGitHubFeedDisplayPhrase(article, templates),
377
443
  };
378
444
  }
379
445
 
380
- function buildCommitArticle(repoLabel: string, detail: GitHubCommitDetail, content?: string): ArticleItem {
446
+ function buildCommitArticle(repoLabel: string, detail: GitHubCommitDetail, content?: string, templates?: PhraseFormatTemplates): ArticleItem {
381
447
  const datetime = detail.commit?.author?.date;
382
448
  const repoName = repoDisplayName(repoLabel);
383
449
  const authorHandle = detail.author?.login?.trim();
@@ -394,13 +460,19 @@ function buildCommitArticle(repoLabel: string, detail: GitHubCommitDetail, conte
394
460
  type: 'article',
395
461
  id: `github:${repoLabel}:${detail.sha}`,
396
462
  title: titleBits.join(' — '),
397
- displayPhrase: buildCommitDisplayPhrase(repoLabel, detail),
463
+ displayPhrase: buildCommitDisplayPhrase(repoLabel, detail, templates),
398
464
  link: detail.html_url,
399
465
  source: repoName,
400
466
  datetime,
401
467
  time: relativeTime(datetime),
402
468
  content: detail.commit?.message?.trim(),
403
469
  articleContent: content,
470
+ metadata: {
471
+ repo: repoName,
472
+ delta: deltaLabel,
473
+ author: authorHandle ? `@${authorHandle}` : undefined,
474
+ sha: shortShaLabel,
475
+ },
404
476
  };
405
477
  }
406
478
 
@@ -433,10 +505,10 @@ async function fetchCommitContext(owner: string, repo: string, ref: string, conf
433
505
 
434
506
  async function fetchRepoCommitArticles(config: Config): Promise<ArticleItem[]> {
435
507
  const { owner, repo } = parseRepoSlug(config.githubActivity.repo);
436
- const token = getGitHubToken(config.githubActivity);
508
+ const token = await getGitHubToken(config.githubActivity);
437
509
  const params = new URLSearchParams({
438
510
  per_page: String(config.githubActivity.maxItems),
439
- since: new Date(Date.now() - config.githubActivity.sinceHours * 60 * 60 * 1000).toISOString(),
511
+ since: new Date(Date.now() - hoursToMs(config.githubActivity.sinceHours)).toISOString(),
440
512
  });
441
513
 
442
514
  if (config.githubActivity.branch?.trim()) {
@@ -462,12 +534,12 @@ async function fetchRepoCommitArticles(config: Config): Promise<ArticleItem[]> {
462
534
  }
463
535
 
464
536
  async function fetchOrgCommitArticles(config: Config): Promise<ArticleItem[]> {
465
- const token = getGitHubToken(config.githubActivity);
537
+ const token = await getGitHubToken(config.githubActivity);
466
538
  const params = new URLSearchParams({ per_page: String(Math.min(Math.max(config.githubActivity.maxItems * 10, 50), 100)) });
467
539
  const eventsUrl = `https://api.github.com/orgs/${config.githubActivity.org}/events?${params.toString()}`;
468
540
  logInfo(config, `Fetching GitHub org events from ${eventsUrl}`);
469
541
  const events = await fetchGitHubJson<GitHubOrgEvent[]>(eventsUrl, token);
470
- const sinceThreshold = Date.now() - config.githubActivity.sinceHours * 60 * 60 * 1000;
542
+ const sinceThreshold = Date.now() - hoursToMs(config.githubActivity.sinceHours);
471
543
 
472
544
  const pushEvents = events
473
545
  .filter(event => event.type === 'PushEvent' && event.repo?.name && event.payload?.head)
@@ -538,7 +610,7 @@ async function resolveFeedUrl(config: Config, token?: string): Promise<string> {
538
610
  }
539
611
 
540
612
  async function fetchGitHubFeedArticles(config: Config): Promise<ArticleItem[]> {
541
- const token = getGitHubToken(config.githubActivity);
613
+ const token = await getGitHubToken(config.githubActivity);
542
614
  let feedUrl: string | undefined;
543
615
  try {
544
616
  feedUrl = await resolveFeedUrl(config, token);
@@ -552,7 +624,7 @@ async function fetchGitHubFeedArticles(config: Config): Promise<ArticleItem[]> {
552
624
  logInfo(config, `Falling back to GitHub organization events from ${eventsUrl}`);
553
625
  const events = await fetchGitHubJson<GitHubOrgEvent[]>(eventsUrl, token);
554
626
  return events
555
- .map(buildOrganizationEventArticle)
627
+ .map((event) => buildOrganizationEventArticle(event))
556
628
  .filter((article): article is ArticleItem => Boolean(article))
557
629
  .slice(0, config.githubActivity.maxItems);
558
630
  }
@@ -1,6 +1,9 @@
1
- import type { ArticleItem, PhraseSource } from '../core/types.js';
1
+ import { formatHackerNewsPhrase } from '../core/phraseFormats.js';
2
+ import type { ArticleItem, Config, PhraseSource } from '../core/types.js';
2
3
  import { fetchJson, logInfo, relativeTime, stripHtml } from '../core/utils.js';
3
4
 
5
+ const HN_API_BASE = 'https://hacker-news.firebaseio.com/v0';
6
+
4
7
  interface HackerNewsItem {
5
8
  by?: string;
6
9
  descendants?: number;
@@ -26,20 +29,20 @@ function buildHackerNewsLink(id: number, explicitUrl?: string): string {
26
29
  return explicitUrl?.trim() || `https://news.ycombinator.com/item?id=${id}`;
27
30
  }
28
31
 
29
- export async function fetchHackerNewsArticles(config: import('../core/types.js').Config): Promise<ArticleItem[]> {
32
+ export async function fetchHackerNewsArticles(config: Config): Promise<ArticleItem[]> {
30
33
  if (!config.hackerNews.enabled) {
31
34
  return [];
32
35
  }
33
36
 
34
37
  const listName = HN_ENDPOINTS[config.hackerNews.feed];
35
- const ids = await fetchJson<number[]>(`https://hacker-news.firebaseio.com/v0/${listName}.json`);
38
+ const ids = await fetchJson<number[]>(`${HN_API_BASE}/${listName}.json`);
36
39
  const fetchCount = Math.min(Math.max(config.hackerNews.maxItems * 3, config.hackerNews.maxItems), 60);
37
40
  const candidateIds = ids.slice(0, fetchCount);
38
41
 
39
42
  logInfo(config, `Fetching ${candidateIds.length} Hacker News items from ${config.hackerNews.feed}`);
40
43
 
41
44
  const rawItems = await Promise.all(
42
- candidateIds.map(id => fetchJson<HackerNewsItem>(`https://hacker-news.firebaseio.com/v0/item/${id}.json`).catch(() => null)),
45
+ candidateIds.map(id => fetchJson<HackerNewsItem>(`${HN_API_BASE}/item/${id}.json`).catch(() => null)),
43
46
  );
44
47
 
45
48
  return rawItems
@@ -51,7 +54,11 @@ export async function fetchHackerNewsArticles(config: import('../core/types.js')
51
54
  const trimmedTitle = item.title?.trim() ?? '';
52
55
  const relativeTimestamp = relativeTime(datetime);
53
56
  const score = typeof item.score === 'number' ? `+${item.score}` : undefined;
54
- const displayPhrase = [`HN: ${trimmedTitle}`, score, relativeTimestamp].filter(Boolean).join(' — ');
57
+ const displayPhrase = formatHackerNewsPhrase(
58
+ { title: trimmedTitle, score, time: relativeTimestamp },
59
+ { template: config.phraseFormatting.templates?.hackerNews },
60
+ );
61
+ const hnText = stripHtml(item.text);
55
62
 
56
63
  return {
57
64
  type: 'article' as const,
@@ -62,8 +69,13 @@ export async function fetchHackerNewsArticles(config: import('../core/types.js')
62
69
  source: 'Hacker News',
63
70
  datetime,
64
71
  time: relativeTimestamp,
65
- content: stripHtml(item.text),
66
- articleContent: stripHtml(item.text),
72
+ content: hnText,
73
+ articleContent: hnText,
74
+ metadata: {
75
+ author: item.by,
76
+ score: typeof item.score === 'number' ? `${item.score} pts` : undefined,
77
+ comments: typeof item.descendants === 'number' ? `${item.descendants}` : undefined,
78
+ },
67
79
  };
68
80
  });
69
81
  }
@@ -1,6 +1,6 @@
1
1
  import { XMLParser } from 'fast-xml-parser';
2
2
  import type { ArticleItem, Config, FeedConfig, PhraseSource } from '../core/types.js';
3
- import { fetchText, logDebug, logInfo, relativeTime, singleLine, stripHtml, truncate } from '../core/utils.js';
3
+ import { decodeHtmlEntities, fetchText, logDebug, logInfo, relativeTime, singleLine, stripHtml, truncate } from '../core/utils.js';
4
4
 
5
5
  type XmlPrimitive = string | number | boolean | null | undefined;
6
6
 
@@ -8,13 +8,14 @@ interface XmlObject {
8
8
  [key: string]: XmlValue;
9
9
  }
10
10
 
11
- interface XmlArray extends Array<XmlValue> {}
11
+ type XmlArray = XmlValue[];
12
12
 
13
13
  type XmlValue = XmlPrimitive | XmlObject | XmlArray;
14
14
 
15
15
  function readText(value: XmlValue): string | undefined {
16
16
  if (typeof value === 'string') {
17
- return value.trim() || undefined;
17
+ const decoded = decodeHtmlEntities(value).trim();
18
+ return decoded || undefined;
18
19
  }
19
20
 
20
21
  if (typeof value === 'number' || typeof value === 'boolean') {
@@ -26,7 +27,12 @@ function readText(value: XmlValue): string | undefined {
26
27
  }
27
28
 
28
29
  const textValue = value['#text'];
29
- return typeof textValue === 'string' ? textValue.trim() || undefined : undefined;
30
+ if (typeof textValue !== 'string') {
31
+ return undefined;
32
+ }
33
+
34
+ const decoded = decodeHtmlEntities(textValue).trim();
35
+ return decoded || undefined;
30
36
  }
31
37
 
32
38
  function readLink(value: XmlValue): string | undefined {
@@ -205,27 +211,35 @@ export async function hydrateArticleContent(articles: ArticleItem[], config: Con
205
211
  logInfo(config, `Fetching article content for up to ${articles.length} articles`);
206
212
 
207
213
  return Promise.all(articles.map(async article => {
208
- if (article.articleContent) {
209
- logDebug(config, `Using embedded feed article content for: ${article.title ?? article.link}`);
214
+ if (!article.link) {
210
215
  return article;
211
216
  }
212
217
 
213
- if (!article.link) {
218
+ // If the article already has substantial embedded content (e.g. RSS <content:encoded>),
219
+ // skip fetching — the feed itself provided the full article body.
220
+ if (article.articleContent && article.articleContent.length >= 500) {
221
+ logDebug(config, `Using embedded article content (${article.articleContent.length} chars) for: ${article.title ?? article.link}`);
214
222
  return article;
215
223
  }
216
224
 
217
225
  try {
218
226
  logInfo(config, `Fetching article content from ${article.link}`);
219
227
  const html = await fetchText(article.link);
220
- const articleContent = extractArticleTextFromHtml(html, config.githubModels.maxArticleContentLength);
228
+ const fetched = extractArticleTextFromHtml(html, config.githubModels.maxArticleContentLength);
221
229
 
222
- if (articleContent) {
223
- logDebug(config, `Fetched article content preview: ${singleLine(articleContent, 220)}`);
230
+ if (fetched) {
231
+ logDebug(config, `Fetched article content preview: ${singleLine(fetched, 220)}`);
224
232
  }
225
233
 
234
+ // Combine existing content (e.g. HN self-text) with fetched article body
235
+ const existing = article.articleContent?.trim();
236
+ const combined = existing && fetched
237
+ ? `${existing}\n\n${fetched}`
238
+ : fetched || existing || article.content;
239
+
226
240
  return {
227
241
  ...article,
228
- articleContent: articleContent || article.content,
242
+ articleContent: combined ? truncate(combined, config.githubModels.maxArticleContentLength) : article.content,
229
243
  };
230
244
  } catch (error) {
231
245
  const message = error instanceof Error ? error.message : String(error);
@@ -1,6 +1,7 @@
1
1
  import YahooFinance from 'yahoo-finance2';
2
+ import { formatStockPhrase as formatStockPhraseTemplate } from '../core/phraseFormats.js';
2
3
  import type { Config, PhraseSource, StockItem } from '../core/types.js';
3
- import { dedupePhrases, formatPrice, formatSignedPercent, logInfo, normalizeSymbols, truncate } from '../core/utils.js';
4
+ import { formatPrice, formatSignedPercent, logInfo, normalizeSymbols, truncate } from '../core/utils.js';
4
5
 
5
6
  interface StockQuoteSnapshot {
6
7
  symbol?: string;
@@ -16,6 +17,21 @@ interface StockQuoteSnapshot {
16
17
 
17
18
  const yahooFinance = new YahooFinance({ suppressNotices: ['yahooSurvey'] });
18
19
 
20
+ function formatMarketLabel(label?: string): string | undefined {
21
+ switch (label) {
22
+ case 'today':
23
+ return '🟢';
24
+ case 'close':
25
+ return '🔒';
26
+ case 'pre-market':
27
+ return '🌅';
28
+ case 'after-hours':
29
+ return '🌙';
30
+ default:
31
+ return label;
32
+ }
33
+ }
34
+
19
35
  function getMarketPriceDetails(quote: StockQuoteSnapshot): {
20
36
  label?: string;
21
37
  price?: number;
@@ -46,17 +62,18 @@ function getMarketPriceDetails(quote: StockQuoteSnapshot): {
46
62
  }
47
63
 
48
64
  export function buildStockPhrase(item: StockItem, config: Config): string {
49
- const parts = [item.symbol, formatPrice(item.price, item.currency)];
50
65
  const signedPercent = formatSignedPercent(item.changePercent);
51
- if (signedPercent) {
52
- parts.push(signedPercent);
53
- }
66
+ const formattedMarketLabel = formatMarketLabel(item.marketLabel);
54
67
 
55
- if (config.stockQuotes.includeMarketState && item.marketLabel) {
56
- parts.push(item.marketLabel);
57
- }
58
-
59
- return truncate(parts.join(' — '), config.phraseFormatting.maxLength);
68
+ return truncate(
69
+ formatStockPhraseTemplate({
70
+ symbol: item.symbol,
71
+ price: formatPrice(item.price, item.currency),
72
+ change: signedPercent,
73
+ market: config.stockQuotes.includeMarketState ? formattedMarketLabel : undefined,
74
+ }, { template: config.phraseFormatting.templates?.stock }),
75
+ config.phraseFormatting.maxLength,
76
+ );
60
77
  }
61
78
 
62
79
  export async function fetchStockItems(config: Config): Promise<StockItem[]> {
@@ -93,6 +110,10 @@ export async function fetchStockItems(config: Config): Promise<StockItem[]> {
93
110
  return [];
94
111
  }
95
112
 
113
+ if (!config.stockQuotes.showClosed && quote.marketState === 'CLOSED') {
114
+ return [];
115
+ }
116
+
96
117
  const details = getMarketPriceDetails(quote);
97
118
  if (!Number.isFinite(details.price)) {
98
119
  return [];