mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
bb21e7e831
* feat(twitter): rewrite lists via GraphQL + add list-tweets The DOM-scraping / detail-click approach in PR #1053 remained fragile against X's frequent overview-page rendering changes and slow (N+1 page loads per list). Rewrite `twitter lists` to call `ListsManagementPageTimeline` GraphQL directly — one request returns all owned + subscribed lists with id/name/member_count/subscriber_count/mode. Also add `twitter list-tweets <listId>` for pulling the tweet stream from a list, completing the read-side chain (lists → pick an id → list-tweets). - lists: drop positional `user` arg (GraphQL returns only logged-in user's lists), add `id` column, change followers to exact integer from subscriber_count. - list-tweets: same GraphQL pattern as bookmarks/likes (BEARER + ct0 + dynamic queryId with static fallback + cursor pagination). - Delete obsolete lists-parser.js and lists.d.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(twitter): add list-add / list-remove with Save-button commit Two new commands to toggle list membership. X's list dialog uses a "click-to-stage, Save-to-commit" pattern — clicking a row only updates optimistic UI; the actual POST fires when the user clicks the top-right "Save" button. Pressing ESC or the close-X silently cancels the change. Implementation: - Resolve listId → name via ListsManagementPageTimeline GraphQL, so we match the dialog row by name (dialog rows have no data-testid listId). - Open profile page → DOM click "…" menu → "Add/remove from Lists". - Scroll dialog to locate target row (virtualized list). - page.nativeClick on row — trusted CDP Input.dispatchMouseEvent fires React's onclick, flips aria-checked (.click() alone does not suffice; X ignores non-trusted events for list mutations). - page.nativeClick on the Save button — commits to server. - Verify by re-fetching ListsManagementPageTimeline and diffing member_count: success only if N→N±1. No silent successes. This fixes the pattern where batch `list-add` calls returned success for every user but committed zero to the server (optimistic UI lied). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: stabilize twitter list manifest and query ids * docs: add twitter list command discoverability --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: jackwener <jakevingoo@gmail.com>
118 lines
4.4 KiB
JavaScript
118 lines
4.4 KiB
JavaScript
import { describe, expect, it } from 'vitest';
|
|
import { extractListEntry, parseListsManagement } from './lists.js';
|
|
|
|
describe('twitter lists parser', () => {
|
|
it('extracts a list entry with full metadata', () => {
|
|
const entry = {
|
|
content: {
|
|
itemContent: {
|
|
list: {
|
|
id_str: '1597593475389984769',
|
|
name: 'Crypto',
|
|
member_count: 44,
|
|
subscriber_count: 8747,
|
|
mode: 'Public',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
expect(extractListEntry(entry, new Set())).toEqual({
|
|
id: '1597593475389984769',
|
|
name: 'Crypto',
|
|
members: '44',
|
|
followers: '8747',
|
|
mode: 'public',
|
|
});
|
|
});
|
|
|
|
it('maps Private mode to private', () => {
|
|
const entry = {
|
|
content: {
|
|
itemContent: {
|
|
list: {
|
|
id_str: '2044679538156912976',
|
|
name: 'AI & Agents',
|
|
member_count: 15,
|
|
subscriber_count: 0,
|
|
mode: 'Private',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
expect(extractListEntry(entry, new Set())?.mode).toBe('private');
|
|
});
|
|
|
|
it('deduplicates by list id', () => {
|
|
const entry = {
|
|
content: { itemContent: { list: { id_str: '1', name: 'X' } } },
|
|
};
|
|
const seen = new Set();
|
|
expect(extractListEntry(entry, seen)).not.toBeNull();
|
|
expect(extractListEntry(entry, seen)).toBeNull();
|
|
});
|
|
|
|
it('returns null when no list payload is present', () => {
|
|
expect(extractListEntry({}, new Set())).toBeNull();
|
|
expect(extractListEntry({ content: { itemContent: {} } }, new Set())).toBeNull();
|
|
});
|
|
|
|
it('parses ListsManagementPageTimeline payload instructions', () => {
|
|
const payload = {
|
|
data: {
|
|
viewer: {
|
|
list_management_timeline: {
|
|
timeline: {
|
|
instructions: [
|
|
{
|
|
entries: [
|
|
{
|
|
entryId: 'owned-list-1',
|
|
content: {
|
|
itemContent: {
|
|
list: { id_str: '1', name: 'Crypto', member_count: 44, subscriber_count: 8747, mode: 'Public' },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
entryId: 'subscribed-list-2',
|
|
content: {
|
|
itemContent: {
|
|
list: { id_str: '2', name: 'AI', member_count: 15, subscriber_count: 0, mode: 'Private' },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const result = parseListsManagement(payload, new Set());
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]).toMatchObject({ id: '1', name: 'Crypto', mode: 'public' });
|
|
expect(result[1]).toMatchObject({ id: '2', name: 'AI', mode: 'private' });
|
|
});
|
|
|
|
it('returns empty list for malformed payload', () => {
|
|
expect(parseListsManagement({}, new Set())).toEqual([]);
|
|
expect(parseListsManagement({ data: {} }, new Set())).toEqual([]);
|
|
});
|
|
|
|
it('dedupes across repeated entries', () => {
|
|
const entryA = { content: { itemContent: { list: { id_str: '1', name: 'A' } } } };
|
|
const payload = {
|
|
data: {
|
|
viewer: {
|
|
list_management_timeline: {
|
|
timeline: { instructions: [{ entries: [entryA, entryA] }] },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const result = parseListsManagement(payload, new Set());
|
|
expect(result).toHaveLength(1);
|
|
});
|
|
});
|