tibodog 0.1.2 → 0.1.3

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,9 @@ 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
+ const seconds = Number(opt.interval) || (opt.rss ? 60 : 300);
158
+ const intervalMs = Math.min(Math.max(seconds, 15), 86_400) * 1000;
126
159
  let primed = !firstRun || opt['bark-old'];
127
160
 
128
161
  async function tick() {
@@ -131,14 +164,15 @@ async function tick() {
131
164
  let barked = 0;
132
165
  for (const p of fresh) {
133
166
  seen.add(p.id);
134
- if (primed && isResetPost(p.text, opt.keyword)) {
167
+ if (primed && isResetPost(p.text, keywords)) {
135
168
  barked++;
136
169
  await alert(p);
137
170
  }
138
171
  }
139
172
  if (!primed) log(t.primed(posts.length));
140
173
  else if (fresh.length && !barked) log(t.quiet(fresh.length));
141
- primed = true;
174
+ // An empty first fetch (a mirror's error page) doesn't count: priming waits for real posts.
175
+ if (posts.length) primed = true;
142
176
  save();
143
177
  }
144
178
 
@@ -149,7 +183,9 @@ while (true) {
149
183
  let wait = intervalMs;
150
184
  try {
151
185
  await tick();
186
+ process.exitCode = 0;
152
187
  } catch (e) {
188
+ process.exitCode = 1; // --once: let cron see a failed check
153
189
  if (e instanceof RateLimited) {
154
190
  wait = Math.max(intervalMs, e.retryAt - Date.now() + 1000);
155
191
  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.3",
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/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,111 @@ 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, name) {
132
+ const lower = xml.toLowerCase();
133
+ let i = 0;
134
+ while ((i = lower.indexOf(`<${name}`, i)) !== -1) {
135
+ const next = lower[i + name.length + 1];
136
+ if (next === '>' || next === '/' || /\s/.test(next ?? '')) {
137
+ const headEnd = lower.indexOf('>', i);
138
+ if (headEnd === -1) return null;
139
+ const head = xml.slice(i, headEnd + 1);
140
+ if (lower[headEnd - 1] === '/') return { head, inner: '' };
141
+ const close = lower.indexOf(`</${name}`, headEnd);
142
+ if (close === -1) return null;
143
+ return { head, inner: xml.slice(headEnd + 1, close) };
144
+ }
145
+ i += name.length + 1;
146
+ }
147
+ return null;
148
+ }
149
+
150
+ const text = (xml, ...names) => {
151
+ for (const n of names) {
152
+ const el = element(xml, n);
153
+ if (el && el.inner.trim()) return decode(el.inner);
154
+ }
155
+ return '';
69
156
  };
70
157
 
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'));
158
+ function items(xml) {
159
+ const lower = xml.toLowerCase();
160
+ const out = [];
161
+ let i = 0;
162
+ while (out.length < MAX_ITEMS) {
163
+ const a = lower.indexOf('<item', i);
164
+ const b = lower.indexOf('<entry', i);
165
+ const start = a === -1 ? b : b === -1 ? a : Math.min(a, b);
166
+ if (start === -1) break;
167
+ const tagName = start === a ? 'item' : 'entry';
168
+ const end = lower.indexOf(`</${tagName}>`, start);
169
+ if (end === -1) break;
170
+ out.push(xml.slice(start, end));
171
+ i = end + tagName.length + 3;
172
+ }
173
+ return out;
174
+ }
175
+
176
+ export function parseFeed(xml, username) {
177
+ return items(xml).map((b) => {
178
+ const linkEl = element(b, 'link');
179
+ const href = linkEl?.head.match(/href\s*=\s*"([^"]*)"/i)?.[1] ?? '';
180
+ const link = (linkEl && decode(linkEl.inner)) || decode(href);
181
+ const guid = text(b, 'guid', 'id');
182
+ const title = text(b, 'title');
183
+ const body = text(b, 'description', 'content', 'summary');
184
+ // Mirrors link to themselves (nitter.net/<user>/status/<id>); the status id is what's shared.
185
+ const sid = link.match(STATUS)?.[1] ?? guid.match(STATUS)?.[1] ?? (/^\d{5,25}$/.test(guid) ? guid : null);
77
186
  return {
78
- id: decode(tag(b, 'guid') || tag(b, 'id')) || link,
187
+ id: sid ?? (guid || link),
79
188
  text: body.length > title.length ? body : title,
80
- url: link,
81
- createdAt: decode(tag(b, 'pubDate') || tag(b, 'published') || tag(b, 'updated')),
189
+ url: postUrl(username, sid),
190
+ createdAt: text(b, 'pubDate', 'published', 'updated'),
82
191
  };
83
192
  });
84
193
  }
85
194
 
86
- export function rssSource({ url }) {
195
+ export function rssSource({ url, username }) {
87
196
  return {
88
197
  name: `RSS ${new URL(url).host}`,
89
198
  async fetch() {
90
- const res = await fetch(url, { headers: { 'User-Agent': UA } });
199
+ const res = await get(url);
91
200
  if (res.status === 429) throw new RateLimited(Date.now() + 5 * 60_000);
92
201
  if (!res.ok) throw new Error(`RSS ${res.status}`);
93
- return parseFeed(await res.text());
202
+ return parseFeed(await readText(res), username);
94
203
  },
95
204
  };
96
205
  }