@steve31415/baselib 3.37.0 → 3.38.1

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 CHANGED
@@ -41,7 +41,9 @@ article calls, and thread assembly through direct reply edges. It includes
41
41
  one level of quotes, article bodies and URL expansions. Use one shared
42
42
  `MirrorTweetClient(mirrorUrl, serviceFetch)` and `TwitterApiIoClient(key)`
43
43
  per run; `assembleThread(id, { api, mirror, ancestors: 'all' })` returns
44
- role-tagged tweets, source counts and warnings. Batch prefetching Mirror
44
+ role-tagged tweets (with `links`: expanded URLs and link-card text), source
45
+ counts and warnings; add `replies: { rule: 'digest', followed }` for Digest's
46
+ reply rule (root-author and followed replies with their context). Batch prefetching Mirror
45
47
  ids seeds the shared cache. Reader and Snip retain their copies for now.
46
48
 
47
49
  Releasing: bump `version`, commit, and run `npm run deploy` (Coder runs it
@@ -4,11 +4,31 @@ export interface TweetAuthor {
4
4
  userName: string;
5
5
  name: string;
6
6
  }
7
+ /** TwitterAPI.io sends `url` (t.co) + `expanded_url`; Mirror stores the
8
+ * expansion in `url` and may add link-card `title`/`description`. */
7
9
  export interface TweetEntityUrl {
8
10
  url: string;
9
11
  expanded_url?: string;
10
12
  display_url?: string;
13
+ title?: string;
14
+ description?: string;
11
15
  }
16
+ /** A link card as TwitterAPI.io returns it: advanced_search gives the body
17
+ * directly, the by-id /tweets endpoint wraps it in `legacy` (probed
18
+ * 2026-10-15, test/fixtures/twitter-digest-live.json). String values such as
19
+ * `title`, `description` and `card_url` (the t.co token) are binding values. */
20
+ export interface TwitterApiIoCardBody {
21
+ name?: string;
22
+ binding_values?: {
23
+ key: string;
24
+ value?: {
25
+ string_value?: string;
26
+ };
27
+ }[];
28
+ }
29
+ export type TwitterApiIoCard = TwitterApiIoCardBody & {
30
+ legacy?: TwitterApiIoCardBody;
31
+ };
12
32
  export interface TwitterApiIoTweet {
13
33
  id: string;
14
34
  text: string;
@@ -20,6 +40,7 @@ export interface TwitterApiIoTweet {
20
40
  entities?: {
21
41
  urls?: TweetEntityUrl[];
22
42
  };
43
+ card?: TwitterApiIoCard | null;
23
44
  article?: unknown;
24
45
  articleBody?: {
25
46
  title: string;
@@ -96,7 +96,9 @@ export class TwitterApiIoClient {
96
96
  const unique = [...new Set(ids)];
97
97
  for (let i = 0; i < unique.length; i += 100) {
98
98
  const result = await this.tweets('/tweets', { tweet_ids: unique.slice(i, i + 100).join(',') });
99
- tweets.push(...result.tweets);
99
+ // An unavailable tweet comes back as a stub with an empty id and no
100
+ // author (live, 2026-10-15); it is not a tweet, so drop it here.
101
+ tweets.push(...result.tweets.filter((t) => t.id && t.author));
100
102
  apiCalls += result.apiCalls ?? 1;
101
103
  }
102
104
  return { tweets, apiCalls };
@@ -17,9 +17,11 @@ export interface MirrorTweetRecord {
17
17
  is_deleted?: boolean | number;
18
18
  deleted_at?: string | null;
19
19
  }
20
+ /** `complete` is false when paging stopped early; `warnings` says why. */
20
21
  export interface MirrorConversation {
21
22
  tweets: TwitterApiIoTweet[];
22
23
  complete: boolean;
24
+ warnings?: string[];
23
25
  }
24
26
  export interface MirrorTwitterClient {
25
27
  getTweetsByIds(ids: string[]): Promise<TwitterApiIoTweet[]>;
@@ -28,8 +30,13 @@ export interface MirrorTwitterClient {
28
30
  /** Mirror snapshots store URL expansions under `url`, without the original
29
31
  * t.co token. Keep both shapes; rendering handles the positional fallback. */
30
32
  export declare function tweetFromMirror(r: MirrorTweetRecord): TwitterApiIoTweet;
33
+ /** Rows per Mirror page (Mirror's cap) and pages per conversation read. */
34
+ export declare const MIRROR_PAGE_LIMIT = 1000;
35
+ export declare const MIRROR_CONVERSATION_MAX_PAGES = 10;
31
36
  /** Shared per run: batch prefetch seeds the id cache, conversation requests
32
- * are coalesced, and failed requests are evicted so a later link can retry. */
37
+ * are coalesced, and failed requests are evicted so a later link can retry.
38
+ * Conversations follow Mirror's keyset cursor (record_timestamp DESC, id
39
+ * DESC) while `has_more`, bounded by MIRROR_CONVERSATION_MAX_PAGES. */
33
40
  export declare class MirrorTweetClient implements MirrorTwitterClient {
34
41
  private readonly mirrorUrl;
35
42
  private readonly fetchFn;
@@ -39,4 +46,5 @@ export declare class MirrorTweetClient implements MirrorTwitterClient {
39
46
  private query;
40
47
  getTweetsByIds(ids: string[]): Promise<TwitterApiIoTweet[]>;
41
48
  getConversation(id: string): Promise<MirrorConversation>;
49
+ private readConversation;
42
50
  }
@@ -31,8 +31,13 @@ export function tweetFromMirror(r) {
31
31
  ...(r.article_content ? { articleBody: { title: r.article_title ?? '', text: r.article_content } } : {}),
32
32
  };
33
33
  }
34
+ /** Rows per Mirror page (Mirror's cap) and pages per conversation read. */
35
+ export const MIRROR_PAGE_LIMIT = 1000;
36
+ export const MIRROR_CONVERSATION_MAX_PAGES = 10;
34
37
  /** Shared per run: batch prefetch seeds the id cache, conversation requests
35
- * are coalesced, and failed requests are evicted so a later link can retry. */
38
+ * are coalesced, and failed requests are evicted so a later link can retry.
39
+ * Conversations follow Mirror's keyset cursor (record_timestamp DESC, id
40
+ * DESC) while `has_more`, bounded by MIRROR_CONVERSATION_MAX_PAGES. */
36
41
  export class MirrorTweetClient {
37
42
  mirrorUrl;
38
43
  fetchFn;
@@ -42,11 +47,11 @@ export class MirrorTweetClient {
42
47
  this.mirrorUrl = mirrorUrl;
43
48
  this.fetchFn = fetchFn;
44
49
  }
45
- async query(filter) {
50
+ async query(filter, cursor) {
46
51
  const response = await this.fetchFn(`${this.mirrorUrl.replace(/\/$/, '')}/api/records/tweets/query`, {
47
52
  audience: this.mirrorUrl.replace(/\/$/, ''), method: 'POST',
48
53
  headers: { 'content-type': 'application/json' },
49
- body: JSON.stringify({ filter_sql: filter, columns: '*', limit: 1000 }),
54
+ body: JSON.stringify({ filter_sql: filter, columns: '*', limit: MIRROR_PAGE_LIMIT, ...(cursor ? { cursor } : {}) }),
50
55
  signal: AbortSignal.timeout(30_000),
51
56
  });
52
57
  if (!response.ok)
@@ -64,7 +69,9 @@ export class MirrorTweetClient {
64
69
  }
65
70
  for (const [id, tweet] of tweets)
66
71
  this.byId.set(id, tweet);
67
- return { tweets: [...tweets.values()], complete: !data.has_more && data.records.length < 1000 };
72
+ // A full page without `has_more` is treated as more to come (Mirror always sends it today).
73
+ const hasMore = data.has_more ?? data.records.length >= MIRROR_PAGE_LIMIT;
74
+ return { tweets: [...tweets.values()], hasMore, cursor: data.cursor ?? null };
68
75
  }
69
76
  async getTweetsByIds(ids) {
70
77
  ids.forEach(assertTweetId);
@@ -83,7 +90,7 @@ export class MirrorTweetClient {
83
90
  assertTweetId(id);
84
91
  let pending = this.conversations.get(id);
85
92
  if (!pending) {
86
- pending = this.query(`conversation_id = '${id}'`).catch((error) => {
93
+ pending = this.readConversation(id).catch((error) => {
87
94
  this.conversations.delete(id);
88
95
  throw error;
89
96
  });
@@ -91,4 +98,29 @@ export class MirrorTweetClient {
91
98
  }
92
99
  return pending;
93
100
  }
101
+ async readConversation(id) {
102
+ const tweets = new Map();
103
+ const cursors = new Set();
104
+ const warnings = [];
105
+ let cursor;
106
+ for (let page = 1;; page++) {
107
+ const result = await this.query(`conversation_id = '${id}'`, cursor);
108
+ for (const tweet of result.tweets)
109
+ if (!tweets.has(tweet.id))
110
+ tweets.set(tweet.id, tweet);
111
+ if (!result.hasMore)
112
+ return { tweets: [...tweets.values()], complete: true, warnings };
113
+ if (!result.cursor || cursors.has(result.cursor)) {
114
+ warnings.push('Mirror conversation pagination cursor missing or repeated');
115
+ break;
116
+ }
117
+ if (page >= MIRROR_CONVERSATION_MAX_PAGES) {
118
+ warnings.push(`Mirror conversation reached the ${MIRROR_CONVERSATION_MAX_PAGES}-page limit`);
119
+ break;
120
+ }
121
+ cursors.add(result.cursor);
122
+ cursor = result.cursor;
123
+ }
124
+ return { tweets: [...tweets.values()], complete: false, warnings };
125
+ }
94
126
  }
package/dist/twitter.d.ts CHANGED
@@ -4,7 +4,18 @@ export * from './twitter-client.js';
4
4
  export * from './twitter-mirror.js';
5
5
  export declare function parseTweetId(value: string): string | null;
6
6
  export declare function isTwitterUrl(value: string): boolean;
7
- export type TweetRole = 'ancestor' | 'linked' | 'self-reply' | 'quoted';
7
+ /** `reply`: a tweet the digest rule includes that is neither the root, an
8
+ * ancestor of the linked tweet, nor one of the linked author's self-replies
9
+ * (a followed or root-author reply, or a stranger shown as its context). The
10
+ * root is always `ancestor` unless it is the linked tweet. */
11
+ export type TweetRole = 'ancestor' | 'linked' | 'self-reply' | 'reply' | 'quoted';
12
+ /** An expanded link with its link-card text, when Mirror or the API has it. */
13
+ export interface TweetLink {
14
+ url: string;
15
+ displayUrl?: string;
16
+ title?: string;
17
+ description?: string;
18
+ }
8
19
  export interface ThreadTweet extends TwitterApiIoTweet {
9
20
  url: string;
10
21
  role: TweetRole;
@@ -13,6 +24,7 @@ export interface ThreadTweet extends TwitterApiIoTweet {
13
24
  title: string;
14
25
  text: string;
15
26
  };
27
+ links: TweetLink[];
16
28
  }
17
29
  export interface TwitterThread {
18
30
  tweets: ThreadTweet[];
@@ -23,13 +35,38 @@ export interface TwitterThread {
23
35
  };
24
36
  warnings: string[];
25
37
  }
38
+ /** Digest's display rule (digest/src/shared/thread-builder.ts `buildThread`):
39
+ * the root, every tweet by the root author or a followed handle, and each of
40
+ * those tweets' ancestor chains (strangers included as context). `followed`
41
+ * holds handles, compared case-insensitively; a leading `@` is ignored. */
42
+ export interface DigestReplyRule {
43
+ rule: 'digest';
44
+ followed: Set<string>;
45
+ }
26
46
  export interface AssembleThreadOptions {
27
47
  api: TwitterClient;
28
48
  mirror?: MirrorTwitterClient;
49
+ /** Default rule only: the digest rule always includes every ancestor. */
29
50
  ancestors?: 'same-author' | 'all';
51
+ /** Omitted: ancestors plus the linked author's contiguous self-replies. */
52
+ replies?: DigestReplyRule;
30
53
  }
54
+ /** Bounds: ancestor chain length, API search pages, and for the digest rule
55
+ * the parent ids looked up and the tweets kept (each with a warning). */
56
+ export declare const THREAD_CHAIN_LIMIT = 50;
57
+ export declare const THREAD_SEARCH_PAGE_LIMIT = 5;
58
+ export declare const DIGEST_PARENT_LOOKUP_LIMIT = 200;
59
+ export declare const DIGEST_THREAD_TWEET_LIMIT = 300;
60
+ /** Snowflake order: a longer id is newer; equal lengths compare as strings
61
+ * (ids exceed Number.MAX_SAFE_INTEGER). Same as Digest's compareTweetIds. */
62
+ export declare function compareTweetIds(a: string, b: string): number;
31
63
  export declare function expandTweetUrls(tweet: TwitterApiIoTweet): string;
64
+ /** A tweet's URL entities, expanded, with link-card title and description:
65
+ * from the entity itself (Mirror's stored shape) or else from the tweet's
66
+ * TwitterAPI.io card, matched to the entity by its t.co `card_url`. */
67
+ export declare function tweetLinks(tweet: TwitterApiIoTweet): TweetLink[];
32
68
  /** Conversation search discovers candidates; only direct parent edges decide
33
- * membership. The linked author's replies to strangers can never become
34
- * descendants. Missing ancestors degrade visibly and preserve known text. */
69
+ * membership. By default the linked author's replies to strangers can never
70
+ * become descendants; `replies: { rule: 'digest' }` applies Digest's rule
71
+ * instead. Missing ancestors degrade visibly and preserve known text. */
35
72
  export declare function assembleThread(id: string, options: AssembleThreadOptions): Promise<TwitterThread>;
package/dist/twitter.js CHANGED
@@ -13,8 +13,21 @@ export function parseTweetId(value) {
13
13
  }
14
14
  }
15
15
  export function isTwitterUrl(value) { return parseTweetId(value) !== null; }
16
- const sameAuthor = (a, b) => !!(a.author.id && b.author.id && a.author.id === b.author.id) ||
17
- !!(a.author.userName && b.author.userName && a.author.userName.toLowerCase() === b.author.userName.toLowerCase());
16
+ /** Bounds: ancestor chain length, API search pages, and for the digest rule
17
+ * the parent ids looked up and the tweets kept (each with a warning). */
18
+ export const THREAD_CHAIN_LIMIT = 50;
19
+ export const THREAD_SEARCH_PAGE_LIMIT = 5;
20
+ export const DIGEST_PARENT_LOOKUP_LIMIT = 200;
21
+ export const DIGEST_THREAD_TWEET_LIMIT = 300;
22
+ /** Snowflake order: a longer id is newer; equal lengths compare as strings
23
+ * (ids exceed Number.MAX_SAFE_INTEGER). Same as Digest's compareTweetIds. */
24
+ export function compareTweetIds(a, b) {
25
+ if (a.length !== b.length)
26
+ return a.length - b.length;
27
+ return a.localeCompare(b);
28
+ }
29
+ const sameAuthor = (a, b) => !!(a.author?.id && b.author?.id && a.author.id === b.author.id) ||
30
+ !!(a.author?.userName && b.author?.userName && a.author.userName.toLowerCase() === b.author.userName.toLowerCase());
18
31
  export function expandTweetUrls(tweet) {
19
32
  let text = tweet.text;
20
33
  const unmatched = [];
@@ -33,11 +46,38 @@ export function expandTweetUrls(tweet) {
33
46
  text += `\nLinks: ${unmatched.join(' ')}`;
34
47
  return text.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
35
48
  }
49
+ /** A tweet's URL entities, expanded, with link-card title and description:
50
+ * from the entity itself (Mirror's stored shape) or else from the tweet's
51
+ * TwitterAPI.io card, matched to the entity by its t.co `card_url`. */
52
+ export function tweetLinks(tweet) {
53
+ const card = tweet.card?.legacy ?? tweet.card;
54
+ const values = new Map();
55
+ for (const binding of Array.isArray(card?.binding_values) ? card.binding_values : []) {
56
+ if (typeof binding?.key === 'string' && typeof binding.value?.string_value === 'string')
57
+ values.set(binding.key, binding.value.string_value);
58
+ }
59
+ const cardUrl = values.get('card_url');
60
+ const text = (value) => typeof value === 'string' && value.trim() ? value : undefined;
61
+ return (tweet.entities?.urls ?? []).filter((u) => typeof u?.url === 'string').map((u) => {
62
+ const fromCard = !!cardUrl && u.url === cardUrl;
63
+ const title = text(u.title) ?? (fromCard ? text(values.get('title')) : undefined);
64
+ const description = text(u.description) ?? (fromCard ? text(values.get('description')) : undefined);
65
+ return {
66
+ url: u.expanded_url || u.url,
67
+ ...(text(u.display_url) ? { displayUrl: u.display_url } : {}),
68
+ ...(title ? { title } : {}),
69
+ ...(description ? { description } : {}),
70
+ };
71
+ });
72
+ }
36
73
  /** Conversation search discovers candidates; only direct parent edges decide
37
- * membership. The linked author's replies to strangers can never become
38
- * descendants. Missing ancestors degrade visibly and preserve known text. */
74
+ * membership. By default the linked author's replies to strangers can never
75
+ * become descendants; `replies: { rule: 'digest' }` applies Digest's rule
76
+ * instead. Missing ancestors degrade visibly and preserve known text. */
39
77
  export async function assembleThread(id, options) {
40
78
  assertTweetId(id);
79
+ if (options.replies && options.replies.rule !== 'digest')
80
+ throw new Error('Unknown thread reply rule');
41
81
  const { api, mirror } = options;
42
82
  const result = { tweets: [], stats: { mirror2: 0, twitterApi: 0, apiCalls: 0 }, warnings: [] };
43
83
  const records = new Map();
@@ -46,7 +86,11 @@ export async function assembleThread(id, options) {
46
86
  if (!records.has(tweet.id) || source === 'mirror2')
47
87
  records.set(tweet.id, { tweet, source });
48
88
  };
49
- const warning = (message, error) => result.warnings.push(`${message}${error instanceof Error ? `: ${error.message}` : ''}`);
89
+ const warning = (message, error) => {
90
+ const text = `${message}${error instanceof Error ? `: ${error.message}` : ''}`;
91
+ if (!result.warnings.includes(text))
92
+ result.warnings.push(text); // one warning per cause
93
+ };
50
94
  const callApi = async (run) => {
51
95
  try {
52
96
  const response = await run();
@@ -73,37 +117,25 @@ export async function assembleThread(id, options) {
73
117
  if (absent.length)
74
118
  add((await callApi(() => api.getTweetsByIds(absent))).tweets, 'twitterapi');
75
119
  };
76
- await fetchIds([id]);
77
- const linked = records.get(id)?.tweet;
78
- if (!linked)
79
- throw new TweetNotFoundError(id);
80
- const cid = linked.conversationId ?? linked.id;
81
- let archivedAuthor = false;
82
- if (mirror)
83
- try {
84
- const conversation = await mirror.getConversation(cid);
85
- add(conversation.tweets, 'mirror2');
86
- archivedAuthor = conversation.complete && conversation.tweets.some((tweet) => sameAuthor(tweet, linked));
87
- if (!conversation.complete)
88
- warning('Mirror conversation reached its row limit; searching the author via API');
89
- }
90
- catch (error) {
91
- warning('Mirror conversation failed', error);
120
+ /** `conversation_id:<cid> from:<author>` — the author's replies Mirror may lack. */
121
+ const searchAuthor = async (author, cid) => {
122
+ if (!/^\w+$/.test(author.author?.userName ?? '')) {
123
+ warning('Twitter conversation author handle missing or invalid');
124
+ return;
92
125
  }
93
- if (!archivedAuthor && /^\w+$/.test(linked.author.userName)) {
94
126
  let cursor;
95
127
  const cursors = new Set();
96
- for (let page = 0; page < 5; page++) {
128
+ for (let page = 0; page < THREAD_SEARCH_PAGE_LIMIT; page++) {
97
129
  try {
98
- const search = await callApi(() => api.advancedSearch(`conversation_id:${cid} from:${linked.author.userName}`, cursor));
99
- add(search.tweets.filter((tweet) => sameAuthor(tweet, linked) && (!tweet.conversationId || tweet.conversationId === cid)), 'twitterapi');
130
+ const search = await callApi(() => api.advancedSearch(`conversation_id:${cid} from:${author.author.userName}`, cursor));
131
+ add(search.tweets.filter((tweet) => sameAuthor(tweet, author) && (!tweet.conversationId || tweet.conversationId === cid)), 'twitterapi');
100
132
  if (!search.tweets.length || !search.has_next_page)
101
133
  break;
102
134
  if (!search.next_cursor || cursors.has(search.next_cursor)) {
103
135
  warning('Twitter conversation pagination cursor missing or repeated');
104
136
  break;
105
137
  }
106
- if (page === 4)
138
+ if (page === THREAD_SEARCH_PAGE_LIMIT - 1)
107
139
  warning('Twitter conversation reached the five-page limit');
108
140
  cursors.add(search.next_cursor);
109
141
  cursor = search.next_cursor;
@@ -113,70 +145,256 @@ export async function assembleThread(id, options) {
113
145
  break;
114
146
  }
115
147
  }
116
- }
117
- else if (!archivedAuthor)
118
- warning('Twitter conversation author handle missing or invalid');
119
- const seen = new Set([id]);
120
- const ancestors = [];
121
- let current = linked;
122
- while (current.inReplyToId && ancestors.length < 50) {
123
- if (seen.has(current.inReplyToId)) {
124
- warning('Tweet ancestor cycle detected');
125
- break;
148
+ };
149
+ await fetchIds([id]);
150
+ const linked = records.get(id)?.tweet;
151
+ if (!linked)
152
+ throw new TweetNotFoundError(id);
153
+ /** Digest rule: the linked tweet's ancestors are fetched first, one parent
154
+ * at a time and outside the shared lookup budget, so a busy thread cannot
155
+ * crowd them out. Returns the top of the chain; gaps go in `linkedGaps`. */
156
+ const linkedGaps = new Set();
157
+ const fillLinkedChain = async () => {
158
+ let current = linked;
159
+ const seen = new Set([linked.id]);
160
+ for (let depth = 0; current.inReplyToId && depth < THREAD_CHAIN_LIMIT; depth++) {
161
+ const parentId = current.inReplyToId;
162
+ if (seen.has(parentId) || linkedGaps.has(parentId))
163
+ break;
164
+ try {
165
+ await fetchIds([parentId]);
166
+ }
167
+ catch (error) {
168
+ warning(`Parent ${parentId} unavailable`, error);
169
+ linkedGaps.add(parentId);
170
+ break;
171
+ }
172
+ const parent = records.get(parentId)?.tweet;
173
+ if (!parent) {
174
+ warning(`Parent ${parentId} not found`);
175
+ linkedGaps.add(parentId);
176
+ break;
177
+ }
178
+ seen.add(parentId);
179
+ current = parent;
126
180
  }
181
+ return current;
182
+ };
183
+ let cid = linked.conversationId || linked.id;
184
+ if (options.replies && !linked.conversationId && linked.inReplyToId) {
185
+ // A reply without a conversation id: its root is the top of its chain.
186
+ const top = await fillLinkedChain();
187
+ cid = top.id;
188
+ if (top.inReplyToId)
189
+ warning(`Tweet ${id} has no conversation id and its root was not found; using ${cid}`);
190
+ }
191
+ let conversation;
192
+ if (mirror)
127
193
  try {
128
- await fetchIds([current.inReplyToId]);
194
+ conversation = await mirror.getConversation(cid);
195
+ add(conversation.tweets, 'mirror2');
196
+ for (const message of conversation.warnings ?? [])
197
+ warning(message);
129
198
  }
130
199
  catch (error) {
131
- warning(`Parent ${current.inReplyToId} unavailable`, error);
132
- break;
133
- }
134
- const parent = records.get(current.inReplyToId)?.tweet;
135
- if (!parent) {
136
- warning(`Parent ${current.inReplyToId} not found`);
137
- break;
138
- }
139
- if ((options.ancestors ?? 'same-author') === 'same-author' && !sameAuthor(parent, linked))
140
- break;
141
- seen.add(parent.id);
142
- ancestors.unshift(parent);
143
- current = parent;
144
- }
145
- if (ancestors.length === 50 && current.inReplyToId)
146
- warning('Tweet ancestors reached the 50-tweet limit');
147
- const descendants = [];
148
- current = linked;
149
- while (descendants.length < 50) {
150
- const candidates = [...records.values()].map((r) => r.tweet)
151
- .filter((tweet) => tweet.inReplyToId === current.id && sameAuthor(tweet, linked) && !seen.has(tweet.id))
152
- .sort((a, b) => a.id.length - b.id.length || a.id.localeCompare(b.id));
153
- if (!candidates.length)
154
- break;
155
- if (candidates.length > 1)
156
- warning(`Multiple self-replies to ${current.id}; selected earliest branch`);
157
- current = candidates[0];
158
- seen.add(current.id);
159
- descendants.push(current);
160
- }
161
- if (descendants.length === 50)
162
- warning('Tweet self-replies reached the 50-tweet limit');
163
- const members = [...ancestors, linked, ...descendants];
200
+ warning('Mirror conversation failed', error);
201
+ }
202
+ const incompleteWarning = (who) => {
203
+ if (conversation && !conversation.complete && !conversation.warnings?.length)
204
+ warning(`Mirror conversation incomplete; searching ${who} via API`);
205
+ };
206
+ /** The default rule: ancestors (same author unless `ancestors: 'all'`) and
207
+ * the linked author's earliest contiguous chain of direct self-replies. */
208
+ const authorThread = async () => {
209
+ const archivedAuthor = !!conversation?.complete && conversation.tweets.some((tweet) => sameAuthor(tweet, linked));
210
+ incompleteWarning('the author');
211
+ if (!archivedAuthor)
212
+ await searchAuthor(linked, cid);
213
+ const seen = new Set([id]);
214
+ const ancestors = [];
215
+ let current = linked;
216
+ while (current.inReplyToId && ancestors.length < THREAD_CHAIN_LIMIT) {
217
+ if (seen.has(current.inReplyToId)) {
218
+ warning('Tweet ancestor cycle detected');
219
+ break;
220
+ }
221
+ try {
222
+ await fetchIds([current.inReplyToId]);
223
+ }
224
+ catch (error) {
225
+ warning(`Parent ${current.inReplyToId} unavailable`, error);
226
+ break;
227
+ }
228
+ const parent = records.get(current.inReplyToId)?.tweet;
229
+ if (!parent) {
230
+ warning(`Parent ${current.inReplyToId} not found`);
231
+ break;
232
+ }
233
+ if ((options.ancestors ?? 'same-author') === 'same-author' && !sameAuthor(parent, linked))
234
+ break;
235
+ seen.add(parent.id);
236
+ ancestors.unshift(parent);
237
+ current = parent;
238
+ }
239
+ if (ancestors.length === THREAD_CHAIN_LIMIT && current.inReplyToId)
240
+ warning(`Tweet ancestors reached the ${THREAD_CHAIN_LIMIT}-tweet limit`);
241
+ const descendants = [];
242
+ current = linked;
243
+ while (descendants.length < THREAD_CHAIN_LIMIT) {
244
+ const candidates = [...records.values()].map((r) => r.tweet)
245
+ .filter((tweet) => tweet.inReplyToId === current.id && sameAuthor(tweet, linked) && !seen.has(tweet.id))
246
+ .sort((a, b) => compareTweetIds(a.id, b.id));
247
+ if (!candidates.length)
248
+ break;
249
+ if (candidates.length > 1)
250
+ warning(`Multiple self-replies to ${current.id}; selected earliest branch`);
251
+ current = candidates[0];
252
+ seen.add(current.id);
253
+ descendants.push(current);
254
+ }
255
+ if (descendants.length === THREAD_CHAIN_LIMIT)
256
+ warning(`Tweet self-replies reached the ${THREAD_CHAIN_LIMIT}-tweet limit`);
257
+ return {
258
+ members: [...ancestors, linked, ...descendants],
259
+ roleOf: (tweet) => tweet.id === id ? 'linked' : ancestors.includes(tweet) ? 'ancestor' : 'self-reply',
260
+ };
261
+ };
262
+ /** Digest's rule over Mirror's conversation plus the root author's replies
263
+ * from the API. The search is skipped only when Mirror's read is complete
264
+ * and the root author is followed (Mirror polls followed accounts; for
265
+ * others it holds only what a one-off conversation fetch found — the live
266
+ * probe's unfollowed root author had a reply Mirror lacked). */
267
+ const digestThread = async (rule) => {
268
+ const followed = new Set([...rule.followed].map((handle) => handle.replace(/^@/, '').toLowerCase()));
269
+ await fillLinkedChain();
270
+ let rootFailed = linkedGaps.has(cid);
271
+ if (!rootFailed)
272
+ try {
273
+ await fetchIds([cid]);
274
+ }
275
+ catch (error) {
276
+ rootFailed = true;
277
+ warning(`Root ${cid} unavailable`, error);
278
+ }
279
+ const root = records.get(cid)?.tweet;
280
+ // Parent lookups below skip ids in `unavailable`, so each gap is reported once.
281
+ const unavailable = new Set([...linkedGaps, ...(root ? [] : [cid])]);
282
+ if (!root && !rootFailed)
283
+ warning(`Root ${cid} not found`);
284
+ else if (root) {
285
+ const archived = !!conversation?.complete && followed.has((root.author?.userName ?? '').toLowerCase());
286
+ incompleteWarning('the root author');
287
+ if (!archived)
288
+ await searchAuthor(root, cid);
289
+ }
290
+ const candidates = () => [linked, ...[...records.values()].map((r) => r.tweet)
291
+ .filter((tweet) => tweet.id !== linked.id && (tweet.conversationId || cid) === cid &&
292
+ (tweet.id === cid || (!!root && sameAuthor(tweet, root)) || followed.has((tweet.author?.userName ?? '').toLowerCase())))
293
+ .sort((a, b) => compareTweetIds(a.id, b.id))];
294
+ // Fill parents missing from every candidate's chain, one batch per depth.
295
+ let lookups = 0;
296
+ for (let round = 0;; round++) {
297
+ const missing = new Set();
298
+ const visited = new Set();
299
+ for (const candidate of candidates()) {
300
+ let tweet = candidate;
301
+ while (tweet?.inReplyToId && !visited.has(tweet.id)) {
302
+ visited.add(tweet.id);
303
+ const parent = records.get(tweet.inReplyToId)?.tweet;
304
+ if (!parent && !unavailable.has(tweet.inReplyToId))
305
+ missing.add(tweet.inReplyToId);
306
+ tweet = parent;
307
+ }
308
+ }
309
+ if (!missing.size)
310
+ break;
311
+ if (round === THREAD_CHAIN_LIMIT || lookups === DIGEST_PARENT_LOOKUP_LIMIT) {
312
+ warning(round === THREAD_CHAIN_LIMIT ? `Tweet ancestors reached the ${THREAD_CHAIN_LIMIT}-tweet limit` : `Thread parent lookups reached the ${DIGEST_PARENT_LOOKUP_LIMIT}-tweet limit`);
313
+ break;
314
+ }
315
+ const batch = [...missing].slice(0, DIGEST_PARENT_LOOKUP_LIMIT - lookups);
316
+ lookups += batch.length;
317
+ let failed = false;
318
+ try {
319
+ await fetchIds(batch);
320
+ }
321
+ catch (error) {
322
+ failed = true;
323
+ warning(`Parent lookup of ${batch.length} tweets failed`, error);
324
+ }
325
+ for (const parentId of batch) {
326
+ if (records.has(parentId))
327
+ continue;
328
+ unavailable.add(parentId);
329
+ if (!failed)
330
+ warning(`Parent ${parentId} not found`);
331
+ }
332
+ }
333
+ // Each candidate brings its chain; the linked chain always, others while they fit.
334
+ const include = new Map();
335
+ const linkedChain = new Set();
336
+ for (const candidate of candidates()) {
337
+ const chain = [];
338
+ const local = new Set();
339
+ let tweet = candidate;
340
+ while (tweet && !include.has(tweet.id)) {
341
+ if (local.has(tweet.id)) {
342
+ warning('Tweet ancestor cycle detected');
343
+ break;
344
+ }
345
+ if (chain.length > THREAD_CHAIN_LIMIT) {
346
+ warning(`Tweet ancestors reached the ${THREAD_CHAIN_LIMIT}-tweet limit`);
347
+ break;
348
+ }
349
+ local.add(tweet.id);
350
+ chain.push(tweet);
351
+ tweet = tweet.inReplyToId ? records.get(tweet.inReplyToId)?.tweet : undefined;
352
+ }
353
+ if (candidate === linked)
354
+ for (const member of chain)
355
+ linkedChain.add(member.id);
356
+ else if (include.size + chain.length > DIGEST_THREAD_TWEET_LIMIT) {
357
+ warning(`Thread reached the ${DIGEST_THREAD_TWEET_LIMIT}-tweet limit; later replies omitted`);
358
+ break;
359
+ }
360
+ for (const member of chain)
361
+ include.set(member.id, member);
362
+ }
363
+ const members = [...include.values()]
364
+ .sort((a, b) => a.id === cid ? -1 : b.id === cid ? 1 : compareTweetIds(a.id, b.id));
365
+ // Replies follow their parents in id order, so one pass finds the
366
+ // linked author's contiguous self-replies.
367
+ const selfReplies = new Set([linked.id]);
368
+ for (const tweet of members) {
369
+ if (tweet.inReplyToId && selfReplies.has(tweet.inReplyToId) && sameAuthor(tweet, linked) && !linkedChain.has(tweet.id))
370
+ selfReplies.add(tweet.id);
371
+ }
372
+ return {
373
+ members,
374
+ roleOf: (tweet) => tweet.id === id ? 'linked'
375
+ : linkedChain.has(tweet.id) || tweet.id === cid ? 'ancestor'
376
+ : selfReplies.has(tweet.id) ? 'self-reply' : 'reply',
377
+ };
378
+ };
379
+ const { members, roleOf } = options.replies ? await digestThread(options.replies) : await authorThread();
164
380
  const quoteSeen = new Set(members.map((t) => t.id));
165
381
  const articleIds = new Set();
382
+ const append = (item, role, source) => {
383
+ if (item.article)
384
+ articleIds.add(item.id);
385
+ const { article: _rawArticle, quoted_tweet: _nestedQuote, ...rest } = item;
386
+ result.tweets.push({
387
+ ...rest, text: expandTweetUrls(item), url: `https://x.com/${item.author.userName || 'i'}/status/${item.id}`,
388
+ role, source, links: tweetLinks(item), ...(item.articleBody ? { article: item.articleBody } : {}),
389
+ });
390
+ if (source === 'mirror2')
391
+ result.stats.mirror2++;
392
+ else
393
+ result.stats.twitterApi++;
394
+ };
166
395
  for (const tweet of members) {
167
396
  const source = records.get(tweet.id).source;
168
- const role = tweet.id === id ? 'linked' : ancestors.includes(tweet) ? 'ancestor' : 'self-reply';
169
- const append = (item, itemRole, itemSource) => {
170
- if (item.article)
171
- articleIds.add(item.id);
172
- const { article: _rawArticle, quoted_tweet: _nestedQuote, ...rest } = item;
173
- result.tweets.push({ ...rest, text: expandTweetUrls(item), url: `https://x.com/${item.author.userName || 'i'}/status/${item.id}`, role: itemRole, source: itemSource, ...(item.articleBody ? { article: item.articleBody } : {}) });
174
- if (itemSource === 'mirror2')
175
- result.stats.mirror2++;
176
- else
177
- result.stats.twitterApi++;
178
- };
179
- append(tweet, role, source);
397
+ append(tweet, roleOf(tweet), source);
180
398
  if (tweet.quoted_tweet && !quoteSeen.has(tweet.quoted_tweet.id)) {
181
399
  quoteSeen.add(tweet.quoted_tweet.id);
182
400
  const archivedQuote = records.get(tweet.quoted_tweet.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.37.0",
3
+ "version": "3.38.1",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",