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
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export type TaskHealthStatus = 'running' | 'succeeded' | 'failed';
|
|
5
|
+
export type TaskHealthPhase =
|
|
6
|
+
| 'initializing'
|
|
7
|
+
| 'fetching-sources'
|
|
8
|
+
| 'formatting-phrases'
|
|
9
|
+
| 'writing-settings'
|
|
10
|
+
| 'updating-scheduler'
|
|
11
|
+
| 'completed'
|
|
12
|
+
| 'failed';
|
|
13
|
+
export type TaskHealthSourceStatus = 'pending' | 'running' | 'succeeded' | 'failed';
|
|
14
|
+
|
|
15
|
+
export interface TaskHealthSourceEntry {
|
|
16
|
+
type: string;
|
|
17
|
+
status: TaskHealthSourceStatus;
|
|
18
|
+
startedAt?: string;
|
|
19
|
+
completedAt?: string;
|
|
20
|
+
durationMs?: number;
|
|
21
|
+
itemCount?: number;
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TaskHealthSummary {
|
|
26
|
+
sourceCount: number;
|
|
27
|
+
articleCount: number;
|
|
28
|
+
stockCount: number;
|
|
29
|
+
phraseCount: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TaskHealthReport {
|
|
33
|
+
status: TaskHealthStatus;
|
|
34
|
+
phase: TaskHealthPhase;
|
|
35
|
+
startedAt: string;
|
|
36
|
+
updatedAt: string;
|
|
37
|
+
completedAt?: string;
|
|
38
|
+
durationMs?: number;
|
|
39
|
+
dryRun: boolean;
|
|
40
|
+
configPath?: string;
|
|
41
|
+
settingsPath?: string;
|
|
42
|
+
pid: number;
|
|
43
|
+
lastMessage?: string;
|
|
44
|
+
error?: string;
|
|
45
|
+
warnings: string[];
|
|
46
|
+
sources: TaskHealthSourceEntry[];
|
|
47
|
+
summary?: TaskHealthSummary;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const TASK_HEALTH_PATH = resolve(process.cwd(), 'launchd', 'task-health.json');
|
|
51
|
+
|
|
52
|
+
function nowIso(): string {
|
|
53
|
+
return new Date().toISOString();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function durationMs(startedAt: string, completedAt: string): number {
|
|
57
|
+
return Math.max(0, new Date(completedAt).getTime() - new Date(startedAt).getTime());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeTaskHealth(report: TaskHealthReport): void {
|
|
61
|
+
mkdirSync(dirname(TASK_HEALTH_PATH), { recursive: true });
|
|
62
|
+
const tempPath = `${TASK_HEALTH_PATH}.tmp`;
|
|
63
|
+
writeFileSync(tempPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
64
|
+
renameSync(tempPath, TASK_HEALTH_PATH);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function readTaskHealth(): TaskHealthReport | null {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(readFileSync(TASK_HEALTH_PATH, 'utf8')) as TaskHealthReport;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class TaskHealthTracker {
|
|
76
|
+
private report: TaskHealthReport;
|
|
77
|
+
|
|
78
|
+
constructor(input: { dryRun: boolean; configPath?: string; settingsPath?: string }) {
|
|
79
|
+
const startedAt = nowIso();
|
|
80
|
+
this.report = {
|
|
81
|
+
status: 'running',
|
|
82
|
+
phase: 'initializing',
|
|
83
|
+
startedAt,
|
|
84
|
+
updatedAt: startedAt,
|
|
85
|
+
dryRun: input.dryRun,
|
|
86
|
+
configPath: input.configPath,
|
|
87
|
+
settingsPath: input.settingsPath,
|
|
88
|
+
pid: process.pid,
|
|
89
|
+
warnings: [],
|
|
90
|
+
sources: [],
|
|
91
|
+
lastMessage: 'Booting thinking-phrases run',
|
|
92
|
+
};
|
|
93
|
+
this.persist();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get filePath(): string {
|
|
97
|
+
return TASK_HEALTH_PATH;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setSources(sourceTypes: string[]): void {
|
|
101
|
+
this.report.sources = sourceTypes.map(type => ({ type, status: 'pending' as const }));
|
|
102
|
+
this.touch('initializing', `Prepared ${sourceTypes.length} source${sourceTypes.length === 1 ? '' : 's'} for execution`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
setPhase(phase: TaskHealthPhase, message: string): void {
|
|
106
|
+
this.touch(phase, message);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setDryRun(dryRun: boolean): void {
|
|
110
|
+
this.report.dryRun = dryRun;
|
|
111
|
+
this.report.updatedAt = nowIso();
|
|
112
|
+
this.persist();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
startSource(sourceType: string): void {
|
|
116
|
+
const startedAt = nowIso();
|
|
117
|
+
this.updateSource(sourceType, {
|
|
118
|
+
status: 'running',
|
|
119
|
+
startedAt,
|
|
120
|
+
completedAt: undefined,
|
|
121
|
+
durationMs: undefined,
|
|
122
|
+
itemCount: undefined,
|
|
123
|
+
error: undefined,
|
|
124
|
+
});
|
|
125
|
+
this.touch('fetching-sources', `Fetching ${sourceType}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
completeSource(sourceType: string, itemCount: number): void {
|
|
129
|
+
const completedAt = nowIso();
|
|
130
|
+
const source = this.findOrCreateSource(sourceType);
|
|
131
|
+
this.updateSource(sourceType, {
|
|
132
|
+
status: 'succeeded',
|
|
133
|
+
completedAt,
|
|
134
|
+
durationMs: source.startedAt ? durationMs(source.startedAt, completedAt) : undefined,
|
|
135
|
+
itemCount,
|
|
136
|
+
});
|
|
137
|
+
this.touch('fetching-sources', `Fetched ${itemCount} item${itemCount === 1 ? '' : 's'} from ${sourceType}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
failSource(sourceType: string, error: string): void {
|
|
141
|
+
const completedAt = nowIso();
|
|
142
|
+
const source = this.findOrCreateSource(sourceType);
|
|
143
|
+
this.updateSource(sourceType, {
|
|
144
|
+
status: 'failed',
|
|
145
|
+
completedAt,
|
|
146
|
+
durationMs: source.startedAt ? durationMs(source.startedAt, completedAt) : undefined,
|
|
147
|
+
error,
|
|
148
|
+
});
|
|
149
|
+
this.touch('fetching-sources', `${sourceType} failed`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
addWarning(warning: string): void {
|
|
153
|
+
if (!this.report.warnings.includes(warning)) {
|
|
154
|
+
this.report.warnings.push(warning);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.report.updatedAt = nowIso();
|
|
158
|
+
this.persist();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
succeed(summary: TaskHealthSummary, message: string): void {
|
|
162
|
+
const completedAt = nowIso();
|
|
163
|
+
this.report.status = 'succeeded';
|
|
164
|
+
this.report.phase = 'completed';
|
|
165
|
+
this.report.summary = summary;
|
|
166
|
+
this.report.lastMessage = message;
|
|
167
|
+
this.report.completedAt = completedAt;
|
|
168
|
+
this.report.updatedAt = completedAt;
|
|
169
|
+
this.report.durationMs = durationMs(this.report.startedAt, completedAt);
|
|
170
|
+
this.persist();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fail(error: string): void {
|
|
174
|
+
const completedAt = nowIso();
|
|
175
|
+
this.report.status = 'failed';
|
|
176
|
+
this.report.phase = 'failed';
|
|
177
|
+
this.report.error = error;
|
|
178
|
+
this.report.lastMessage = error;
|
|
179
|
+
this.report.completedAt = completedAt;
|
|
180
|
+
this.report.updatedAt = completedAt;
|
|
181
|
+
this.report.durationMs = durationMs(this.report.startedAt, completedAt);
|
|
182
|
+
this.persist();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private touch(phase: TaskHealthPhase, message: string): void {
|
|
186
|
+
this.report.phase = phase;
|
|
187
|
+
this.report.lastMessage = message;
|
|
188
|
+
this.report.updatedAt = nowIso();
|
|
189
|
+
this.persist();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private findOrCreateSource(sourceType: string): TaskHealthSourceEntry {
|
|
193
|
+
const existing = this.report.sources.find(source => source.type === sourceType);
|
|
194
|
+
if (existing) {
|
|
195
|
+
return existing;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const source = { type: sourceType, status: 'pending' as const };
|
|
199
|
+
this.report.sources.push(source);
|
|
200
|
+
return source;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private updateSource(sourceType: string, updates: Partial<TaskHealthSourceEntry>): void {
|
|
204
|
+
const source = this.findOrCreateSource(sourceType);
|
|
205
|
+
Object.assign(source, updates);
|
|
206
|
+
this.report.updatedAt = nowIso();
|
|
207
|
+
this.persist();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private persist(): void {
|
|
211
|
+
writeTaskHealth(this.report);
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -9,31 +9,50 @@ export type GitHubFeedKind = 'timeline' | 'current-user-public' | 'current-user'
|
|
|
9
9
|
export interface FeedConfig {
|
|
10
10
|
url: string;
|
|
11
11
|
source?: string;
|
|
12
|
+
fetchIntervalSeconds?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PhraseFormatTemplates {
|
|
16
|
+
article?: string;
|
|
17
|
+
hackerNews?: string;
|
|
18
|
+
stock?: string;
|
|
19
|
+
githubCommit?: string;
|
|
20
|
+
githubFeed?: string;
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
export interface PhraseFormatting {
|
|
15
24
|
includeSource: boolean;
|
|
16
25
|
includeTime: boolean;
|
|
17
26
|
maxLength: number;
|
|
27
|
+
templates?: PhraseFormatTemplates;
|
|
18
28
|
}
|
|
19
29
|
|
|
20
30
|
export interface GitHubModelsConfig {
|
|
21
31
|
enabled: boolean;
|
|
32
|
+
endpoint: string;
|
|
22
33
|
model: string;
|
|
23
34
|
tokenEnvVar: string;
|
|
24
35
|
maxInputItems: number;
|
|
36
|
+
maxInputTokens: number;
|
|
25
37
|
maxTokens: number;
|
|
38
|
+
maxConcurrency: number;
|
|
26
39
|
maxPhrasesPerArticle: number;
|
|
27
40
|
temperature: number;
|
|
28
41
|
fetchArticleContent: boolean;
|
|
29
42
|
maxArticleContentLength: number;
|
|
43
|
+
/** Default prompt used when no source-specific prompt exists */
|
|
30
44
|
systemPrompt?: string;
|
|
45
|
+
/** Per-source prompts keyed by source type (rss, hacker-news, github-activity, earthquakes, custom-json) */
|
|
46
|
+
prompts?: Record<string, string>;
|
|
47
|
+
cacheTtlSeconds?: number;
|
|
31
48
|
}
|
|
32
49
|
|
|
33
50
|
export interface StockQuoteConfig {
|
|
34
51
|
enabled: boolean;
|
|
35
52
|
symbols: string[];
|
|
36
53
|
includeMarketState: boolean;
|
|
54
|
+
showClosed?: boolean;
|
|
55
|
+
fetchIntervalSeconds?: number;
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
export interface HackerNewsConfig {
|
|
@@ -41,6 +60,7 @@ export interface HackerNewsConfig {
|
|
|
41
60
|
feed: HackerNewsFeed;
|
|
42
61
|
maxItems: number;
|
|
43
62
|
minScore: number;
|
|
63
|
+
fetchIntervalSeconds?: number;
|
|
44
64
|
}
|
|
45
65
|
|
|
46
66
|
export interface EarthquakeConfig {
|
|
@@ -52,6 +72,7 @@ export interface EarthquakeConfig {
|
|
|
52
72
|
place?: string;
|
|
53
73
|
radiusKm: number;
|
|
54
74
|
orderBy: EarthquakeOrder;
|
|
75
|
+
fetchIntervalSeconds?: number;
|
|
55
76
|
}
|
|
56
77
|
|
|
57
78
|
export interface WeatherAlertsConfig {
|
|
@@ -60,6 +81,7 @@ export interface WeatherAlertsConfig {
|
|
|
60
81
|
area?: string;
|
|
61
82
|
minimumSeverity: WeatherSeverity;
|
|
62
83
|
limit: number;
|
|
84
|
+
fetchIntervalSeconds?: number;
|
|
63
85
|
}
|
|
64
86
|
|
|
65
87
|
export interface CustomJsonConfig {
|
|
@@ -74,6 +96,7 @@ export interface CustomJsonConfig {
|
|
|
74
96
|
dateField?: string;
|
|
75
97
|
idField?: string;
|
|
76
98
|
maxItems: number;
|
|
99
|
+
fetchIntervalSeconds?: number;
|
|
77
100
|
}
|
|
78
101
|
|
|
79
102
|
export interface GitHubActivityConfig {
|
|
@@ -87,10 +110,12 @@ export interface GitHubActivityConfig {
|
|
|
87
110
|
maxItems: number;
|
|
88
111
|
sinceHours: number;
|
|
89
112
|
tokenEnvVar: string;
|
|
113
|
+
fetchIntervalSeconds?: number;
|
|
90
114
|
}
|
|
91
115
|
|
|
92
116
|
export interface Config {
|
|
93
117
|
feeds: FeedConfig[];
|
|
118
|
+
rssFetchIntervalSeconds: number;
|
|
94
119
|
limit: number;
|
|
95
120
|
mode: Mode;
|
|
96
121
|
target: Target;
|
|
@@ -104,6 +129,7 @@ export interface Config {
|
|
|
104
129
|
earthquakes: EarthquakeConfig;
|
|
105
130
|
weatherAlerts: WeatherAlertsConfig;
|
|
106
131
|
customJson: CustomJsonConfig;
|
|
132
|
+
customJsonSources?: CustomJsonConfig[];
|
|
107
133
|
githubActivity: GitHubActivityConfig;
|
|
108
134
|
}
|
|
109
135
|
|
|
@@ -111,6 +137,8 @@ export interface CliOverrides extends Partial<Config> {
|
|
|
111
137
|
dryRun?: boolean;
|
|
112
138
|
interactive?: boolean;
|
|
113
139
|
uninstall?: boolean;
|
|
140
|
+
clearCache?: boolean;
|
|
141
|
+
triggerSchedulerNow?: boolean;
|
|
114
142
|
createNewConfig?: boolean;
|
|
115
143
|
installScheduler?: boolean;
|
|
116
144
|
uninstallScheduler?: boolean;
|
|
@@ -120,14 +148,6 @@ export interface CliOverrides extends Partial<Config> {
|
|
|
120
148
|
staticPackPath?: string;
|
|
121
149
|
}
|
|
122
150
|
|
|
123
|
-
export interface GitHubModelsResponse {
|
|
124
|
-
choices?: Array<{
|
|
125
|
-
message?: {
|
|
126
|
-
content?: string;
|
|
127
|
-
};
|
|
128
|
-
}>;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
151
|
export interface ArticleItem {
|
|
132
152
|
type: 'article';
|
|
133
153
|
id: string;
|
|
@@ -139,6 +159,10 @@ export interface ArticleItem {
|
|
|
139
159
|
time?: string;
|
|
140
160
|
content?: string;
|
|
141
161
|
articleContent?: string;
|
|
162
|
+
/** Source-specific metadata for suffix display (e.g. HN score, commit delta) */
|
|
163
|
+
metadata?: Record<string, string | undefined>;
|
|
164
|
+
/** When true, the article's displayPhrase is final and should not be rewritten by AI models */
|
|
165
|
+
skipModelRewrite?: boolean;
|
|
142
166
|
}
|
|
143
167
|
|
|
144
168
|
export interface StockItem {
|
package/src/core/utils.ts
CHANGED
|
@@ -150,6 +150,11 @@ export function stripHtml(input?: string): string | undefined {
|
|
|
150
150
|
return cleaned || undefined;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/** Convert hours to milliseconds. */
|
|
154
|
+
export function hoursToMs(hours: number): number {
|
|
155
|
+
return hours * 3_600_000;
|
|
156
|
+
}
|
|
157
|
+
|
|
153
158
|
export function relativeTime(input?: string): string | undefined {
|
|
154
159
|
if (!input) {
|
|
155
160
|
return undefined;
|
|
@@ -261,13 +266,13 @@ export async function fetchJson<T>(url: string, headers?: Record<string, string>
|
|
|
261
266
|
interface ZippopotamResponse {
|
|
262
267
|
country?: string;
|
|
263
268
|
'post code'?: string;
|
|
264
|
-
places?:
|
|
269
|
+
places?: {
|
|
265
270
|
latitude?: string;
|
|
266
271
|
longitude?: string;
|
|
267
272
|
'place name'?: string;
|
|
268
273
|
state?: string;
|
|
269
274
|
'state abbreviation'?: string;
|
|
270
|
-
}
|
|
275
|
+
}[];
|
|
271
276
|
}
|
|
272
277
|
|
|
273
278
|
const zipLocationCache = new Map<string, Promise<ZipLocation>>();
|
|
@@ -310,3 +315,23 @@ export async function fetchUsZipLocation(zipCode: string): Promise<ZipLocation>
|
|
|
310
315
|
zipLocationCache.set(normalizedZip, lookupPromise);
|
|
311
316
|
return lookupPromise;
|
|
312
317
|
}
|
|
318
|
+
|
|
319
|
+
interface IpGeoResponse {
|
|
320
|
+
status?: string;
|
|
321
|
+
zip?: string;
|
|
322
|
+
city?: string;
|
|
323
|
+
regionName?: string;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Best-effort ZIP detection via IP geolocation. Returns undefined on failure. */
|
|
327
|
+
export async function detectZipFromIp(): Promise<string | undefined> {
|
|
328
|
+
try {
|
|
329
|
+
const geo = await fetchJson<IpGeoResponse>('http://ip-api.com/json/?fields=status,zip,city,regionName');
|
|
330
|
+
if (geo.status === 'success' && geo.zip && isValidUsZipCode(geo.zip)) {
|
|
331
|
+
return geo.zip;
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
// Best-effort — silently fail
|
|
335
|
+
}
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ArticleItem, Config, PhraseSource } from '../core/types.js';
|
|
1
|
+
import type { ArticleItem, Config, CustomJsonConfig, PhraseSource } from '../core/types.js';
|
|
2
2
|
import { fetchJson, logInfo, relativeTime, stripHtml, truncate } from '../core/utils.js';
|
|
3
3
|
|
|
4
4
|
type PathSegment = number | string;
|
|
@@ -125,33 +125,29 @@ function resolveItems(payload: unknown, path?: string): unknown[] {
|
|
|
125
125
|
throw new Error(`Custom JSON items path did not resolve to an array${path?.trim() ? `: ${path}` : ''}`);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
logInfo(config, `Fetching custom JSON items from ${config.customJson.url}`);
|
|
134
|
-
const payload = await fetchJson<unknown>(config.customJson.url);
|
|
135
|
-
const items = resolveItems(payload, config.customJson.itemsPath).slice(0, config.customJson.maxItems);
|
|
136
|
-
const defaultSource = config.customJson.sourceLabel?.trim() || buildDefaultSourceLabel(config.customJson.url);
|
|
128
|
+
async function fetchSingleCustomJsonSource(sourceConfig: CustomJsonConfig, config: Config): Promise<ArticleItem[]> {
|
|
129
|
+
logInfo(config, `Fetching custom JSON items from ${sourceConfig.url}`);
|
|
130
|
+
const payload = await fetchJson<unknown>(sourceConfig.url);
|
|
131
|
+
const items = resolveItems(payload, sourceConfig.itemsPath).slice(0, sourceConfig.maxItems);
|
|
132
|
+
const defaultSource = sourceConfig.sourceLabel?.trim() || buildDefaultSourceLabel(sourceConfig.url);
|
|
137
133
|
|
|
138
134
|
return items.flatMap((item, index) => {
|
|
139
135
|
if (!item || typeof item !== 'object') {
|
|
140
136
|
return [];
|
|
141
137
|
}
|
|
142
138
|
|
|
143
|
-
const title = readStringValue(item,
|
|
139
|
+
const title = readStringValue(item, sourceConfig.titleField);
|
|
144
140
|
if (!title) {
|
|
145
141
|
return [];
|
|
146
142
|
}
|
|
147
143
|
|
|
148
|
-
const content = stripHtml(readStringValue(item,
|
|
149
|
-
const datetime = toIsoDate(getPathValue(item,
|
|
150
|
-
const link = readStringValue(item,
|
|
151
|
-
const source = readStringValue(item,
|
|
152
|
-
const id = readStringValue(item,
|
|
144
|
+
const content = stripHtml(readStringValue(item, sourceConfig.contentField));
|
|
145
|
+
const datetime = toIsoDate(getPathValue(item, sourceConfig.dateField));
|
|
146
|
+
const link = readStringValue(item, sourceConfig.linkField);
|
|
147
|
+
const source = readStringValue(item, sourceConfig.sourceField) ?? defaultSource;
|
|
148
|
+
const id = readStringValue(item, sourceConfig.idField)
|
|
153
149
|
?? link
|
|
154
|
-
?? `${
|
|
150
|
+
?? `${sourceConfig.url}#${index}:${title}`;
|
|
155
151
|
|
|
156
152
|
return [{
|
|
157
153
|
type: 'article' as const,
|
|
@@ -167,8 +163,22 @@ export async function fetchCustomJsonArticles(config: Config): Promise<ArticleIt
|
|
|
167
163
|
});
|
|
168
164
|
}
|
|
169
165
|
|
|
166
|
+
export async function fetchCustomJsonArticles(config: Config): Promise<ArticleItem[]> {
|
|
167
|
+
const allSources: CustomJsonConfig[] = [
|
|
168
|
+
...(config.customJson.enabled ? [config.customJson] : []),
|
|
169
|
+
...(config.customJsonSources ?? []).filter(s => s.enabled),
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
if (allSources.length === 0) {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const results = await Promise.all(allSources.map(source => fetchSingleCustomJsonSource(source, config)));
|
|
177
|
+
return results.flat();
|
|
178
|
+
}
|
|
179
|
+
|
|
170
180
|
export const customJsonSource: PhraseSource = {
|
|
171
181
|
type: 'custom-json',
|
|
172
|
-
isEnabled: config => config.customJson.enabled,
|
|
182
|
+
isEnabled: config => config.customJson.enabled || (config.customJsonSources ?? []).some(s => s.enabled),
|
|
173
183
|
fetch: fetchCustomJsonArticles,
|
|
174
184
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ArticleItem, PhraseSource } from '../core/types.js';
|
|
2
|
-
import { fetchJson, fetchUsZipLocation, logInfo, relativeTime } from '../core/utils.js';
|
|
1
|
+
import type { ArticleItem, Config, PhraseSource } from '../core/types.js';
|
|
2
|
+
import { fetchJson, fetchUsZipLocation, hoursToMs, logInfo, relativeTime } from '../core/utils.js';
|
|
3
3
|
|
|
4
4
|
interface UsgsFeature {
|
|
5
5
|
id: string;
|
|
@@ -38,12 +38,12 @@ function buildEarthquakeContent(feature: UsgsFeature): string | undefined {
|
|
|
38
38
|
return parts.length > 0 ? parts.join(' • ') : undefined;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export async function fetchEarthquakeArticles(config:
|
|
41
|
+
export async function fetchEarthquakeArticles(config: Config): Promise<ArticleItem[]> {
|
|
42
42
|
if (!config.earthquakes.enabled) {
|
|
43
43
|
return [];
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
const startTime = new Date(Date.now() - config.earthquakes.windowHours
|
|
46
|
+
const startTime = new Date(Date.now() - hoursToMs(config.earthquakes.windowHours)).toISOString();
|
|
47
47
|
const params = new URLSearchParams({
|
|
48
48
|
format: 'geojson',
|
|
49
49
|
orderby: config.earthquakes.orderBy,
|