Merge pull request #8 from alexknowshtml/feat/bookmarks-command
Add native quote tweet support to TweetData
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
- `likes` command to list your liked tweets (thanks @swairshah).
|
- `likes` command to list your liked tweets (thanks @swairshah).
|
||||||
|
- Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Query ID updater now tracks the Likes GraphQL operation.
|
- Query ID updater now tracks the Likes GraphQL operation.
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ bird query-ids --fresh
|
|||||||
|
|
||||||
Global options:
|
Global options:
|
||||||
- `--timeout <ms>`: abort requests after the given timeout (milliseconds).
|
- `--timeout <ms>`: abort requests after the given timeout (milliseconds).
|
||||||
|
- `--quote-depth <n>`: max quoted tweet depth in JSON output (default: 1; 0 disables).
|
||||||
- `--plain`: stable output (no emoji, no color).
|
- `--plain`: stable output (no emoji, no color).
|
||||||
- `--no-emoji`: disable emoji output.
|
- `--no-emoji`: disable emoji output.
|
||||||
- `--no-color`: disable ANSI colors (or set `NO_COLOR=1`).
|
- `--no-color`: disable ANSI colors (or set `NO_COLOR=1`).
|
||||||
@@ -117,12 +118,14 @@ Example `~/.config/bird/config.json5`:
|
|||||||
// Cookie source order for browser extraction (string or array)
|
// Cookie source order for browser extraction (string or array)
|
||||||
cookieSource: ["firefox", "safari"],
|
cookieSource: ["firefox", "safari"],
|
||||||
firefoxProfile: "default-release",
|
firefoxProfile: "default-release",
|
||||||
timeoutMs: 20000
|
timeoutMs: 20000,
|
||||||
|
quoteDepth: 1
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Environment shortcuts:
|
Environment shortcuts:
|
||||||
- `BIRD_TIMEOUT_MS`
|
- `BIRD_TIMEOUT_MS`
|
||||||
|
- `BIRD_QUOTE_DEPTH`
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
@@ -130,6 +133,24 @@ Environment shortcuts:
|
|||||||
- `read` returns full text for Notes and Articles when present.
|
- `read` returns full text for Notes and Articles when present.
|
||||||
- Use `--plain` for stable, script-friendly output (no emoji, no color).
|
- Use `--plain` for stable, script-friendly output (no emoji, no color).
|
||||||
|
|
||||||
|
### JSON Schema
|
||||||
|
|
||||||
|
When using `--json`, tweet objects include:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `id` | string | Tweet ID |
|
||||||
|
| `text` | string | Full tweet text (includes Note/Article content when present) |
|
||||||
|
| `author` | object | `{ username, name }` |
|
||||||
|
| `authorId` | string? | Author's user ID |
|
||||||
|
| `createdAt` | string | Timestamp |
|
||||||
|
| `replyCount` | number | Number of replies |
|
||||||
|
| `retweetCount` | number | Number of retweets |
|
||||||
|
| `likeCount` | number | Number of likes |
|
||||||
|
| `conversationId` | string | Thread conversation ID |
|
||||||
|
| `inReplyToStatusId` | string? | Parent tweet ID (present if this is a reply) |
|
||||||
|
| `quotedTweet` | object? | Embedded quote tweet (same schema; depth controlled by `--quote-depth`) |
|
||||||
|
|
||||||
## Query IDs (GraphQL)
|
## Query IDs (GraphQL)
|
||||||
|
|
||||||
X rotates GraphQL “query IDs” frequently. Each GraphQL operation is addressed as:
|
X rotates GraphQL “query IDs” frequently. Each GraphQL operation is addressed as:
|
||||||
|
|||||||
+37
-9
@@ -109,6 +109,7 @@ type BirdConfig = {
|
|||||||
firefoxProfile?: string;
|
firefoxProfile?: string;
|
||||||
cookieSource?: CookieSource | CookieSource[];
|
cookieSource?: CookieSource | CookieSource[];
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
quoteDepth?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function readConfigFile(path: string): Partial<BirdConfig> {
|
function readConfigFile(path: string): Partial<BirdConfig> {
|
||||||
@@ -193,6 +194,7 @@ program
|
|||||||
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, [])
|
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, [])
|
||||||
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect, [])
|
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect, [])
|
||||||
.option('--timeout <ms>', 'Request timeout in milliseconds')
|
.option('--timeout <ms>', 'Request timeout in milliseconds')
|
||||||
|
.option('--quote-depth <depth>', 'Max quoted tweet depth (default: 1; 0 disables)')
|
||||||
.option('--plain', 'Plain output (stable, no emoji, no color)')
|
.option('--plain', 'Plain output (stable, no emoji, no color)')
|
||||||
.option('--no-emoji', 'Disable emoji output')
|
.option('--no-emoji', 'Disable emoji output')
|
||||||
.option('--no-color', 'Disable ANSI colors (or set NO_COLOR)');
|
.option('--no-color', 'Disable ANSI colors (or set NO_COLOR)');
|
||||||
@@ -241,6 +243,23 @@ function resolveTimeoutFromOptions(options: { timeout?: string | number }): numb
|
|||||||
return resolveTimeoutMs(options.timeout, config.timeoutMs, process.env.BIRD_TIMEOUT_MS);
|
return resolveTimeoutMs(options.timeout, config.timeoutMs, process.env.BIRD_TIMEOUT_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveQuoteDepth(...values: Array<string | number | undefined | null>): number | undefined {
|
||||||
|
for (const value of values) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10);
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||||
|
return Math.floor(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number }): number | undefined {
|
||||||
|
return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, process.env.BIRD_QUOTE_DEPTH);
|
||||||
|
}
|
||||||
|
|
||||||
function detectMime(path: string): string | null {
|
function detectMime(path: string): string | null {
|
||||||
const ext = path.toLowerCase();
|
const ext = path.toLowerCase();
|
||||||
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
|
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
|
||||||
@@ -402,6 +421,7 @@ program
|
|||||||
.action(async (text: string) => {
|
.action(async (text: string) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
|
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||||
let media: MediaSpec[] = [];
|
let media: MediaSpec[] = [];
|
||||||
try {
|
try {
|
||||||
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
|
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
|
||||||
@@ -425,7 +445,7 @@ program
|
|||||||
console.error(`${l('source')}${cookies.source}`);
|
console.error(`${l('source')}${cookies.source}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs });
|
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||||
let mediaIds: string[] | undefined;
|
let mediaIds: string[] | undefined;
|
||||||
if (media.length > 0) {
|
if (media.length > 0) {
|
||||||
const uploaded: string[] = [];
|
const uploaded: string[] = [];
|
||||||
@@ -460,6 +480,7 @@ program
|
|||||||
.action(async (tweetIdOrUrl: string, text: string) => {
|
.action(async (tweetIdOrUrl: string, text: string) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
|
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||||
let media: MediaSpec[] = [];
|
let media: MediaSpec[] = [];
|
||||||
try {
|
try {
|
||||||
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
|
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
|
||||||
@@ -486,7 +507,7 @@ program
|
|||||||
|
|
||||||
console.error(`${p('info')}Replying to tweet: ${tweetId}`);
|
console.error(`${p('info')}Replying to tweet: ${tweetId}`);
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs });
|
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||||
let mediaIds: string[] | undefined;
|
let mediaIds: string[] | undefined;
|
||||||
if (media.length > 0) {
|
if (media.length > 0) {
|
||||||
const uploaded: string[] = [];
|
const uploaded: string[] = [];
|
||||||
@@ -521,6 +542,7 @@ program
|
|||||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
|
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||||
|
|
||||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||||
|
|
||||||
@@ -535,7 +557,7 @@ program
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs });
|
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||||
const result = await client.getTweet(tweetId);
|
const result = await client.getTweet(tweetId);
|
||||||
|
|
||||||
if (result.success && result.tweet) {
|
if (result.success && result.tweet) {
|
||||||
@@ -564,6 +586,7 @@ program
|
|||||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
|
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||||
|
|
||||||
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
||||||
@@ -577,7 +600,7 @@ program
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs });
|
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||||
const result = await client.getReplies(tweetId);
|
const result = await client.getReplies(tweetId);
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
@@ -597,6 +620,7 @@ program
|
|||||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
|
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||||
|
|
||||||
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
||||||
@@ -610,7 +634,7 @@ program
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs });
|
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||||
const result = await client.getThread(tweetId);
|
const result = await client.getThread(tweetId);
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
@@ -631,6 +655,7 @@ program
|
|||||||
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
|
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
|
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||||
const count = Number.parseInt(cmdOpts.count || '10', 10);
|
const count = Number.parseInt(cmdOpts.count || '10', 10);
|
||||||
|
|
||||||
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
||||||
@@ -644,7 +669,7 @@ program
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs });
|
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||||
const result = await client.search(query, count);
|
const result = await client.search(query, count);
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
@@ -665,6 +690,7 @@ program
|
|||||||
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
|
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
|
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||||
const count = Number.parseInt(cmdOpts.count || '10', 10);
|
const count = Number.parseInt(cmdOpts.count || '10', 10);
|
||||||
|
|
||||||
const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user);
|
const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user);
|
||||||
@@ -686,7 +712,7 @@ program
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs });
|
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
const who = await client.getCurrentUser();
|
const who = await client.getCurrentUser();
|
||||||
@@ -762,6 +788,7 @@ program
|
|||||||
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
|
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
|
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||||
const count = Number.parseInt(cmdOpts.count || '20', 10);
|
const count = Number.parseInt(cmdOpts.count || '20', 10);
|
||||||
|
|
||||||
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
||||||
@@ -775,7 +802,7 @@ program
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs });
|
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||||
const result = await client.getLikes(count);
|
const result = await client.getLikes(count);
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
@@ -793,6 +820,7 @@ program
|
|||||||
.action(async () => {
|
.action(async () => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
|
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||||
|
|
||||||
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
||||||
|
|
||||||
@@ -809,7 +837,7 @@ program
|
|||||||
console.error(`${l('source')}${cookies.source}`);
|
console.error(`${l('source')}${cookies.source}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs });
|
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||||
const result = await client.getCurrentUser();
|
const result = await client.getCurrentUser();
|
||||||
|
|
||||||
const credentialSource = cookies.source ?? 'env/auto-detected cookies';
|
const credentialSource = cookies.source ?? 'env/auto-detected cookies';
|
||||||
|
|||||||
@@ -41,7 +41,18 @@ const QUERY_IDS: Record<OperationName, string> = {
|
|||||||
|
|
||||||
const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array<OperationName>;
|
const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array<OperationName>;
|
||||||
|
|
||||||
|
function normalizeQuoteDepth(value?: number): number {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.floor(value));
|
||||||
|
}
|
||||||
|
|
||||||
type GraphqlTweetResult = {
|
type GraphqlTweetResult = {
|
||||||
|
__typename?: string;
|
||||||
rest_id?: string;
|
rest_id?: string;
|
||||||
legacy?: {
|
legacy?: {
|
||||||
full_text?: string;
|
full_text?: string;
|
||||||
@@ -186,6 +197,10 @@ type GraphqlTweetResult = {
|
|||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
tweet?: GraphqlTweetResult;
|
||||||
|
quoted_status_result?: {
|
||||||
|
result?: GraphqlTweetResult;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TweetResult =
|
export type TweetResult =
|
||||||
@@ -218,6 +233,8 @@ export interface TweetData {
|
|||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
inReplyToStatusId?: string;
|
inReplyToStatusId?: string;
|
||||||
|
// Optional quoted tweet; depth controlled by quoteDepth (default: 1).
|
||||||
|
quotedTweet?: TweetData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetTweetResult {
|
export interface GetTweetResult {
|
||||||
@@ -246,6 +263,8 @@ export interface TwitterClientOptions {
|
|||||||
cookies: TwitterCookies;
|
cookies: TwitterCookies;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
// Max depth for quoted tweets (0 disables). Defaults to 1.
|
||||||
|
quoteDepth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateTweetResponse {
|
interface CreateTweetResponse {
|
||||||
@@ -270,6 +289,7 @@ export class TwitterClient {
|
|||||||
private cookieHeader: string;
|
private cookieHeader: string;
|
||||||
private userAgent: string;
|
private userAgent: string;
|
||||||
private timeoutMs?: number;
|
private timeoutMs?: number;
|
||||||
|
private quoteDepth: number;
|
||||||
private clientUuid: string;
|
private clientUuid: string;
|
||||||
private clientDeviceId: string;
|
private clientDeviceId: string;
|
||||||
private clientUserId?: string;
|
private clientUserId?: string;
|
||||||
@@ -285,6 +305,7 @@ export class TwitterClient {
|
|||||||
options.userAgent ||
|
options.userAgent ||
|
||||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||||
this.timeoutMs = options.timeoutMs;
|
this.timeoutMs = options.timeoutMs;
|
||||||
|
this.quoteDepth = normalizeQuoteDepth(options.quoteDepth);
|
||||||
this.clientUuid = randomUUID();
|
this.clientUuid = randomUUID();
|
||||||
this.clientDeviceId = randomUUID();
|
this.clientDeviceId = randomUUID();
|
||||||
}
|
}
|
||||||
@@ -711,7 +732,17 @@ export class TwitterClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapTweetResult(result: GraphqlTweetResult | undefined): TweetData | undefined {
|
private unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined {
|
||||||
|
if (!result) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (result.tweet) {
|
||||||
|
return result.tweet;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapTweetResult(result: GraphqlTweetResult | undefined, quoteDepth = this.quoteDepth): TweetData | undefined {
|
||||||
const userResult = result?.core?.user_results?.result;
|
const userResult = result?.core?.user_results?.result;
|
||||||
const userLegacy = userResult?.legacy;
|
const userLegacy = userResult?.legacy;
|
||||||
const userCore = userResult?.core;
|
const userCore = userResult?.core;
|
||||||
@@ -727,6 +758,14 @@ export class TwitterClient {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let quotedTweet: TweetData | undefined;
|
||||||
|
if (quoteDepth > 0) {
|
||||||
|
const quotedResult = this.unwrapTweetResult(result.quoted_status_result?.result);
|
||||||
|
if (quotedResult) {
|
||||||
|
quotedTweet = this.mapTweetResult(quotedResult, quoteDepth - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: result.rest_id,
|
id: result.rest_id,
|
||||||
text,
|
text,
|
||||||
@@ -741,6 +780,7 @@ export class TwitterClient {
|
|||||||
name: name || username,
|
name: name || username,
|
||||||
},
|
},
|
||||||
authorId: userId,
|
authorId: userId,
|
||||||
|
quotedTweet,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { TwitterClient } from '../src/lib/twitter-client.js';
|
import { type TweetData, TwitterClient } from '../src/lib/twitter-client.js';
|
||||||
|
|
||||||
describe('TwitterClient', () => {
|
describe('TwitterClient', () => {
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
@@ -960,6 +960,95 @@ describe('TwitterClient', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('quoted tweets', () => {
|
||||||
|
const makeTweetResult = (
|
||||||
|
id: string,
|
||||||
|
text: string,
|
||||||
|
username = `user${id}`,
|
||||||
|
name = `User ${id}`,
|
||||||
|
): Record<string, unknown> => ({
|
||||||
|
rest_id: id,
|
||||||
|
legacy: {
|
||||||
|
full_text: text,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
reply_count: 0,
|
||||||
|
retweet_count: 0,
|
||||||
|
favorite_count: 0,
|
||||||
|
conversation_id_str: id,
|
||||||
|
},
|
||||||
|
core: {
|
||||||
|
user_results: {
|
||||||
|
result: {
|
||||||
|
rest_id: `u${id}`,
|
||||||
|
legacy: { screen_name: username, name },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes one level of quoted tweet by default', () => {
|
||||||
|
const quoted = makeTweetResult('2', 'quoted');
|
||||||
|
const root = makeTweetResult('1', 'root');
|
||||||
|
root.quoted_status_result = { result: quoted };
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const mapped = (
|
||||||
|
client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }
|
||||||
|
).mapTweetResult(root);
|
||||||
|
|
||||||
|
expect(mapped?.quotedTweet?.id).toBe('2');
|
||||||
|
expect(mapped?.quotedTweet?.quotedTweet).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors quoteDepth = 0', () => {
|
||||||
|
const quoted = makeTweetResult('2', 'quoted');
|
||||||
|
const root = makeTweetResult('1', 'root');
|
||||||
|
root.quoted_status_result = { result: quoted };
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies, quoteDepth: 0 });
|
||||||
|
const mapped = (
|
||||||
|
client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }
|
||||||
|
).mapTweetResult(root);
|
||||||
|
|
||||||
|
expect(mapped?.quotedTweet).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recurses when quoteDepth > 1', () => {
|
||||||
|
const quoted2 = makeTweetResult('3', 'quoted2');
|
||||||
|
const quoted1 = makeTweetResult('2', 'quoted1');
|
||||||
|
quoted1.quoted_status_result = { result: quoted2 };
|
||||||
|
const root = makeTweetResult('1', 'root');
|
||||||
|
root.quoted_status_result = { result: quoted1 };
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies, quoteDepth: 2 });
|
||||||
|
const mapped = (
|
||||||
|
client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }
|
||||||
|
).mapTweetResult(root);
|
||||||
|
|
||||||
|
expect(mapped?.quotedTweet?.id).toBe('2');
|
||||||
|
expect(mapped?.quotedTweet?.quotedTweet?.id).toBe('3');
|
||||||
|
expect(mapped?.quotedTweet?.quotedTweet?.quotedTweet).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unwraps quoted tweet visibility wrappers', () => {
|
||||||
|
const quoted = makeTweetResult('2', 'quoted');
|
||||||
|
const root = makeTweetResult('1', 'root');
|
||||||
|
root.quoted_status_result = {
|
||||||
|
result: {
|
||||||
|
__typename: 'TweetWithVisibilityResults',
|
||||||
|
tweet: quoted,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const mapped = (
|
||||||
|
client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }
|
||||||
|
).mapTweetResult(root);
|
||||||
|
|
||||||
|
expect(mapped?.quotedTweet?.id).toBe('2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('search', () => {
|
describe('search', () => {
|
||||||
let mockFetch: ReturnType<typeof vi.fn>;
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user