@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 +3 -1
- package/dist/twitter-client.d.ts +21 -0
- package/dist/twitter-client.js +3 -1
- package/dist/twitter-mirror.d.ts +9 -1
- package/dist/twitter-mirror.js +37 -5
- package/dist/twitter.d.ts +40 -3
- package/dist/twitter.js +300 -82
- package/package.json +1 -1
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
|
|
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
|
package/dist/twitter-client.d.ts
CHANGED
|
@@ -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;
|
package/dist/twitter-client.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/dist/twitter-mirror.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/twitter-mirror.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
34
|
-
* descendants
|
|
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
|
-
|
|
17
|
-
|
|
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(/&/g, '&').replace(/</g, '<').replace(/>/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.
|
|
38
|
-
* descendants
|
|
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) =>
|
|
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
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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 <
|
|
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:${
|
|
99
|
-
add(search.tweets.filter((tweet) => sameAuthor(tweet,
|
|
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 ===
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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