tibodog 0.1.2 → 0.1.4

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.
@@ -0,0 +1,77 @@
1
+ import XCTest
2
+ @testable import TiboDog
3
+
4
+ final class CodexUsageTests: XCTestCase {
5
+ func testPrefersCodexBucketAndDecodesResetSeconds() throws {
6
+ let result = try CodexUsageAPI.decode([
7
+ "rateLimits": ["primary": ["usedPercent": 99]],
8
+ "rateLimitsByLimitId": [
9
+ "codex": ["planType": "plus", "primary": ["usedPercent": 63, "windowDurationMins": 300, "resetsAt": 1789194640],
10
+ "secondary": ["usedPercent": 16, "windowDurationMins": 10080]],
11
+ "other": ["primary": ["usedPercent": 100]]
12
+ ]
13
+ ])
14
+ XCTAssertEqual(result.usage.fiveHour?.utilization, 63)
15
+ XCTAssertEqual(result.usage.fiveHour?.resetsAt, Date(timeIntervalSince1970: 1789194640))
16
+ XCTAssertEqual(result.usage.fiveHour?.durationLabel, "5H")
17
+ XCTAssertEqual(result.usage.sevenDay?.durationLabel, "7D")
18
+ XCTAssertEqual(result.plan, "plus")
19
+ XCTAssertNil(result.usage.sevenDayOpus)
20
+ XCTAssertNil(result.resetCredits)
21
+ }
22
+
23
+ func testDecodesResetCredits() throws {
24
+ let result = try CodexUsageAPI.decode([
25
+ "rateLimits": ["primary": ["usedPercent": 1, "windowDurationMins": 300]],
26
+ "rateLimitResetCredits": ["availableCount": 2]
27
+ ])
28
+ XCTAssertEqual(result.resetCredits, 2)
29
+ }
30
+
31
+ func testLegacySingleWindowAndNonStandardDuration() throws {
32
+ let result = try CodexUsageAPI.decode(["rateLimits": ["primary": ["usedPercent": 0, "windowDurationMins": 15]]])
33
+ XCTAssertEqual(result.usage.fiveHour?.percentText, "0%")
34
+ XCTAssertEqual(result.usage.fiveHour?.durationLabel, "15M")
35
+ XCTAssertNil(result.usage.sevenDay)
36
+ XCTAssertNil(result.usage.fiveHour?.resetsAt)
37
+ }
38
+
39
+ func testMissingUsageIsNotZeroAndOtherBucketIsNotCodex() {
40
+ XCTAssertThrowsError(try CodexUsageAPI.decode(["rateLimits": NSNull()]))
41
+ XCTAssertThrowsError(try CodexUsageAPI.decode(["rateLimits": ["primary": ["resetsAt": 123]]]))
42
+ XCTAssertThrowsError(try CodexUsageAPI.decode(["rateLimitsByLimitId": ["other": ["primary": ["usedPercent": 12]]]]))
43
+ }
44
+
45
+ func testInvalidPercentageAndClamping() throws {
46
+ XCTAssertThrowsError(try CodexUsageAPI.decode(["rateLimits": ["primary": ["usedPercent": Double.nan]]]))
47
+ let result = try CodexUsageAPI.decode(["rateLimits": ["primary": ["usedPercent": 110]]])
48
+ XCTAssertEqual(result.usage.fiveHour?.fraction, 1)
49
+ }
50
+
51
+ func testClaudeDecoderStillWorks() throws {
52
+ let data = Data(#"{"five_hour":{"utilization":42,"resets_at":"2026-09-12T12:00:00Z"},"seven_day":{"utilization":71},"seven_day_opus":{"utilization":18}}"#.utf8)
53
+ let usage = try Usage(json: data)
54
+ XCTAssertEqual(usage.fiveHour?.utilization, 42)
55
+ XCTAssertNotNil(usage.fiveHour?.resetsAt)
56
+ XCTAssertEqual(usage.sevenDayOpus?.utilization, 18)
57
+ }
58
+
59
+ func testLiveCodexReadWhenRequested() throws {
60
+ guard ProcessInfo.processInfo.environment["TIBODOG_TEST_LIVE_CODEX"] == "1" else {
61
+ throw XCTSkip("Set TIBODOG_TEST_LIVE_CODEX=1 to check the installed Codex login.")
62
+ }
63
+ let result = try CodexUsageAPI.fetch()
64
+ XCTAssertTrue(result.usage.fiveHour != nil || result.usage.sevenDay != nil)
65
+ }
66
+ }
67
+
68
+ final class DogLinkTests: XCTestCase {
69
+ func testOnlyPostsOnXOpen() {
70
+ XCTAssertTrue(isPostURL(URL(string: "https://x.com/thsottiaux/status/2114588174249071102")!))
71
+ XCTAssertTrue(isPostURL(URL(string: "https://twitter.com/a/status/123456")!))
72
+ for bad in ["file:///System/Applications/Calculator.app", "http://x.com/a/status/1", "vscode://x/status/1",
73
+ "https://x.com.evil.com/a/status/1", "https://evil.com/x.com/status/1", "https://x.com/settings"] {
74
+ XCTAssertFalse(isPostURL(URL(string: bad)!), bad)
75
+ }
76
+ }
77
+ }
package/bin/tibodog.js CHANGED
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
3
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
4
  import { join, dirname } from 'node:path';
6
5
  import { isResetPost } from '../src/detect.js';
7
6
  import { xSource, rssSource, RateLimited } from '../src/sources.js';
8
7
  import { playBark, notify, openUrl } from '../src/bark.js';
9
8
  import { showDog, log, barking, SLEEPING, WATCHING } from '../src/dog.js';
10
9
  import { detectLang, getMessages } from '../src/i18n.js';
11
- import { ensureWidget, launchWidget, widgetRunning, widgetSupported, installApp, writeAlert } from '../src/widget.js';
10
+ import { ensureWidget, launchWidget, widgetRunning, widgetSupported, installApp, writeAlert, DIR } from '../src/widget.js';
12
11
 
13
- const { values: opt } = parseArgs({
14
- options: {
12
+ // Anything that slips through still gets logged instead of killing the watcher.
13
+ process.on('unhandledRejection', (e) => log(`⚠️ ${e?.message ?? e}`));
14
+
15
+ const options = {
15
16
  user: { type: 'string', default: process.env.TIBODOG_USER ?? 'thsottiaux' },
16
17
  rss: { type: 'string', default: process.env.TIBODOG_RSS },
17
18
  interval: { type: 'string', default: process.env.TIBODOG_INTERVAL },
@@ -26,8 +27,17 @@ const { values: opt } = parseArgs({
26
27
  once: { type: 'boolean', default: false },
27
28
  test: { type: 'boolean', default: false },
28
29
  help: { type: 'boolean', short: 'h', default: false },
29
- },
30
- });
30
+ };
31
+
32
+ let opt;
33
+ try {
34
+ ({ values: opt } = parseArgs({ options }));
35
+ } catch (e) {
36
+ const t = getMessages(detectLang());
37
+ console.error(`🐕 ${e.message}\n`);
38
+ console.log(t.help);
39
+ process.exit(2);
40
+ }
31
41
 
32
42
  const lang = detectLang(opt.lang);
33
43
  const t = getMessages(lang);
@@ -37,19 +47,35 @@ if (opt.help) {
37
47
  process.exit(0);
38
48
  }
39
49
 
50
+ // An X handle: also names the state file, so nothing else gets through.
51
+ if (!/^[A-Za-z0-9_]{1,15}$/.test(opt.user)) {
52
+ console.error(`🐕 --user: "${opt.user}" isn't an X handle`);
53
+ process.exit(2);
54
+ }
55
+ const keywords = opt.keyword.map((k) => k.trim()).filter(Boolean);
56
+
40
57
  const barks = Math.max(1, Number(opt.barks) || 1);
41
58
  const useWidget = widgetSupported() && !opt['no-widget'];
42
59
 
43
60
  // Puts the notch widget (usage, Now Playing, the dog) on screen, building it on first run.
44
61
  let widgetLaunchedAt = 0;
62
+ let widgetBroken = false;
45
63
  async function startWidget() {
46
- // Just opened: it can take a moment to show up as running.
47
- if (!useWidget || widgetRunning() || Date.now() - widgetLaunchedAt < 30_000) return;
48
- const { app, error } = await ensureWidget({ onBuild: () => log(t.widgetBuilding) });
49
- if (error) {
50
- log(`${t.widgetFailed}\n${error}`);
64
+ // Just opened: it can take a moment to show up as running. A failed build isn't retried this run.
65
+ if (!useWidget || widgetBroken || widgetRunning() || Date.now() - widgetLaunchedAt < 30_000) return;
66
+ let result;
67
+ try {
68
+ result = await ensureWidget({ onBuild: () => log(t.widgetBuilding) });
69
+ } catch (e) {
70
+ result = { error: e.message };
71
+ }
72
+ const { app, error, fallback } = result;
73
+ if (error) log(`${t.widgetFailed}\n${error}`);
74
+ if (!app) {
75
+ widgetBroken = true;
51
76
  return;
52
77
  }
78
+ if (fallback) widgetBroken = true; // running the last good build; don't rebuild on every alert
53
79
  launchWidget(app);
54
80
  widgetLaunchedAt = Date.now();
55
81
  log(t.widgetUp);
@@ -76,17 +102,21 @@ async function alert(post) {
76
102
  notify(t.notifyTitle, post.text.slice(0, 180));
77
103
  if (opt.open && post.url) openUrl(post.url);
78
104
  // The widget picks this up and barks in the notch until it's read.
79
- writeAlert({
80
- id: post.id ?? `test-${Date.now()}`,
81
- title: t.alertTitle,
82
- body: post.text,
83
- url: post.url,
84
- woof: t.alertWoof,
85
- open: t.alertOpen,
86
- postedAt: post.createdAt,
87
- });
105
+ try {
106
+ writeAlert({
107
+ id: post.id ?? `test-${Date.now()}`,
108
+ title: t.alertTitle,
109
+ body: post.text,
110
+ url: post.url,
111
+ woof: t.alertWoof,
112
+ open: t.alertOpen,
113
+ postedAt: post.createdAt,
114
+ });
115
+ } catch (e) {
116
+ log(`⚠️ ${e.message}`);
117
+ }
118
+ await playBark(barks, opt.sound); // the bark never waits on the widget
88
119
  await startWidget(); // in case it was quit
89
- await playBark(barks, opt.sound);
90
120
  }
91
121
 
92
122
  if (opt.test) {
@@ -94,14 +124,15 @@ if (opt.test) {
94
124
  process.exit(0);
95
125
  }
96
126
 
97
- const statePath = join(process.env.TIBODOG_HOME ?? join(homedir(), '.tibodog'), `${opt.user}.json`);
127
+ const statePath = join(DIR, `${opt.user}.json`);
98
128
  const seen = new Set((() => {
99
129
  try {
100
- return JSON.parse(readFileSync(statePath, 'utf8')).seen ?? [];
130
+ const saved = JSON.parse(readFileSync(statePath, 'utf8')).seen;
131
+ return Array.isArray(saved) ? saved.map(String) : [];
101
132
  } catch {
102
- return null;
133
+ return [];
103
134
  }
104
- })() ?? []);
135
+ })());
105
136
  const firstRun = seen.size === 0;
106
137
 
107
138
  function save() {
@@ -114,7 +145,7 @@ await startWidget();
114
145
  let source;
115
146
  try {
116
147
  source = opt.rss
117
- ? rssSource({ url: opt.rss })
148
+ ? rssSource({ url: opt.rss, username: opt.user })
118
149
  : xSource({ username: opt.user, bearerToken: process.env.X_BEARER_TOKEN, missingTokenMessage: t.noToken });
119
150
  } catch (e) {
120
151
  showDog(SLEEPING);
@@ -122,7 +153,10 @@ try {
122
153
  process.exit(1);
123
154
  }
124
155
 
125
- const intervalMs = (Number(opt.interval) || (opt.rss ? 60 : 300)) * 1000;
156
+ // Between 15 seconds and a day: anything else would hammer the feed or overflow setTimeout.
157
+ // RSS mirrors are mostly volunteer-run and refresh every few minutes at best: 5 minutes is plenty.
158
+ const seconds = Number(opt.interval) || 300;
159
+ const intervalMs = Math.min(Math.max(seconds, 15), 86_400) * 1000;
126
160
  let primed = !firstRun || opt['bark-old'];
127
161
 
128
162
  async function tick() {
@@ -131,14 +165,15 @@ async function tick() {
131
165
  let barked = 0;
132
166
  for (const p of fresh) {
133
167
  seen.add(p.id);
134
- if (primed && isResetPost(p.text, opt.keyword)) {
168
+ if (primed && isResetPost(p.text, keywords)) {
135
169
  barked++;
136
170
  await alert(p);
137
171
  }
138
172
  }
139
173
  if (!primed) log(t.primed(posts.length));
140
174
  else if (fresh.length && !barked) log(t.quiet(fresh.length));
141
- primed = true;
175
+ // An empty first fetch (a mirror's error page) doesn't count: priming waits for real posts.
176
+ if (posts.length) primed = true;
142
177
  save();
143
178
  }
144
179
 
@@ -149,7 +184,9 @@ while (true) {
149
184
  let wait = intervalMs;
150
185
  try {
151
186
  await tick();
187
+ process.exitCode = 0;
152
188
  } catch (e) {
189
+ process.exitCode = 1; // --once: let cron see a failed check
153
190
  if (e instanceof RateLimited) {
154
191
  wait = Math.max(intervalMs, e.retryAt - Date.now() + 1000);
155
192
  log(t.rateLimited(Math.round(wait / 1000)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tibodog",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "A notch widget (Claude Code/Codex usage, Now Playing) with a dog that barks when Tibo (@thsottiaux) resets the Codex limits on X",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  "app/Package.swift",
14
14
  "app/LICENSE",
15
15
  "app/Sources",
16
+ "app/Tests",
16
17
  "app/Resources",
17
18
  "app/Scripts",
18
19
  "app/Vendor",
@@ -25,7 +26,7 @@
25
26
  "test": "node --test test/*.test.js"
26
27
  },
27
28
  "engines": {
28
- "node": ">=18"
29
+ "node": ">=18.11"
29
30
  },
30
31
  "keywords": [
31
32
  "codex",
package/src/bark.js CHANGED
@@ -1,10 +1,11 @@
1
1
  // Plays the bundled bark recording, or a synthesized bark as a fallback.
2
2
 
3
3
  import { spawn } from 'node:child_process';
4
- import { writeFileSync, existsSync } from 'node:fs';
4
+ import { writeFileSync, existsSync, mkdtempSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { isPostUrl } from './sources.js';
8
9
 
9
10
  const RATE = 22050;
10
11
 
@@ -58,7 +59,8 @@ export function makeBarkWav(count = 2) {
58
59
  function players(file) {
59
60
  if (process.platform === 'darwin') return [['afplay', [file]]];
60
61
  if (process.platform === 'win32') {
61
- return [['powershell', ['-NoProfile', '-c', `(New-Object Media.SoundPlayer '${file}').PlaySync()`]]];
62
+ // The path goes in through the environment, so a quote in it can't change the command.
63
+ return [['powershell', ['-NoProfile', '-c', '(New-Object Media.SoundPlayer $env:TIBODOG_WAV).PlaySync()'], { TIBODOG_WAV: file }]];
62
64
  }
63
65
  return [
64
66
  ['paplay', [file]],
@@ -67,11 +69,16 @@ function players(file) {
67
69
  ];
68
70
  }
69
71
 
70
- function run(cmd, args) {
72
+ // Never throws: a missing player, a bad argument or a crash just means "didn't work".
73
+ function run(cmd, args, env) {
71
74
  return new Promise((resolve) => {
72
- const p = spawn(cmd, args, { stdio: 'ignore' });
73
- p.on('error', () => resolve(false));
74
- p.on('exit', (code) => resolve(code === 0));
75
+ try {
76
+ const p = spawn(cmd, args, { stdio: 'ignore', env: env ? { ...process.env, ...env } : undefined });
77
+ p.on('error', () => resolve(false));
78
+ p.on('exit', (code) => resolve(code === 0));
79
+ } catch {
80
+ resolve(false);
81
+ }
75
82
  });
76
83
  }
77
84
 
@@ -82,15 +89,15 @@ let cached = null;
82
89
 
83
90
  function synthFile() {
84
91
  if (!cached) {
85
- cached = join(tmpdir(), `tibodog-bark-${process.pid}.wav`);
92
+ cached = join(mkdtempSync(join(tmpdir(), 'tibodog-')), 'bark.wav');
86
93
  writeFileSync(cached, makeBarkWav(3));
87
94
  }
88
95
  return cached;
89
96
  }
90
97
 
91
98
  async function playOnce(file) {
92
- for (const [cmd, args] of players(file)) {
93
- if (await run(cmd, args)) return true;
99
+ for (const [cmd, args, env] of players(file)) {
100
+ if (await run(cmd, args, env)) return true;
94
101
  }
95
102
  return false;
96
103
  }
@@ -119,7 +126,9 @@ export function notify(title, message) {
119
126
  return Promise.resolve(false);
120
127
  }
121
128
 
129
+ // Only ever a post on x.com (see sources.js): never a file, an app or another scheme from a feed.
122
130
  export function openUrl(url) {
131
+ if (!isPostUrl(url)) return Promise.resolve(false);
123
132
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
124
133
  return run(cmd, [url]);
125
134
  }
package/src/detect.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const RESET = /\b(re-?set(s|ting)?|refresh(ed|ing)?|wiped)\b|리셋|초기화/i;
4
4
  const CONTEXT = /\b(limits?|usage|rate|quotas?|codex|plans?|weekly|5h|allowance)\b|사용량|한도/i;
5
5
  // How Tibo announces a reset on its own, with no "limits" in the post ("Day 21/ ... But today is a reset.").
6
- const DECLARED = /\b(today is a reset|reset day|and reset|(have|has|been|just) reset)\b/i;
6
+ const DECLARED = /\b((is|that'?s|that’s) a reset|reset day|and reset|(have|has|been|just) reset)\b/i;
7
7
  // Cheap guard against obvious non-announcements ("password reset", "factory reset").
8
8
  const NOISE = /\b(password|factory|hard reset|git reset)\b/i;
9
9
 
package/src/i18n.js CHANGED
@@ -30,7 +30,7 @@ Sources (pick one):
30
30
 
31
31
  Options:
32
32
  --user <handle> account to watch (default: thsottiaux)
33
- --interval <sec> poll interval (default: 60 for RSS, 300 for X API)
33
+ --interval <sec> poll interval (default: 300)
34
34
  --keyword <text> extra trigger phrase, repeatable
35
35
  --barks <n> times to play the bark per alert (default: 1)
36
36
  --sound <file.wav|synth> your own dog, or 'synth' for the synthesized bark
@@ -73,7 +73,7 @@ Options:
73
73
 
74
74
  옵션:
75
75
  --user <handle> 감시할 계정 (기본: thsottiaux)
76
- --interval <초> 확인 주기 (기본: RSS 60초, X API 300초)
76
+ --interval <초> 확인 주기 (기본: 300초)
77
77
  --keyword <텍스트> 추가 트리거 문구, 여러 번 지정 가능
78
78
  --barks <n> 알림당 짖는 소리 반복 횟수 (기본: 1)
79
79
  --sound <file.wav|synth> 내 개 소리 파일, 또는 synth로 합성 소리 사용
package/src/sources.js CHANGED
@@ -9,6 +9,53 @@ export class RateLimited extends Error {
9
9
  }
10
10
  }
11
11
 
12
+ // Network limits: a slow, stuck or huge response can't stall the watcher or fill memory.
13
+ const TIMEOUT_MS = 30_000;
14
+ const MAX_BYTES = 2 * 1024 * 1024;
15
+
16
+ async function get(url, headers = {}) {
17
+ return fetch(url, { headers: { 'User-Agent': UA, ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS) });
18
+ }
19
+
20
+ async function readText(res) {
21
+ if (!res.body) return '';
22
+ const reader = res.body.getReader();
23
+ const chunks = [];
24
+ let size = 0;
25
+ for (;;) {
26
+ const { done, value } = await reader.read();
27
+ if (done) break;
28
+ size += value.length;
29
+ if (size > MAX_BYTES) {
30
+ await reader.cancel();
31
+ throw new Error('response too large');
32
+ }
33
+ chunks.push(value);
34
+ }
35
+ return Buffer.concat(chunks).toString('utf8');
36
+ }
37
+
38
+ // Post text ends up in the terminal, notifications and the widget: no control characters (NULs, ANSI
39
+ // and OSC escapes), only text.
40
+ export const clean = (s) => String(s ?? '').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '');
41
+
42
+ const STATUS = /\/status(?:es)?\/(\d{5,25})(?:\D|$)/;
43
+
44
+ // The only links tibodog ever opens or hands to the widget: a post on x.com, rebuilt from its id.
45
+ // A feed's own <link> could point anywhere (file://, a custom app scheme, a local app).
46
+ export function postUrl(username, id) {
47
+ return /^\d{5,25}$/.test(String(id)) ? `https://x.com/${username || 'i'}/status/${id}` : null;
48
+ }
49
+
50
+ export function isPostUrl(url) {
51
+ try {
52
+ const u = new URL(url);
53
+ return u.protocol === 'https:' && /^(www\.|mobile\.)?(x|twitter)\.com$/.test(u.hostname) && STATUS.test(u.pathname);
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
12
59
  // --- X API v2 (needs a bearer token; timeline reads require a paid tier) ---
13
60
 
14
61
  export function xSource({ username, bearerToken, missingTokenMessage }) {
@@ -16,15 +63,14 @@ export function xSource({ username, bearerToken, missingTokenMessage }) {
16
63
  let userId = null;
17
64
 
18
65
  async function api(path) {
19
- const res = await fetch(`https://api.x.com/2${path}`, {
20
- headers: { Authorization: `Bearer ${bearerToken}`, 'User-Agent': UA },
21
- });
66
+ const res = await get(`https://api.x.com/2${path}`, { Authorization: `Bearer ${bearerToken}` });
22
67
  if (res.status === 429) {
23
68
  const reset = Number(res.headers.get('x-rate-limit-reset'));
24
69
  throw new RateLimited(reset ? reset * 1000 : Date.now() + 15 * 60_000);
25
70
  }
26
- if (!res.ok) throw new Error(`X API ${res.status}: ${(await res.text()).slice(0, 200)}`);
27
- return res.json();
71
+ const body = await readText(res);
72
+ if (!res.ok) throw new Error(`X API ${res.status}: ${clean(body).slice(0, 200)}`);
73
+ return JSON.parse(body);
28
74
  }
29
75
 
30
76
  return {
@@ -39,9 +85,9 @@ export function xSource({ username, bearerToken, missingTokenMessage }) {
39
85
  `/users/${userId}/tweets?max_results=10&exclude=retweets&tweet.fields=created_at,note_tweet`,
40
86
  );
41
87
  return data.map((t) => ({
42
- id: t.id,
43
- text: t.note_tweet?.text ?? t.text,
44
- url: `https://x.com/${username}/status/${t.id}`,
88
+ id: String(t.id),
89
+ text: clean(t.note_tweet?.text ?? t.text).trim(),
90
+ url: postUrl(username, t.id),
45
91
  createdAt: t.created_at,
46
92
  }));
47
93
  },
@@ -49,48 +95,112 @@ export function xSource({ username, bearerToken, missingTokenMessage }) {
49
95
  }
50
96
 
51
97
  // --- RSS / Atom (Nitter, RSSHub, rss.app, ... anything that mirrors the account) ---
98
+ // Scanned with indexOf rather than regexes over the whole body, so a malformed or hostile feed stays
99
+ // linear time.
100
+
101
+ const MAX_ITEMS = 50;
102
+ const ENTITIES = { lt: '<', gt: '>', quot: '"', apos: "'", amp: '&', nbsp: ' ' };
52
103
 
53
- const decode = (s) =>
54
- s
55
- .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
56
- .replace(/<[^>]+>/g, ' ')
57
- .replace(/&lt;/g, '<')
58
- .replace(/&gt;/g, '>')
59
- .replace(/&quot;/g, '"')
60
- .replace(/&#39;|&apos;/g, "'")
61
- .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
62
- .replace(/&amp;/g, '&')
63
- .replace(/\s+/g, ' ')
64
- .trim();
65
-
66
- const tag = (xml, name) => {
67
- const m = xml.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, 'i'));
68
- return m ? m[1] : '';
104
+ function unwrapCdata(s) {
105
+ let out = '';
106
+ let i = 0;
107
+ for (;;) {
108
+ const start = s.indexOf('<![CDATA[', i);
109
+ if (start === -1) return out + s.slice(i);
110
+ const end = s.indexOf(']]>', start + 9);
111
+ if (end === -1) return out + s.slice(i, start) + s.slice(start + 9);
112
+ out += s.slice(i, start) + s.slice(start + 9, end);
113
+ i = end + 3;
114
+ }
115
+ }
116
+
117
+ const stripTags = (s) => s.replace(/<[^<>]*>/g, ' ');
118
+
119
+ const entities = (s) =>
120
+ s.replace(/&(#x[0-9a-f]{1,8}|#\d{1,10}|lt|gt|quot|apos|amp|nbsp);/gi, (_, e) => {
121
+ const name = e.toLowerCase();
122
+ if (name[0] !== '#') return ENTITIES[name];
123
+ const n = name[1] === 'x' ? parseInt(name.slice(2), 16) : Number(name.slice(1));
124
+ return n > 0 && n <= 0x10ffff && (n < 0xd800 || n > 0xdfff) ? String.fromCodePoint(n) : '';
125
+ });
126
+
127
+ // Escaped HTML in descriptions (&lt;p&gt;) only becomes tags after decoding, so strip twice.
128
+ const decode = (s) => clean(stripTags(entities(stripTags(unwrapCdata(s))))).replace(/\s+/g, ' ').trim();
129
+
130
+ // The first <name ...>...</name> (or a self-closing <name .../>) in xml.
131
+ function element(xml, tagName) {
132
+ const name = tagName.toLowerCase(); // pubDate, ...
133
+ const lower = xml.toLowerCase();
134
+ let i = 0;
135
+ while ((i = lower.indexOf(`<${name}`, i)) !== -1) {
136
+ const next = lower[i + name.length + 1];
137
+ if (next === '>' || next === '/' || /\s/.test(next ?? '')) {
138
+ const headEnd = lower.indexOf('>', i);
139
+ if (headEnd === -1) return null;
140
+ const head = xml.slice(i, headEnd + 1);
141
+ if (lower[headEnd - 1] === '/') return { head, inner: '' };
142
+ const close = lower.indexOf(`</${name}`, headEnd);
143
+ if (close === -1) return null;
144
+ return { head, inner: xml.slice(headEnd + 1, close) };
145
+ }
146
+ i += name.length + 1;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ const text = (xml, ...names) => {
152
+ for (const n of names) {
153
+ const el = element(xml, n);
154
+ if (el && el.inner.trim()) return decode(el.inner);
155
+ }
156
+ return '';
69
157
  };
70
158
 
71
- export function parseFeed(xml) {
72
- const blocks = xml.match(/<(item|entry)[\s>][\s\S]*?<\/\1>/gi) ?? [];
73
- return blocks.map((b) => {
74
- const link = decode(tag(b, 'link')) || (b.match(/<link[^>]*href="([^"]+)"/i)?.[1] ?? '');
75
- const title = decode(tag(b, 'title'));
76
- const body = decode(tag(b, 'description') || tag(b, 'content') || tag(b, 'summary'));
159
+ function items(xml) {
160
+ const lower = xml.toLowerCase();
161
+ const out = [];
162
+ let i = 0;
163
+ while (out.length < MAX_ITEMS) {
164
+ const a = lower.indexOf('<item', i);
165
+ const b = lower.indexOf('<entry', i);
166
+ const start = a === -1 ? b : b === -1 ? a : Math.min(a, b);
167
+ if (start === -1) break;
168
+ const tagName = start === a ? 'item' : 'entry';
169
+ const end = lower.indexOf(`</${tagName}>`, start);
170
+ if (end === -1) break;
171
+ out.push(xml.slice(start, end));
172
+ i = end + tagName.length + 3;
173
+ }
174
+ return out;
175
+ }
176
+
177
+ export function parseFeed(xml, username) {
178
+ return items(xml).map((b) => {
179
+ const linkEl = element(b, 'link');
180
+ const href = linkEl?.head.match(/href\s*=\s*"([^"]*)"/i)?.[1] ?? '';
181
+ const link = (linkEl && decode(linkEl.inner)) || decode(href);
182
+ const guid = text(b, 'guid', 'id');
183
+ const title = text(b, 'title');
184
+ const body = text(b, 'description', 'content', 'summary');
185
+ // Mirrors link to themselves (nitter.net/<user>/status/<id>); the status id is what's shared.
186
+ const sid = link.match(STATUS)?.[1] ?? guid.match(STATUS)?.[1] ?? (/^\d{5,25}$/.test(guid) ? guid : null);
77
187
  return {
78
- id: decode(tag(b, 'guid') || tag(b, 'id')) || link,
188
+ id: sid ?? (guid || link),
79
189
  text: body.length > title.length ? body : title,
80
- url: link,
81
- createdAt: decode(tag(b, 'pubDate') || tag(b, 'published') || tag(b, 'updated')),
190
+ url: postUrl(username, sid),
191
+ createdAt: text(b, 'pubDate', 'published', 'updated'),
82
192
  };
83
193
  });
84
194
  }
85
195
 
86
- export function rssSource({ url }) {
196
+ export function rssSource({ url, username }) {
87
197
  return {
88
198
  name: `RSS ${new URL(url).host}`,
89
199
  async fetch() {
90
- const res = await fetch(url, { headers: { 'User-Agent': UA } });
200
+ const res = await get(url);
91
201
  if (res.status === 429) throw new RateLimited(Date.now() + 5 * 60_000);
92
202
  if (!res.ok) throw new Error(`RSS ${res.status}`);
93
- return parseFeed(await res.text());
203
+ return parseFeed(await readText(res), username);
94
204
  },
95
205
  };
96
206
  }