@steve31415/baselib 3.7.0 → 3.8.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 +9 -1
- package/dist/twitter-client.d.ts +75 -0
- package/dist/twitter-client.js +117 -0
- package/dist/twitter-mirror.d.ts +42 -0
- package/dist/twitter-mirror.js +94 -0
- package/dist/twitter.d.ts +35 -0
- package/dist/twitter.js +203 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ What it provides and why: `docs/SPEC.md`. How it's put together:
|
|
|
6
6
|
`~/migration/research/base-services-design.md` (step 5).
|
|
7
7
|
|
|
8
8
|
Server subpath exports: `config`, `log`, `auth`, `s2s`, `db`, `http`, `sync`,
|
|
9
|
-
`app-update`, `llm`, and `
|
|
9
|
+
`app-update`, `llm`, `email`, and `twitter`. Browser exports: `log-browser`, `rum`,
|
|
10
10
|
`slow-fetch`, `sync-browser`, and `app-update-browser`.
|
|
11
11
|
|
|
12
12
|
`sync` provides the Postgres event-log and server protocol primitives;
|
|
@@ -34,6 +34,14 @@ deploy (`~/plasticine-way/docs/OPERATIONS.md`, "Deployment safety").
|
|
|
34
34
|
first step of any production investigation (same doc, "Investigating
|
|
35
35
|
production state").
|
|
36
36
|
|
|
37
|
+
`twitter` provides Mirror2-first tweet lookup, TwitterAPI.io batch/search/
|
|
38
|
+
article calls, and thread assembly through direct reply edges. It includes
|
|
39
|
+
one level of quotes, article bodies and URL expansions. Use one shared
|
|
40
|
+
`MirrorTweetClient(mirrorUrl, serviceFetch)` and `TwitterApiIoClient(key)`
|
|
41
|
+
per run; `assembleThread(id, { api, mirror, ancestors: 'all' })` returns
|
|
42
|
+
role-tagged tweets, source counts and warnings. Batch prefetching Mirror
|
|
43
|
+
ids seeds the shared cache. Reader2 and Snip retain their copies for now.
|
|
44
|
+
|
|
37
45
|
Releasing: bump `version`, commit, and run `npm run deploy` (Coder runs it
|
|
38
46
|
automatically when integrating a baselib branch) — it pushes the `v<version>`
|
|
39
47
|
tag, whose workflow gates on verify and publishes via OIDC, and waits for the
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Logger } from './log.js';
|
|
2
|
+
export interface TweetAuthor {
|
|
3
|
+
id: string;
|
|
4
|
+
userName: string;
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
export interface TweetEntityUrl {
|
|
8
|
+
url: string;
|
|
9
|
+
expanded_url?: string;
|
|
10
|
+
display_url?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface TwitterApiIoTweet {
|
|
13
|
+
id: string;
|
|
14
|
+
text: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
author: TweetAuthor;
|
|
17
|
+
conversationId?: string | null;
|
|
18
|
+
inReplyToId?: string | null;
|
|
19
|
+
quoted_tweet?: TwitterApiIoTweet | null;
|
|
20
|
+
entities?: {
|
|
21
|
+
urls?: TweetEntityUrl[];
|
|
22
|
+
};
|
|
23
|
+
article?: unknown;
|
|
24
|
+
articleBody?: {
|
|
25
|
+
title: string;
|
|
26
|
+
text: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export interface TwitterApiIoResponse {
|
|
30
|
+
tweets: TwitterApiIoTweet[];
|
|
31
|
+
apiCalls?: number;
|
|
32
|
+
has_next_page?: boolean;
|
|
33
|
+
next_cursor?: string | null;
|
|
34
|
+
}
|
|
35
|
+
export interface TwitterApiIoArticleResponse {
|
|
36
|
+
apiCalls?: number;
|
|
37
|
+
article: {
|
|
38
|
+
title: string;
|
|
39
|
+
contents?: {
|
|
40
|
+
text: string;
|
|
41
|
+
}[];
|
|
42
|
+
} | null;
|
|
43
|
+
}
|
|
44
|
+
export interface TwitterClient {
|
|
45
|
+
getTweetsByIds(ids: string[]): Promise<TwitterApiIoResponse>;
|
|
46
|
+
advancedSearch(query: string, cursor?: string): Promise<TwitterApiIoResponse>;
|
|
47
|
+
getArticle(tweetId: string): Promise<TwitterApiIoArticleResponse>;
|
|
48
|
+
}
|
|
49
|
+
export declare class TweetNotFoundError extends Error {
|
|
50
|
+
readonly tweetId: string;
|
|
51
|
+
constructor(tweetId: string);
|
|
52
|
+
}
|
|
53
|
+
export declare class TwitterApiError extends Error {
|
|
54
|
+
readonly apiCalls: number;
|
|
55
|
+
constructor(message: string, apiCalls: number);
|
|
56
|
+
}
|
|
57
|
+
export declare function assertTweetId(id: string): void;
|
|
58
|
+
/** Harvested from Reader2/Mirror2: bounded retries and timeout, four active
|
|
59
|
+
* requests per shared client. Credentials and upstream bodies are never logged. */
|
|
60
|
+
export declare class TwitterApiIoClient implements TwitterClient {
|
|
61
|
+
private readonly apiKey;
|
|
62
|
+
private readonly logger?;
|
|
63
|
+
private readonly fetchFn;
|
|
64
|
+
private active;
|
|
65
|
+
private readonly waiters;
|
|
66
|
+
constructor(apiKey: string, logger?: Pick<Logger, "info" | "warn"> | undefined, fetchFn?: typeof fetch);
|
|
67
|
+
private request;
|
|
68
|
+
private tweets;
|
|
69
|
+
getTweetsByIds(ids: string[]): Promise<TwitterApiIoResponse>;
|
|
70
|
+
getTweetById(id: string): Promise<{
|
|
71
|
+
tweet: TwitterApiIoTweet;
|
|
72
|
+
}>;
|
|
73
|
+
advancedSearch(query: string, cursor?: string): Promise<TwitterApiIoResponse>;
|
|
74
|
+
getArticle(id: string): Promise<TwitterApiIoArticleResponse>;
|
|
75
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export class TweetNotFoundError extends Error {
|
|
2
|
+
tweetId;
|
|
3
|
+
constructor(tweetId) {
|
|
4
|
+
super(`Tweet ${tweetId} not found`);
|
|
5
|
+
this.tweetId = tweetId;
|
|
6
|
+
this.name = 'TweetNotFoundError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export class TwitterApiError extends Error {
|
|
10
|
+
apiCalls;
|
|
11
|
+
constructor(message, apiCalls) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.apiCalls = apiCalls;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function assertTweetId(id) {
|
|
17
|
+
if (!/^\d{1,25}$/.test(id))
|
|
18
|
+
throw new Error('Invalid tweet id');
|
|
19
|
+
}
|
|
20
|
+
/** Harvested from Reader2/Mirror2: bounded retries and timeout, four active
|
|
21
|
+
* requests per shared client. Credentials and upstream bodies are never logged. */
|
|
22
|
+
export class TwitterApiIoClient {
|
|
23
|
+
apiKey;
|
|
24
|
+
logger;
|
|
25
|
+
fetchFn;
|
|
26
|
+
active = 0;
|
|
27
|
+
waiters = [];
|
|
28
|
+
constructor(apiKey, logger, fetchFn = globalThis.fetch) {
|
|
29
|
+
this.apiKey = apiKey;
|
|
30
|
+
this.logger = logger;
|
|
31
|
+
this.fetchFn = fetchFn;
|
|
32
|
+
}
|
|
33
|
+
async request(path, params) {
|
|
34
|
+
if (this.active >= 4)
|
|
35
|
+
await new Promise((resolve) => this.waiters.push(resolve));
|
|
36
|
+
else
|
|
37
|
+
this.active++;
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(`https://api.twitterapi.io/twitter${path}`);
|
|
40
|
+
for (const [key, value] of Object.entries(params))
|
|
41
|
+
url.searchParams.set(key, value);
|
|
42
|
+
for (let attempt = 0;; attempt++) {
|
|
43
|
+
let response;
|
|
44
|
+
try {
|
|
45
|
+
response = await this.fetchFn(url.toString(), {
|
|
46
|
+
headers: { 'X-API-Key': this.apiKey }, signal: AbortSignal.timeout(30_000),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
if (attempt >= 3)
|
|
51
|
+
throw new TwitterApiError('TwitterAPI.io network request failed', attempt + 1);
|
|
52
|
+
this.logger?.warn('twitter_api_retry', { path, attempt: attempt + 1 });
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
this.logger?.info('twitter_api_response', { path, status: response.status, attempt });
|
|
57
|
+
if ((response.status === 429 || response.status >= 500) && attempt < 3) {
|
|
58
|
+
const header = response.headers.get('retry-after');
|
|
59
|
+
const seconds = header ? Number(header) : NaN;
|
|
60
|
+
const parsed = header && Number.isNaN(seconds) ? Date.parse(header) - Date.now() : seconds * 1000;
|
|
61
|
+
const delay = Number.isFinite(parsed) && parsed >= 0 ? Math.min(parsed, 30_000) : 1000 * 2 ** attempt;
|
|
62
|
+
await response.body?.cancel();
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
await response.body?.cancel();
|
|
68
|
+
throw new TwitterApiError(`TwitterAPI.io HTTP ${response.status}`, attempt + 1);
|
|
69
|
+
}
|
|
70
|
+
const data = await response.json();
|
|
71
|
+
if (data.status === 'error' || data.status === 'failed' || (data.code && data.code !== 200)) {
|
|
72
|
+
throw new TwitterApiError('TwitterAPI.io returned an API error', attempt + 1);
|
|
73
|
+
}
|
|
74
|
+
return Object.assign(data, { apiCalls: attempt + 1 });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
const next = this.waiters.shift();
|
|
79
|
+
if (next)
|
|
80
|
+
next();
|
|
81
|
+
else
|
|
82
|
+
this.active--;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async tweets(path, params) {
|
|
86
|
+
const raw = await this.request(path, params);
|
|
87
|
+
const tweets = raw.data?.tweets ?? raw.tweets;
|
|
88
|
+
if (!Array.isArray(tweets))
|
|
89
|
+
throw new Error('TwitterAPI.io response has no tweets array');
|
|
90
|
+
return { tweets, has_next_page: raw.has_next_page, next_cursor: raw.next_cursor, apiCalls: raw.apiCalls };
|
|
91
|
+
}
|
|
92
|
+
async getTweetsByIds(ids) {
|
|
93
|
+
ids.forEach(assertTweetId);
|
|
94
|
+
const tweets = [];
|
|
95
|
+
let apiCalls = 0;
|
|
96
|
+
const unique = [...new Set(ids)];
|
|
97
|
+
for (let i = 0; i < unique.length; i += 100) {
|
|
98
|
+
const result = await this.tweets('/tweets', { tweet_ids: unique.slice(i, i + 100).join(',') });
|
|
99
|
+
tweets.push(...result.tweets);
|
|
100
|
+
apiCalls += result.apiCalls ?? 1;
|
|
101
|
+
}
|
|
102
|
+
return { tweets, apiCalls };
|
|
103
|
+
}
|
|
104
|
+
async getTweetById(id) {
|
|
105
|
+
const tweet = (await this.getTweetsByIds([id])).tweets.find((t) => t.id === id);
|
|
106
|
+
if (!tweet)
|
|
107
|
+
throw new TweetNotFoundError(id);
|
|
108
|
+
return { tweet };
|
|
109
|
+
}
|
|
110
|
+
advancedSearch(query, cursor) {
|
|
111
|
+
return this.tweets('/tweet/advanced_search', { query, queryType: 'Latest', ...(cursor ? { cursor } : {}) });
|
|
112
|
+
}
|
|
113
|
+
getArticle(id) {
|
|
114
|
+
assertTweetId(id);
|
|
115
|
+
return this.request('/article', { tweet_id: id });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { serviceFetch } from './s2s.js';
|
|
2
|
+
import { type TwitterApiIoTweet, type TweetEntityUrl } from './twitter-client.js';
|
|
3
|
+
export interface MirrorTweetRecord {
|
|
4
|
+
tweet_id: string;
|
|
5
|
+
conversation_id?: string | null;
|
|
6
|
+
in_reply_to_tweet_id?: string | null;
|
|
7
|
+
author_id?: string | null;
|
|
8
|
+
author_handle?: string | null;
|
|
9
|
+
author_name?: string | null;
|
|
10
|
+
tweet_text?: string | null;
|
|
11
|
+
quoted_tweet_json?: string | null;
|
|
12
|
+
quoted_tweet_id?: string | null;
|
|
13
|
+
article_title?: string | null;
|
|
14
|
+
article_content?: string | null;
|
|
15
|
+
record_timestamp?: number | null;
|
|
16
|
+
urls?: string | TweetEntityUrl[] | null;
|
|
17
|
+
is_deleted?: boolean | number;
|
|
18
|
+
deleted_at?: string | null;
|
|
19
|
+
}
|
|
20
|
+
export interface MirrorConversation {
|
|
21
|
+
tweets: TwitterApiIoTweet[];
|
|
22
|
+
complete: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface MirrorTwitterClient {
|
|
25
|
+
getTweetsByIds(ids: string[]): Promise<TwitterApiIoTweet[]>;
|
|
26
|
+
getConversation(id: string): Promise<MirrorConversation>;
|
|
27
|
+
}
|
|
28
|
+
/** Mirror snapshots store URL expansions under `url`, without the original
|
|
29
|
+
* t.co token. Keep both shapes; rendering handles the positional fallback. */
|
|
30
|
+
export declare function tweetFromMirror(r: MirrorTweetRecord): TwitterApiIoTweet;
|
|
31
|
+
/** 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. */
|
|
33
|
+
export declare class MirrorTweetClient implements MirrorTwitterClient {
|
|
34
|
+
private readonly mirrorUrl;
|
|
35
|
+
private readonly fetchFn;
|
|
36
|
+
private readonly byId;
|
|
37
|
+
private readonly conversations;
|
|
38
|
+
constructor(mirrorUrl: string, fetchFn: typeof serviceFetch);
|
|
39
|
+
private query;
|
|
40
|
+
getTweetsByIds(ids: string[]): Promise<TwitterApiIoTweet[]>;
|
|
41
|
+
getConversation(id: string): Promise<MirrorConversation>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { assertTweetId } from './twitter-client.js';
|
|
2
|
+
/** Mirror snapshots store URL expansions under `url`, without the original
|
|
3
|
+
* t.co token. Keep both shapes; rendering handles the positional fallback. */
|
|
4
|
+
export function tweetFromMirror(r) {
|
|
5
|
+
let urls = [];
|
|
6
|
+
if (r.urls) {
|
|
7
|
+
const parsed = typeof r.urls === 'string' ? JSON.parse(r.urls) : r.urls;
|
|
8
|
+
if (Array.isArray(parsed))
|
|
9
|
+
urls = parsed.filter((u) => typeof u?.url === 'string');
|
|
10
|
+
}
|
|
11
|
+
let quoted;
|
|
12
|
+
if (r.quoted_tweet_json) {
|
|
13
|
+
const q = JSON.parse(r.quoted_tweet_json);
|
|
14
|
+
if (typeof q.id === 'string')
|
|
15
|
+
quoted = {
|
|
16
|
+
id: q.id, text: typeof q.text === 'string' ? q.text : '',
|
|
17
|
+
createdAt: typeof q.created_at === 'string' ? q.created_at : '',
|
|
18
|
+
author: { id: '', userName: String(q.author_handle ?? ''), name: String(q.author_name ?? '') },
|
|
19
|
+
...(typeof q.article_content === 'string' && q.article_content ? {
|
|
20
|
+
articleBody: { title: String(q.article_title ?? ''), text: q.article_content },
|
|
21
|
+
} : {}),
|
|
22
|
+
...(Array.isArray(q.urls) ? { entities: { urls: q.urls } } : {}),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
id: r.tweet_id, conversationId: r.conversation_id, inReplyToId: r.in_reply_to_tweet_id,
|
|
27
|
+
author: { id: r.author_id ?? '', userName: r.author_handle ?? '', name: r.author_name ?? '' },
|
|
28
|
+
text: r.tweet_text ?? '',
|
|
29
|
+
createdAt: r.record_timestamp ? new Date(r.record_timestamp * 1000).toISOString() : '',
|
|
30
|
+
entities: { urls }, quoted_tweet: quoted,
|
|
31
|
+
...(r.article_content ? { articleBody: { title: r.article_title ?? '', text: r.article_content } } : {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** 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. */
|
|
36
|
+
export class MirrorTweetClient {
|
|
37
|
+
mirrorUrl;
|
|
38
|
+
fetchFn;
|
|
39
|
+
byId = new Map();
|
|
40
|
+
conversations = new Map();
|
|
41
|
+
constructor(mirrorUrl, fetchFn) {
|
|
42
|
+
this.mirrorUrl = mirrorUrl;
|
|
43
|
+
this.fetchFn = fetchFn;
|
|
44
|
+
}
|
|
45
|
+
async query(filter) {
|
|
46
|
+
const response = await this.fetchFn(`${this.mirrorUrl.replace(/\/$/, '')}/api/records/tweets/query`, {
|
|
47
|
+
audience: this.mirrorUrl.replace(/\/$/, ''), method: 'POST',
|
|
48
|
+
headers: { 'content-type': 'application/json' },
|
|
49
|
+
body: JSON.stringify({ filter_sql: filter, columns: '*', limit: 1000 }),
|
|
50
|
+
signal: AbortSignal.timeout(30_000),
|
|
51
|
+
});
|
|
52
|
+
if (!response.ok)
|
|
53
|
+
throw new Error(`Mirror2 tweets query HTTP ${response.status}`);
|
|
54
|
+
const data = await response.json();
|
|
55
|
+
if (!Array.isArray(data.records))
|
|
56
|
+
throw new Error('Mirror2 response has no records array');
|
|
57
|
+
const tweets = new Map();
|
|
58
|
+
for (const r of data.records) {
|
|
59
|
+
if (r.is_deleted || r.deleted_at)
|
|
60
|
+
continue;
|
|
61
|
+
const tweet = tweetFromMirror(r);
|
|
62
|
+
if (!tweets.has(tweet.id))
|
|
63
|
+
tweets.set(tweet.id, tweet);
|
|
64
|
+
}
|
|
65
|
+
for (const [id, tweet] of tweets)
|
|
66
|
+
this.byId.set(id, tweet);
|
|
67
|
+
return { tweets: [...tweets.values()], complete: !data.has_more && data.records.length < 1000 };
|
|
68
|
+
}
|
|
69
|
+
async getTweetsByIds(ids) {
|
|
70
|
+
ids.forEach(assertTweetId);
|
|
71
|
+
const unique = [...new Set(ids)];
|
|
72
|
+
const missing = unique.filter((id) => !this.byId.has(id));
|
|
73
|
+
for (let i = 0; i < missing.length; i += 100) {
|
|
74
|
+
const batch = missing.slice(i, i + 100);
|
|
75
|
+
await this.query(`tweet_id IN (${batch.map((id) => `'${id}'`).join(',')})`);
|
|
76
|
+
for (const id of batch)
|
|
77
|
+
if (!this.byId.has(id))
|
|
78
|
+
this.byId.set(id, null);
|
|
79
|
+
}
|
|
80
|
+
return unique.map((id) => this.byId.get(id)).filter((t) => !!t);
|
|
81
|
+
}
|
|
82
|
+
getConversation(id) {
|
|
83
|
+
assertTweetId(id);
|
|
84
|
+
let pending = this.conversations.get(id);
|
|
85
|
+
if (!pending) {
|
|
86
|
+
pending = this.query(`conversation_id = '${id}'`).catch((error) => {
|
|
87
|
+
this.conversations.delete(id);
|
|
88
|
+
throw error;
|
|
89
|
+
});
|
|
90
|
+
this.conversations.set(id, pending);
|
|
91
|
+
}
|
|
92
|
+
return pending;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type TwitterClient, type TwitterApiIoTweet } from './twitter-client.js';
|
|
2
|
+
import type { MirrorTwitterClient } from './twitter-mirror.js';
|
|
3
|
+
export * from './twitter-client.js';
|
|
4
|
+
export * from './twitter-mirror.js';
|
|
5
|
+
export declare function parseTweetId(value: string): string | null;
|
|
6
|
+
export declare function isTwitterUrl(value: string): boolean;
|
|
7
|
+
export type TweetRole = 'ancestor' | 'linked' | 'self-reply' | 'quoted';
|
|
8
|
+
export interface ThreadTweet extends TwitterApiIoTweet {
|
|
9
|
+
url: string;
|
|
10
|
+
role: TweetRole;
|
|
11
|
+
source: 'mirror2' | 'twitterapi';
|
|
12
|
+
article?: {
|
|
13
|
+
title: string;
|
|
14
|
+
text: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface TwitterThread {
|
|
18
|
+
tweets: ThreadTweet[];
|
|
19
|
+
stats: {
|
|
20
|
+
mirror2: number;
|
|
21
|
+
twitterApi: number;
|
|
22
|
+
apiCalls: number;
|
|
23
|
+
};
|
|
24
|
+
warnings: string[];
|
|
25
|
+
}
|
|
26
|
+
export interface AssembleThreadOptions {
|
|
27
|
+
api: TwitterClient;
|
|
28
|
+
mirror?: MirrorTwitterClient;
|
|
29
|
+
ancestors?: 'same-author' | 'all';
|
|
30
|
+
}
|
|
31
|
+
export declare function expandTweetUrls(tweet: TwitterApiIoTweet): string;
|
|
32
|
+
/** 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. */
|
|
35
|
+
export declare function assembleThread(id: string, options: AssembleThreadOptions): Promise<TwitterThread>;
|
package/dist/twitter.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { assertTweetId, TweetNotFoundError, TwitterApiError } from './twitter-client.js';
|
|
2
|
+
export * from './twitter-client.js';
|
|
3
|
+
export * from './twitter-mirror.js';
|
|
4
|
+
export function parseTweetId(value) {
|
|
5
|
+
try {
|
|
6
|
+
const u = new URL(value);
|
|
7
|
+
if (!['http:', 'https:'].includes(u.protocol) || !/^(?:(?:www|mobile)\.)?(?:x\.com|twitter\.com)$/.test(u.hostname))
|
|
8
|
+
return null;
|
|
9
|
+
return u.pathname.match(/^\/(?:\w+|i\/web)\/status\/(\d{1,25})(?:\/|$)/)?.[1] ?? null;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
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());
|
|
18
|
+
export function expandTweetUrls(tweet) {
|
|
19
|
+
let text = tweet.text;
|
|
20
|
+
const unmatched = [];
|
|
21
|
+
for (const u of tweet.entities?.urls ?? []) {
|
|
22
|
+
if (u.expanded_url && u.url && text.includes(u.url))
|
|
23
|
+
text = text.split(u.url).join(u.expanded_url);
|
|
24
|
+
else if (u.url && !/^https?:\/\/t\.co\//.test(u.url) && !text.includes(u.url))
|
|
25
|
+
unmatched.push(u.expanded_url ?? u.url);
|
|
26
|
+
}
|
|
27
|
+
// Mirror does not retain t.co keys. Only substitute when unambiguous;
|
|
28
|
+
// otherwise append expansions without assigning the wrong target.
|
|
29
|
+
const tokens = [...new Set(text.match(/https?:\/\/t\.co\/[\w]+/g) ?? [])];
|
|
30
|
+
if (tokens.length === 1 && unmatched.length === 1)
|
|
31
|
+
text = text.split(tokens[0]).join(unmatched[0]);
|
|
32
|
+
else if (unmatched.length)
|
|
33
|
+
text += `\nLinks: ${unmatched.join(' ')}`;
|
|
34
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
35
|
+
}
|
|
36
|
+
/** 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. */
|
|
39
|
+
export async function assembleThread(id, options) {
|
|
40
|
+
assertTweetId(id);
|
|
41
|
+
const { api, mirror } = options;
|
|
42
|
+
const result = { tweets: [], stats: { mirror2: 0, twitterApi: 0, apiCalls: 0 }, warnings: [] };
|
|
43
|
+
const records = new Map();
|
|
44
|
+
const add = (tweets, source) => {
|
|
45
|
+
for (const tweet of tweets)
|
|
46
|
+
if (!records.has(tweet.id) || source === 'mirror2')
|
|
47
|
+
records.set(tweet.id, { tweet, source });
|
|
48
|
+
};
|
|
49
|
+
const warning = (message, error) => result.warnings.push(`${message}${error instanceof Error ? `: ${error.message}` : ''}`);
|
|
50
|
+
const callApi = async (run) => {
|
|
51
|
+
try {
|
|
52
|
+
const response = await run();
|
|
53
|
+
result.stats.apiCalls += response.apiCalls ?? 1;
|
|
54
|
+
return response;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
result.stats.apiCalls += error instanceof TwitterApiError ? error.apiCalls : 1;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const fetchIds = async (ids) => {
|
|
62
|
+
const missing = [...new Set(ids)].filter((key) => !records.has(key));
|
|
63
|
+
if (!missing.length)
|
|
64
|
+
return;
|
|
65
|
+
if (mirror)
|
|
66
|
+
try {
|
|
67
|
+
add(await mirror.getTweetsByIds(missing), 'mirror2');
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
warning('Mirror2 lookup failed', error);
|
|
71
|
+
}
|
|
72
|
+
const absent = missing.filter((key) => !records.has(key));
|
|
73
|
+
if (absent.length)
|
|
74
|
+
add((await callApi(() => api.getTweetsByIds(absent))).tweets, 'twitterapi');
|
|
75
|
+
};
|
|
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('Mirror2 conversation reached its row limit; searching the author via API');
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
warning('Mirror2 conversation failed', error);
|
|
92
|
+
}
|
|
93
|
+
if (!archivedAuthor && /^\w+$/.test(linked.author.userName)) {
|
|
94
|
+
let cursor;
|
|
95
|
+
const cursors = new Set();
|
|
96
|
+
for (let page = 0; page < 5; page++) {
|
|
97
|
+
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');
|
|
100
|
+
if (!search.tweets.length || !search.has_next_page)
|
|
101
|
+
break;
|
|
102
|
+
if (!search.next_cursor || cursors.has(search.next_cursor)) {
|
|
103
|
+
warning('Twitter conversation pagination cursor missing or repeated');
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
if (page === 4)
|
|
107
|
+
warning('Twitter conversation reached the five-page limit');
|
|
108
|
+
cursors.add(search.next_cursor);
|
|
109
|
+
cursor = search.next_cursor;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
warning('Twitter conversation search failed', error);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
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;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
await fetchIds([current.inReplyToId]);
|
|
129
|
+
}
|
|
130
|
+
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];
|
|
164
|
+
const quoteSeen = new Set(members.map((t) => t.id));
|
|
165
|
+
const articleIds = new Set();
|
|
166
|
+
for (const tweet of members) {
|
|
167
|
+
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);
|
|
180
|
+
if (tweet.quoted_tweet && !quoteSeen.has(tweet.quoted_tweet.id)) {
|
|
181
|
+
quoteSeen.add(tweet.quoted_tweet.id);
|
|
182
|
+
const archivedQuote = records.get(tweet.quoted_tweet.id);
|
|
183
|
+
append(archivedQuote?.tweet ?? tweet.quoted_tweet, 'quoted', archivedQuote?.source ?? source);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const tweet of result.tweets) {
|
|
187
|
+
const isArticle = articleIds.has(tweet.id) || /https?:\/\/(?:x\.com|twitter\.com)\/i\/article\/\d+/.test(tweet.text);
|
|
188
|
+
if (tweet.article || !isArticle)
|
|
189
|
+
continue;
|
|
190
|
+
try {
|
|
191
|
+
const { article } = await callApi(() => api.getArticle(tweet.id));
|
|
192
|
+
const text = article?.contents?.map((part) => part.text).join('\n\n') ?? '';
|
|
193
|
+
if (text.trim())
|
|
194
|
+
tweet.article = { title: article?.title ?? '', text };
|
|
195
|
+
else
|
|
196
|
+
warning(`X Article ${tweet.id} returned no body`);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
warning(`X Article ${tweet.id} unavailable`, error);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steve31415/baselib",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
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",
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
"dist"
|
|
10
10
|
],
|
|
11
11
|
"exports": {
|
|
12
|
+
"./twitter": {
|
|
13
|
+
"types": "./dist/twitter.d.ts",
|
|
14
|
+
"default": "./dist/twitter.js"
|
|
15
|
+
},
|
|
12
16
|
".": {
|
|
13
17
|
"types": "./dist/index.d.ts",
|
|
14
18
|
"default": "./dist/index.js"
|