Skip to content

Commit 4475d4e

Browse files
authored
feat(twitter): P1+P2+P3+P4+P5 — search filters, bookmark folders, engagement scoring, sibling dedupe + help docs (#1406)
Round 21 follow-up to #1400 (P0 write-action symmetry, merged `644d4517`). 5 features + help docs unified into one PR per WAWQAQ "全部合成一个 PR" directive. ## Scope - **P1** (`cf10c098`): `twitter search` `--from / --has / --exclude / --product` filters, mapping to X `from:` / `filter:` / `-filter:` / `f=` operators; legacy `--filter top|live` preserved (--product win on conflict) - **P2** (`a484a69a`): new `twitter bookmark-folders` + `bookmark-folder <id>`; X Premium GraphQL `bookmarkFoldersSlice` + `BookmarkFolderTimeline`; queryId 三层 fallback (placeholder.json → client-web bundle → pinned constants) - **P3** (`f209f914`): `--top-by-engagement N` to 7 tweet-shaped read commands (search/timeline/likes/bookmarks/list-tweets/tweets/thread); single helper in `utils.js`; formula `likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5`; **N=0 reference equality no-op** → existing 157 twitter tests 0 churn - **P4** (`89283fa0`): `TWITTER_BEARER_TOKEN` + composer image helpers extracted to `utils.js` (12 GraphQL adapter dedup); reply hardening; quote adds `--image` - **P5** (`a3d10a48`): sibling article-scope helper extracted to `shared.js` (9 write commands reuse, dedup with #1400 P0 invariant) - **docs** (`2a358d80`): help-doc precision (positional-omitted defaults + download/bookmarks/notifications/timeline/lists description thicken; concurrent #1401/#1403 wording preserved) 47 files / +2594/-470. Tests **96 → 216 (+120)**, manifest 798 → 801 (+3), typed-error-lint 190 → 189 (resolved 1 grandfathered sentinel). ## Iteration history (3 review fix commits on top of 6 author commits) - `7f93779b` — codex-mini1 lead fix1: 3 blocker bundle (P5 host invariant + P2 safe-id + sentinel removal + P1 fallback fail-fast) - `2a29ecc6` — codex-mini1 lead fix2: P3 help formula consistency (doc/help text matches actual `log10(views+1)×0.5`) - `df4dcd76` — codex-mini1 lead fix3 (F-P-1 aux catch): P2 `bookmark-folder --limit` upfront validation (`Number(kwargs.limit ?? 20)` + reject non-positive/non-integer + regression `0/negative/fractional/NaN` + `page.goto` zero-call assert) ## 4 progressive blockers caught (codex-mini1 lead 3 rounds + F-P-1 aux 1 round) 1. **P5 host invariant gap** (lead): article-scope helper preserved exact `/status/<id>` path but ignored link host → off-domain `https://evil.com/alice/status/<target>` would satisfy `__twHasLinkToTarget`. Fixed: `https` + X/Twitter host or subdomain + exact `/status/<id>` or `/i/status/<id>` path; query/hash allowed; off-domain/host-suffix/non-https/path-suffix/substring-id rejected; JSDOM positive + 5 negative anchors. 2. **P2 listing→detail round-trip + sentinel** (lead): `bookmark-folders` accepted opaque IDs but `bookmark-folder <id>` only accepted numeric → round-trip broken; new `author: 'unknown'` sentinel created fabricated author URL. Fixed: `[A-Za-z0-9_-]+` opaque safe-id (rejects `/`, `?`, `%`, spaces) + `resolveTwitterQueryId()` sanitization for queryId resolution; sentinel removed → empty author + canonical `/i/status/<id>` URL. 3. **P1 fallback silent tab miss** (lead): pushState fail → fallback typing into search box, `clickProductTabIfNeeded()` silent return on tab not found → user `--product photos` silently degraded to Top results. Fixed: throw `CommandExecutionError` when requested `--product` tab cannot be selected + invalid `--from` / `--limit` upfront pre-nav reject + double-direction tests. 4. **P2 limit silent normalize** (aux): `const limit = kwargs.limit || 20` → `--limit 0` silent → 20; negative/non-integer pre-IO unchecked. Fixed: `Number(kwargs.limit ?? 20)` + require positive integer before `page.goto` + regression covers `0/negative/fractional/NaN` + `page.goto` zero-call. ## Cultural sediment (Round 21 audit checklist 7 rules / 6 dimensions) This PR **immediately validated 4 of 7 rules** in review pipeline: - (b) silent-clamp class — P1 fallback silent tab miss (silent semantic-downgrade) + P2 `|| 20` silent normalize - (e) ID exact-not-substring — P5 host invariant (was only path-exact, not host-exact) - (f) grandfathered-not-exempt — P5 helper-refactor boundary lost host invariant + P2 new adapter inherited grandfathered `'unknown'` sentinel - (g) fallback-must-have-success-criterion — P1 fallback path missing post-condition assertion 7 rules / 6 dimensions: - (a) cross-grep sibling URL pattern — structural - (b) silent-clamp class — failure mode (input) - (c) broad querySelector → article-scoping — scope - (d) missing-validation early reject — boundary - (e) ID exact-not-substring — identity - (f) grandfathered-not-exempt (corollary: applies to new file + new helper-refactor boundary; not original-file line-edit) — time-axis - (g) fallback-must-have-success-criterion (sub-rule g': fallback unit test must include post-condition assertion, not just "doesn't throw") — failure mode (output) **Cross-PR validation 4-chain on meta-anchor "Structural exactness for identity matching"**: - #1391 URL layer (`isFacebookAuthRedirectPath`: top-level anchor + `\.php` + `(/|$)` segment edge) - #1392 URL parser layer (`parseGrokSessionId`: bare UUID exact / URL host-exact-or-subdomain + path-exact) - #1400 DOM layer (article-scoping: status-id `/\/status\/${id}(?:\/|$)/` regex / segment-array exact) - #1406 P5 helper-refactor boundary (full URL invariant in shared helper: host+path re-anchored after extraction) - Common invariant: boundary-lock structural shape; **fuzzy match is silent-failure 温床**; lesson lifecycle = surface-shift not add-and-forget. **Audit framework self-discipline**: each rule must have grep-able detection signal, otherwise rule degenerates to mantra. Framework is "7 rules + sub-instance pattern in new surface", not frozen 7 rules. **Round 17 race-mitigation 第 9 连续 race-free execution**: standard alternation cadence (#1400 A 组 → #1406 B 组), lead final + aux final + `@pr-monitor squash?` trigger, pr-monitor proactive ack + serial squash, lead silent on closeout. ## Validation gates (final head `df4dcd76`) Local: Twitter adapter tests `25 files / 216 tests`, focused P1/P2/P3/P5 tests `99/99`, `node --check` touched runtime, `npx tsc --noEmit`, `npm run build`, manifest 801 entries, typed-error-lint `189/189`, silent-column-drop `103/103`, doc-coverage `140/140`, docs:build clean, listing-id advisory `13` unchanged (wikipedia/trending residual non-Twitter), `git diff --check` clean. GitHub: build×3 (ubuntu/macos/windows) SUCCESS, unit-test shards SUCCESS, bun-test SUCCESS, adapter-test SUCCESS, audit SUCCESS, doc-coverage SUCCESS, docs-build SUCCESS, smoke-test skipped, PR `CLEAN/MERGEABLE`. Reviewers: - Lead: @codex-mini1 (3 fix rounds, all caught proactively + amend P3 help consistency) - Aux: @First-principles-1 (better-solution triangulation on P2 queryId 三层 fallback + P5 invariant + P3 N=0 reference no-op + caught P2 limit silent normalize) - Author: @opencli-user (5-feature scope + 7-rule sediment co-author + corollary contributor)
1 parent 34f793f commit 4475d4e

47 files changed

Lines changed: 2764 additions & 466 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cli-manifest.json

Lines changed: 197 additions & 27 deletions
Large diffs are not rendered by default.

clis/twitter/article.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
22
import { cli, Strategy } from '@jackwener/opencli/registry';
33
import { resolveTwitterQueryId } from './shared.js';
4+
import { TWITTER_BEARER_TOKEN } from './utils.js';
45
const TWEET_RESULT_BY_REST_ID_QUERY_ID = '7xflPyRiUxGVbJd4uWmbfg';
56
cli({
67
site: 'twitter',
@@ -57,7 +58,7 @@ cli({
5758
const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
5859
if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
5960
60-
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
61+
const bearer = ${JSON.stringify(TWITTER_BEARER_TOKEN)};
6162
const headers = {
6263
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
6364
'X-Csrf-Token': ct0,

clis/twitter/bookmark-folder.js

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { cli, Strategy } from '@jackwener/opencli/registry';
2+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3+
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
4+
import { resolveTwitterQueryId } from './shared.js';
5+
6+
// Companion to bookmark-folders.js: reads tweets inside a single folder.
7+
// X exposes folder contents through a separate timeline operation
8+
// (BookmarkFolderTimeline). The shape is essentially the same as the
9+
// global bookmarks timeline (bookmark_timeline_v2.timeline.instructions),
10+
// just scoped to one folder via the bookmark_collection_id variable.
11+
const OPERATION_NAME = 'BookmarkFolderTimeline';
12+
const FALLBACK_QUERY_ID = '13H7EUATwethsj_jZ6QQAQ';
13+
const FOLDER_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
14+
15+
const FEATURES = {
16+
rweb_video_screen_enabled: false,
17+
profile_label_improvements_pcf_label_in_post_enabled: true,
18+
responsive_web_profile_redirect_enabled: false,
19+
rweb_tipjar_consumption_enabled: false,
20+
verified_phone_label_enabled: false,
21+
creator_subscriptions_tweet_preview_api_enabled: true,
22+
responsive_web_graphql_timeline_navigation_enabled: true,
23+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
24+
premium_content_api_read_enabled: false,
25+
communities_web_enable_tweet_community_results_fetch: true,
26+
c9s_tweet_anatomy_moderator_badge_enabled: true,
27+
articles_preview_enabled: true,
28+
responsive_web_edit_tweet_api_enabled: true,
29+
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
30+
view_counts_everywhere_api_enabled: true,
31+
longform_notetweets_consumption_enabled: true,
32+
responsive_web_twitter_article_tweet_consumption_enabled: true,
33+
tweet_awards_web_tipping_enabled: false,
34+
content_disclosure_indicator_enabled: true,
35+
content_disclosure_ai_generated_indicator_enabled: true,
36+
freedom_of_speech_not_reach_fetch_enabled: true,
37+
standardized_nudges_misinfo: true,
38+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
39+
longform_notetweets_rich_text_read_enabled: true,
40+
longform_notetweets_inline_media_enabled: false,
41+
responsive_web_enhance_cards_enabled: false,
42+
};
43+
44+
function buildFolderTimelineUrl(queryId, folderId, count, cursor) {
45+
const vars = {
46+
bookmark_collection_id: String(folderId),
47+
count,
48+
includePromotedContent: false,
49+
};
50+
if (cursor) vars.cursor = cursor;
51+
return `/i/api/graphql/${queryId}/${OPERATION_NAME}`
52+
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
53+
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
54+
}
55+
56+
function extractFolderTweet(result, seen) {
57+
if (!result) return null;
58+
const tw = result.tweet || result;
59+
const legacy = tw.legacy || {};
60+
if (!tw.rest_id || seen.has(tw.rest_id)) return null;
61+
seen.add(tw.rest_id);
62+
const user = tw.core?.user_results?.result;
63+
const screenName = user?.legacy?.screen_name || user?.core?.screen_name || '';
64+
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
65+
return {
66+
id: tw.rest_id,
67+
author: screenName,
68+
text: noteText || legacy.full_text || '',
69+
likes: legacy.favorite_count || 0,
70+
retweets: legacy.retweet_count || 0,
71+
bookmarks: legacy.bookmark_count || 0,
72+
created_at: legacy.created_at || '',
73+
url: screenName ? `https://x.com/${screenName}/status/${tw.rest_id}` : `https://x.com/i/status/${tw.rest_id}`,
74+
};
75+
}
76+
77+
/**
78+
* Parse the bookmark-folder timeline payload. Same instruction-walking
79+
* pattern as the global bookmarks timeline; X re-uses the
80+
* `bookmark_timeline_v2` envelope for folder-scoped queries.
81+
*
82+
* Exported via __test__.
83+
*/
84+
export function parseBookmarkFolderTimeline(data, seen) {
85+
const tweets = [];
86+
let nextCursor = null;
87+
const instructions = data?.data?.bookmark_collection_timeline?.timeline?.instructions
88+
|| data?.data?.bookmark_timeline_v2?.timeline?.instructions
89+
|| data?.data?.bookmark_timeline?.timeline?.instructions
90+
|| [];
91+
for (const inst of instructions) {
92+
for (const entry of inst.entries || []) {
93+
const content = entry.content;
94+
if (content?.entryType === 'TimelineTimelineCursor' || content?.__typename === 'TimelineTimelineCursor') {
95+
if (content.cursorType === 'Bottom' || content.cursorType === 'ShowMore')
96+
nextCursor = content.value;
97+
continue;
98+
}
99+
if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
100+
nextCursor = content?.value || content?.itemContent?.value || nextCursor;
101+
continue;
102+
}
103+
const direct = extractFolderTweet(content?.itemContent?.tweet_results?.result, seen);
104+
if (direct) {
105+
tweets.push(direct);
106+
continue;
107+
}
108+
for (const item of content?.items || []) {
109+
const nested = extractFolderTweet(item.item?.itemContent?.tweet_results?.result, seen);
110+
if (nested) tweets.push(nested);
111+
}
112+
}
113+
}
114+
return { tweets, nextCursor };
115+
}
116+
117+
cli({
118+
site: 'twitter',
119+
name: 'bookmark-folder',
120+
access: 'read',
121+
description: 'Read the tweets inside a single Twitter/X bookmark folder. Get the folder id from `opencli twitter bookmark-folders`.',
122+
domain: 'x.com',
123+
strategy: Strategy.COOKIE,
124+
browser: true,
125+
args: [
126+
{ name: 'folder-id', positional: true, type: 'string', required: true, help: 'Folder id from `opencli twitter bookmark-folders`.' },
127+
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' },
128+
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the folder by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API\'s native (saved-time) ordering.' },
129+
],
130+
columns: ['id', 'author', 'text', 'likes', 'retweets', 'bookmarks', 'created_at', 'url'],
131+
func: async (page, kwargs) => {
132+
const folderId = String(kwargs['folder-id'] || '').trim();
133+
if (!folderId || !FOLDER_ID_PATTERN.test(folderId)) {
134+
throw new ArgumentError(
135+
`Invalid folder-id: ${JSON.stringify(kwargs['folder-id'])}. Expected a safe folder ID from \`opencli twitter bookmark-folders\`.`,
136+
);
137+
}
138+
const limit = Number(kwargs.limit ?? 20);
139+
if (!Number.isInteger(limit) || limit < 1) {
140+
throw new ArgumentError(`Invalid --limit: ${JSON.stringify(kwargs.limit)}. Expected a positive integer.`);
141+
}
142+
143+
await page.goto('https://x.com');
144+
await page.wait(3);
145+
const ct0 = await page.evaluate(`() => {
146+
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
147+
}`);
148+
if (!ct0)
149+
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
150+
151+
const queryId = await resolveTwitterQueryId(page, OPERATION_NAME, FALLBACK_QUERY_ID);
152+
153+
const headers = JSON.stringify({
154+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
155+
'X-Csrf-Token': ct0,
156+
'X-Twitter-Auth-Type': 'OAuth2Session',
157+
'X-Twitter-Active-User': 'yes',
158+
});
159+
160+
const allTweets = [];
161+
const seen = new Set();
162+
let cursor = null;
163+
for (let i = 0; i < 5 && allTweets.length < limit; i++) {
164+
const fetchCount = Math.min(100, limit - allTweets.length + 10);
165+
const apiUrl = buildFolderTimelineUrl(queryId, folderId, fetchCount, cursor);
166+
const data = await page.evaluate(`async () => {
167+
const r = await fetch(${JSON.stringify(apiUrl)}, { headers: ${headers}, credentials: 'include' });
168+
return r.ok ? await r.json() : { error: r.status };
169+
}`);
170+
if (data?.error) {
171+
if (allTweets.length === 0)
172+
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch folder ${folderId}. queryId may have expired, or the folder may not exist.`);
173+
break;
174+
}
175+
const { tweets, nextCursor } = parseBookmarkFolderTimeline(data, seen);
176+
allTweets.push(...tweets);
177+
if (!nextCursor || nextCursor === cursor) break;
178+
cursor = nextCursor;
179+
}
180+
const trimmed = allTweets.slice(0, limit);
181+
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
182+
},
183+
});
184+
185+
export const __test__ = {
186+
parseBookmarkFolderTimeline,
187+
buildFolderTimelineUrl,
188+
FOLDER_ID_PATTERN,
189+
};

0 commit comments

Comments
 (0)