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.
- package/README.md +230 -142
- package/configs/hn-top.config.json +60 -27
- package/launchd/rss-update.error.log +3 -27
- package/launchd/rss-update.log +308 -0
- package/launchd/task-health.json +54 -0
- package/out/dwyl-quotes.json +1621 -0
- package/out/javascript-tips.json +107 -0
- package/out/league-loading-screen-tips.json +107 -0
- package/out/ruby-tips.json +115 -0
- package/out/settings-linux.json +87 -0
- package/out/settings-mac.json +87 -0
- package/out/settings-windows.json +87 -0
- package/out/typescript-tips.json +131 -0
- package/out/vscode-tips.json +87 -0
- package/out/wow-loading-screen-tips.json +116 -0
- package/package.json +19 -12
- package/scripts/build.ts +3 -3
- package/scripts/debug-hn-hydration.ts +33 -0
- package/scripts/run-rss-update.zsh +25 -3
- package/scripts/show-thinking-phrases-health.ts +74 -0
- package/scripts/trigger-thinking-phrases-scheduler.zsh +50 -0
- package/src/core/config.ts +65 -3
- package/src/core/githubModels.ts +200 -112
- package/src/core/interactive.ts +49 -67
- package/src/core/phraseCache.ts +242 -0
- package/src/core/phraseFormats.ts +243 -0
- package/src/core/presets.ts +1 -1
- package/src/core/runner.ts +246 -113
- package/src/core/scheduler.ts +1 -1
- package/src/core/taskHealth.ts +213 -0
- package/src/core/types.ts +32 -8
- package/src/core/utils.ts +27 -2
- package/src/sources/customJson.ts +28 -18
- package/src/sources/earthquakes.ts +4 -4
- package/src/sources/githubActivity.ts +120 -48
- package/src/sources/hackerNews.ts +19 -7
- package/src/sources/rss.ts +25 -11
- package/src/sources/stocks.ts +31 -10
- package/src/sources/weatherAlerts.ts +173 -7
- package/tsconfig.json +1 -1
- package/scripts/update-rss-settings.ts +0 -7
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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?:
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 && /\(
|
|
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
|
-
|
|
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':
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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[]>(
|
|
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>(
|
|
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 =
|
|
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:
|
|
66
|
-
articleContent:
|
|
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
|
}
|
package/src/sources/rss.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
228
|
+
const fetched = extractArticleTextFromHtml(html, config.githubModels.maxArticleContentLength);
|
|
221
229
|
|
|
222
|
-
if (
|
|
223
|
-
logDebug(config, `Fetched article content preview: ${singleLine(
|
|
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:
|
|
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);
|
package/src/sources/stocks.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
52
|
-
parts.push(signedPercent);
|
|
53
|
-
}
|
|
66
|
+
const formattedMarketLabel = formatMarketLabel(item.marketLabel);
|
|
54
67
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 [];
|