@steve31415/baselib 3.36.2 → 3.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/log-browser.js +1 -1
- package/dist/log-core.d.ts +25 -6
- package/dist/log-core.js +20 -3
- package/dist/twitter-client.d.ts +21 -0
- 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 +298 -80
- 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/log-browser.js
CHANGED
|
@@ -88,7 +88,7 @@ export function createBrowserLogger(opts = {}) {
|
|
|
88
88
|
// keeping are often the ones an offline stretch causes: while the
|
|
89
89
|
// browser reports itself offline, failed attempts don't count, so a
|
|
90
90
|
// batch waits out the outage (until the page dies or the buffer cap
|
|
91
|
-
// evicts it) instead of being dropped after ~
|
|
91
|
+
// evicts it) instead of being dropped after its 6 attempts (~5 min).
|
|
92
92
|
isOffline: () => navigator.onLine === false,
|
|
93
93
|
onGiveUp: (batch, error, reason) => console.warn(`log ship dropped a batch (${reason}): ${String(error)}`, {
|
|
94
94
|
batch_id: batch.id,
|
package/dist/log-core.d.ts
CHANGED
|
@@ -54,13 +54,16 @@ export interface ShipperOptions {
|
|
|
54
54
|
token: string;
|
|
55
55
|
batchSize?: number;
|
|
56
56
|
intervalMs?: number;
|
|
57
|
-
/** Total delivery attempts per batch (default
|
|
57
|
+
/** Total delivery attempts per batch (default 6; see DEFAULT_RETRY_DELAYS_MS). */
|
|
58
58
|
maxAttempts?: number;
|
|
59
|
-
/** Backoff before attempt 2, 3, … (default
|
|
60
|
-
* Cloud Run instance the CPU is throttled between requests, so these
|
|
59
|
+
/** Backoff before attempt 2, 3, … (default DEFAULT_RETRY_DELAYS_MS). On an
|
|
60
|
+
* idle Cloud Run instance the CPU is throttled between requests, so these
|
|
61
61
|
* fire at the next CPU window (next request or shutdown), not on the
|
|
62
|
-
* wall clock. */
|
|
62
|
+
* wall clock — which only stretches the budget. */
|
|
63
63
|
retryDelaysMs?: number[];
|
|
64
|
+
/** While isOffline() is true, how long a batch waits before its next
|
|
65
|
+
* (uncounted) attempt (default 40 s). */
|
|
66
|
+
offlineRetryMs?: number;
|
|
64
67
|
/** Per-attempt fetch timeout (default 10s; 3s aborted most of the fleet's
|
|
65
68
|
* attempts in the two weeks to 2026-08-28). */
|
|
66
69
|
attemptTimeoutMs?: number;
|
|
@@ -74,8 +77,8 @@ export interface ShipperOptions {
|
|
|
74
77
|
/** True while the platform knows it has no network (the browser passes
|
|
75
78
|
* `navigator.onLine === false`). A failed attempt made while offline says
|
|
76
79
|
* nothing about the batch or the ingest service, so it is not counted
|
|
77
|
-
* toward maxAttempts and not reported: the batch waits and retries
|
|
78
|
-
*
|
|
80
|
+
* toward maxAttempts and not reported: the batch waits and retries after
|
|
81
|
+
* offlineRetryMs, or at once on resume(). The signal only decides
|
|
79
82
|
* what a failure means — attempts still run while "offline". */
|
|
80
83
|
isOffline?: () => boolean;
|
|
81
84
|
/** A delivery attempt failed; the batch will be retried. */
|
|
@@ -86,6 +89,21 @@ export interface ShipperOptions {
|
|
|
86
89
|
* keep, listed in `failures`. Those events are lost; the rest landed. */
|
|
87
90
|
onPartialFailure?: (batch: ShippedBatch, failed: number, failures: unknown[]) => void;
|
|
88
91
|
}
|
|
92
|
+
/** The default retry budget: 6 attempts with 5 + 15 + 40 + 90 + 150 s =
|
|
93
|
+
* 5 minutes of backoff between the first and the last.
|
|
94
|
+
*
|
|
95
|
+
* Deliberate choice, 2026-10-15 (watchdog task dc78e9b2): the earlier
|
|
96
|
+
* 4 attempts over 5 + 15 + 40 s (~60 s) dropped a mirror batch on
|
|
97
|
+
* 2026-10-14, when Axiom ingest answered 503 on and off for ~150 s — 2.5×
|
|
98
|
+
* the budget — the only ingest outage in the 4 weeks of markers on record.
|
|
99
|
+
* 5 minutes covers that episode twice over. Headroom: at the fleet's peak
|
|
100
|
+
* five-minute volume (mirror, 1 538 events, ~0.3 MB, 2026-10-08..15) the
|
|
101
|
+
* events held through a 5-minute outage fill ~31% of the 5 000-event /
|
|
102
|
+
* 5 MB buffer cap, so the cap, not the attempt count, bounds a longer
|
|
103
|
+
* outage. Longer delays cost nothing on a throttled instance (they ride the
|
|
104
|
+
* next CPU window either way) and only matter while ingest is failing. */
|
|
105
|
+
export declare const DEFAULT_MAX_ATTEMPTS = 6;
|
|
106
|
+
export declare const DEFAULT_RETRY_DELAYS_MS: readonly number[];
|
|
89
107
|
export declare class LogShipper {
|
|
90
108
|
private readonly opts;
|
|
91
109
|
private queue;
|
|
@@ -102,6 +120,7 @@ export declare class LogShipper {
|
|
|
102
120
|
private readonly intervalMs;
|
|
103
121
|
private readonly maxAttempts;
|
|
104
122
|
private readonly retryDelaysMs;
|
|
123
|
+
private readonly offlineRetryMs;
|
|
105
124
|
private readonly attemptTimeoutMs;
|
|
106
125
|
private readonly maxBufferedEvents;
|
|
107
126
|
private readonly maxBufferedBytes;
|
package/dist/log-core.js
CHANGED
|
@@ -45,6 +45,21 @@ export function serializeError(err) {
|
|
|
45
45
|
export function truncate(s, max) {
|
|
46
46
|
return s.length <= max ? s : s.slice(0, max) + `…[+${s.length - max}]`;
|
|
47
47
|
}
|
|
48
|
+
/** The default retry budget: 6 attempts with 5 + 15 + 40 + 90 + 150 s =
|
|
49
|
+
* 5 minutes of backoff between the first and the last.
|
|
50
|
+
*
|
|
51
|
+
* Deliberate choice, 2026-10-15 (watchdog task dc78e9b2): the earlier
|
|
52
|
+
* 4 attempts over 5 + 15 + 40 s (~60 s) dropped a mirror batch on
|
|
53
|
+
* 2026-10-14, when Axiom ingest answered 503 on and off for ~150 s — 2.5×
|
|
54
|
+
* the budget — the only ingest outage in the 4 weeks of markers on record.
|
|
55
|
+
* 5 minutes covers that episode twice over. Headroom: at the fleet's peak
|
|
56
|
+
* five-minute volume (mirror, 1 538 events, ~0.3 MB, 2026-10-08..15) the
|
|
57
|
+
* events held through a 5-minute outage fill ~31% of the 5 000-event /
|
|
58
|
+
* 5 MB buffer cap, so the cap, not the attempt count, bounds a longer
|
|
59
|
+
* outage. Longer delays cost nothing on a throttled instance (they ride the
|
|
60
|
+
* next CPU window either way) and only matter while ingest is failing. */
|
|
61
|
+
export const DEFAULT_MAX_ATTEMPTS = 6;
|
|
62
|
+
export const DEFAULT_RETRY_DELAYS_MS = [5_000, 15_000, 40_000, 90_000, 150_000];
|
|
48
63
|
const RESPONSE_BODY_MAX = 300;
|
|
49
64
|
/** A 4xx is a verdict on the bytes, not the moment: retrying the identical
|
|
50
65
|
* body cannot succeed. 408 (timeout) and 429 (rate limit) are the exceptions. */
|
|
@@ -88,6 +103,7 @@ export class LogShipper {
|
|
|
88
103
|
intervalMs;
|
|
89
104
|
maxAttempts;
|
|
90
105
|
retryDelaysMs;
|
|
106
|
+
offlineRetryMs;
|
|
91
107
|
attemptTimeoutMs;
|
|
92
108
|
maxBufferedEvents;
|
|
93
109
|
maxBufferedBytes;
|
|
@@ -95,8 +111,9 @@ export class LogShipper {
|
|
|
95
111
|
this.opts = opts;
|
|
96
112
|
this.batchSize = opts.batchSize ?? 50;
|
|
97
113
|
this.intervalMs = opts.intervalMs ?? 2000;
|
|
98
|
-
this.maxAttempts = opts.maxAttempts ??
|
|
99
|
-
this.retryDelaysMs = opts.retryDelaysMs ??
|
|
114
|
+
this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
115
|
+
this.retryDelaysMs = opts.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
|
|
116
|
+
this.offlineRetryMs = opts.offlineRetryMs ?? 40_000;
|
|
100
117
|
this.attemptTimeoutMs = opts.attemptTimeoutMs ?? 10_000;
|
|
101
118
|
this.maxBufferedEvents = opts.maxBufferedEvents ?? 5_000;
|
|
102
119
|
this.maxBufferedBytes = opts.maxBufferedBytes ?? 5 * 1024 * 1024;
|
|
@@ -259,7 +276,7 @@ export class LogShipper {
|
|
|
259
276
|
if (!outcome.ok && !outcome.terminal && this.opts.isOffline?.()) {
|
|
260
277
|
// Uncounted: the budget is for failures that could be the batch's
|
|
261
278
|
// or the service's, and an offline failure is neither.
|
|
262
|
-
batch.nextAttemptAt = Date.now() + this.
|
|
279
|
+
batch.nextAttemptAt = Date.now() + this.offlineRetryMs;
|
|
263
280
|
this.schedulePump();
|
|
264
281
|
return;
|
|
265
282
|
}
|
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-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,6 +13,19 @@ export function parseTweetId(value) {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
export function isTwitterUrl(value) { return parseTweetId(value) !== null; }
|
|
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
|
+
}
|
|
16
29
|
const sameAuthor = (a, b) => !!(a.author.id && b.author.id && a.author.id === b.author.id) ||
|
|
17
30
|
!!(a.author.userName && b.author.userName && a.author.userName.toLowerCase() === b.author.userName.toLowerCase());
|
|
18
31
|
export function expandTweetUrls(tweet) {
|
|
@@ -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