Skip to content

feat(twitter): P1+P2+P3+P4+P5 — search filters, bookmark folders, engagement scoring, sibling dedupe + help docs#1406

Merged
jackwener merged 7 commits intomainfrom
twitter-feature-expansion
May 7, 2026
Merged

feat(twitter): P1+P2+P3+P4+P5 — search filters, bookmark folders, engagement scoring, sibling dedupe + help docs#1406
jackwener merged 7 commits intomainfrom
twitter-feature-expansion

Conversation

@jackwener
Copy link
Copy Markdown
Owner

Follow-up to #1400 (P0 write-action symmetry). Combines P1+P2+P3+P4+P5 plus a help-doc pass into one PR per WAWQAQ direction (msg=02d700c6 / msg=882c5ad9).

Scope (6 commits, all on this branch)

# Commit What
P5 13b8b7a Sibling hardening — share article-scoped DOM matching across 9 write commands via buildTwitterArticleScopeSource
P4 13036ba Centralize TWITTER_BEARER_TOKEN + composer image helpers in clis/twitter/utils.js (12 GraphQL adapters dedup'd, reply hardened, quote gains --image)
docs 2009994 Help-doc precision: document positional-omitted defaults for followers/following/profile/likes, expand download / bookmarks / notifications / timeline / lists descriptions
P1 5515797 twitter search adds --from / --has / --exclude / --product filter flags mapped onto X's search operators (from: / filter: / -filter: / f= URL param)
P3 7a138fc --top-by-engagement N on all 7 tweet-shaped read commands. Weighted formula likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5, single-source helper in utils.js, no-op when N=0
P2 e543076 New twitter bookmark-folders (list folders) + twitter bookmark-folder <id> (read folder contents). Premium-only X feature, GraphQL plumbing with dynamic queryId resolution

Why this layout

WAWQAQ asked to compare with public-clis/twitter-cli (msg=c497cd99). Five gaps surfaced:

  • P0 (write-action symmetry: unlike / retweet / unretweet / quote) — already shipped in feat(twitter): add unlike + retweet + unretweet + quote (write-action symmetry P0) #1400.
  • P1 (search advanced filters)
  • P2 (bookmark folders, Premium feature missing entirely)
  • P3 (engagement-weighted ranking — agents skimming a noisy timeline want top-N by signal, not chronology)
  • P4 (utils.js dedupe — 12 adapters had the same bearer token literal, 2 had identical image helpers)

P5 (sibling hardening) was also packaged here per the same direction.

Tests / audits / manifest

Before After
Twitter test files 16 25
Twitter tests 96 201
silent-column-drop 103 / 103 baseline 103 / 103 unchanged
typed-error-lint 188 190 (+2 from P2 sibling-pattern parity, baseline updated)
Manifest 798 801 (+3: search +0, bookmark-folders +1, bookmark-folder +1, quote --image columns unchanged)

Behavioural compatibility

  • Every new flag (--top-by-engagement, --from, --has, --exclude, --product) defaults to off / no-op so existing callers see no change.
  • --filter top|live (legacy search) is preserved; --product wins when both set, documented in help.
  • Help-doc updates only reword description / help strings — no behaviour change, no schema change.
  • P3 helper applyTopByEngagement(rows, 0) returns the same array reference, so 157 pre-existing twitter tests still pass identical assertions without any churn.

Notes for the reviewer

  • P2 inherits two pre-existing patterns from 7+ sibling adapters (silent-clamp Math.min(100, limit-…+10) over-fetch and silent-sentinel 'unknown' for missing screen_name). Already grandfathered in baseline; refactoring those would diverge from sibling parity. Baseline has 2 new entries for bookmark-folder.js matching the sibling pattern verbatim.
  • The --update-baseline line-drift on 5 other entries is automatic refresh — P3/P4 added one import line at the top of those files which shifted line numbers by 1, and search.js drifted further from P1 helper code at the top.
  • All four __test__ exports follow the existing twitter convention (no __test__ index file, each adapter exports its own helper namespace).
  • Quote --image is parity-only with reply/post/dm --image; --image-url for quote was deferred per "no over-engineering" — single-image quote was the highest-value gap, batch / video can come later if asked.

jackwener added 6 commits May 8, 2026 02:13
…write actions

Shared helper `buildTwitterArticleScopeSource(tweetId)` emits the canonical
exact-match scoping bindings (tweetId, __twTweetPathRe, __twGetStatusIdFromHref,
__twHasLinkToTarget, findTargetArticle) used by every write-action IIFE.

Why
---
PR #1400 (P0) review caught two silent failure classes that were previously
hand-rolled in each adapter:
  1. Substring match on tweet id: `pathname.includes('/status/' + tweetId)`
     would silently match `/status/1234567` when the requested id was `123`.
  2. Loose article match: bare `[data-testid="like"]` on conversation pages
     silently grabs the first article (parent tweet), liking the wrong tweet.

Five sibling adapters (like, bookmark, unbookmark, hide-reply, delete) had
inherited these patterns from delete.js. Four newly-merged P0 adapters
(unlike, retweet, unretweet, quote) inlined a fresh copy of the corrected
helper. Centralising the helper eliminates drift risk and makes the
exact-match guarantee a single review surface.

What changed
------------
- shared.js: export `buildTwitterArticleScopeSource(tweetId)` returning a
  string fragment for embedding inside `page.evaluate(...)` IIFEs.
- shared.test.js: add 7 JSDOM-based tests proving the regex anchors reject
  substring (`123` vs `1234567`) and path-suffix (`/photo/1`) attacks, and
  that `__twHasLinkToTarget` works for the quote-card use case (no <article>
  ancestor).
- like.js, bookmark.js, unbookmark.js, hide-reply.js, delete.js, unlike.js,
  retweet.js, unretweet.js, quote.js: refactored to use the shared helper.
- delete.js: also drops the local `extractTweetId` (which accepted any host)
  in favour of `parseTweetUrl` from shared.js (typed ArgumentError).
- like.test.js, bookmark.test.js, unbookmark.test.js, hide-reply.test.js:
  new test files (these adapters had no tests before).
- delete.test.js, unlike.test.js, retweet.test.js, unretweet.test.js,
  quote.test.js: updated assertions to verify the helper is embedded.

Test
----
All 124 twitter tests pass (was 92 before this change, +32 from the four
new test files and shared.js JSDOM coverage).
typed-error-lint: 189 (unchanged from baseline).
silent-column-drop: 103 (unchanged from baseline).
…s.js, add quote --image

P4 of WAWQAQ's twitter-cli enrichment series — surgical dedupe of code that
sibling adapters were already copy-pasting verbatim, plus a single feature add
(quote-tweet image attach).

Why scope is narrow:
The composer-text-insertion / submit-and-verify flows differ between post,
reply, and quote in real ways (different toast patterns, post-submit
verifications, quote has the additional quoted-card-rendered guard). Per
WAWQAQ's "no over-engineering" feedback, only true verbatim duplication is
extracted. FEATURES dicts differ per GraphQL endpoint and stay per-file.

What moves into clis/twitter/utils.js
- TWITTER_BEARER_TOKEN — was duplicated across 12 GraphQL adapters (article,
  bookmarks, following, likes, list-add, list-remove, list-tweets, lists,
  profile, thread, timeline, tweets). All 12 now import the single source.
- COMPOSER_FILE_INPUT_SELECTOR / SUPPORTED_IMAGE_EXTENSIONS / MAX_IMAGE_SIZE_BYTES
  constants used by the /compose/post route.
- resolveImagePath / resolveImageExtension — already-validated path resolution
  with typed ArgumentError on bad input.
- downloadRemoteImage — now returns { absPath, cleanupDir } so the caller can
  rmSync the per-call tmp dir in finally. (Was an opaque path before; reply.js
  cleanup path is the same, just explicit.)
- attachComposerImage — extracted from reply.js attachReplyImage. Native CDP
  setFileInput first; falls back to base64 DataTransfer shim; polls preview
  thumbnail to confirm upload actually rendered (without this, a 200 from
  setFileInput could mask silent-no-attachment).

reply.js hardening (security: replaces loose substring match)
- buildReplyComposerUrl now uses parseTweetUrl(rawUrl).id from shared.js
  instead of a local /\\/status\\/(\\d+)/ that would (a) match substrings like
  /status/123 in a longer id and (b) accept any host.
- The combined --image + --image-url throw is now a typed CommandExecutionError
  instead of a generic Error.

quote.js feature add
- New --image and --image-url flags (matches reply / post symmetry).
- Same upfront validation: parseTweetUrl runs before any browser interaction;
  --image and --image-url are mutually exclusive (CommandExecutionError).
- Reuses the same attachComposerImage helper; cleanup tmp dir in finally.

Tests
- reply.test.js: ports image-helper tests to import from utils.js __test__,
  drops the removed extractTweetId test, replaces it with parseTweetUrl-driven
  buildReplyComposerUrl tests that prove the security tightening.
- quote.test.js: adds --image / --image-url upload coverage matching reply's
  shape (mocks setFileInput, fetch, asserts goto / wait / row shape).
- All 22 twitter test files / 128 tests pass.

Audits
- typed-error-lint: 189 → 188 (resolved one silent-sentinel: reply.js no
  longer has the throw new Error('Unsupported remote image format ...')
  branch, since resolveImageExtension lives in utils.js as ArgumentError).
  Baseline shrunk by one entry.
- silent-column-drop / listing-id-pairing: unchanged.

Out of scope (intentionally)
- post.js / post-thread.js do not yet share the composer helpers — they have
  multi-tweet thread orchestration and a different set of preview-confirmation
  selectors. Folding them in would be a separate, larger change.
…t help

Triggered by WAWQAQ DM (msg=3e88a6bb): "推特的有些 help 文档是不对的,
譬如说没有说明当后面的 opencli followers [user] [options] 如果都没填的时候,
获取的是什么?"

The runtime fallback for omitted positionals was correct in code, but the
help text was silent about it. Now both the description (visible in
top-level `--help` output) and the per-arg help (visible in command help)
state what happens when the positional is omitted.

Optional-positional defaults — now documented:
- twitter/followers      <user> → defaults to logged-in user
- twitter/following      <user> → defaults to logged-in user
- twitter/likes          <username> → defaults to logged-in user (was
  already documented at arg level; description now mirrors it)
- twitter/profile        <username> → defaults to logged-in user (same)

download required-but-mutually-exclusive — clarified:
- twitter/download requires <username> OR --tweet-url (the runtime
  return-row error message already said this; description and per-arg
  help now say it upfront).

Self-data commands (no positional, but description was vague about whose
data):
- twitter/bookmarks      → "Fetch your Twitter/X bookmarks (the
  logged-in user's saved tweets, newest first)"
- twitter/notifications  → "Get your Twitter/X notifications (the
  logged-in user's likes/replies/follows feed, newest first)"
- twitter/timeline       → "Fetch the logged-in user's home timeline
  (for-you algorithmic feed by default; pass --type following for the
  chronological feed of accounts you follow)"

Also flesh out --limit help across the touched adapters so each shows
its default and unit (was empty string in the manifest):
- followers / following / likes / lists / bookmarks / notifications /
  timeline / download / + the timeline --type help.

cli-manifest.json regenerated via `npm run build-manifest` (no schema
change, only description / args[].help string updates).

Audits: typed-error-lint 188 → 188, silent-column-drop 103 → 103, all
22 twitter test files / 128 tests pass.
…er flags

Adds four filter flags that map onto X's public search-operator surface so
callers don't have to memorise the operator strings:

  --from <user>           appends `from:<user>` (leading @ is stripped)
  --has   <kind>          appends `filter:<media|images|videos|links|replies>`
  --exclude <kind>        appends `-filter:<replies|retweets|media|links>`
                          (retweets is an alias for X's `-filter:nativeretweets`)
  --product <tab>         picks the X result tab (top|live|photos|videos)
                          via the f= URL param. photos/videos use the singular
                          form Twitter expects (image/video).

Backwards compatible: the legacy `--filter top|live` flag is kept and still
works unchanged. When both `--product` and `--filter` are set, --product wins.

ArgumentError is thrown when the composed query is empty (i.e. <query> blank
and no filters supplied).

Implementation:
- buildSearchQuery() and resolveSearchFParam() are pure helpers exported via
  __test__ for unit coverage. 21 new helper tests + 3 new e2e tests cover:
  query composition order, @ stripping, retweets→nativeretweets aliasing,
  --product winning over --filter, and ArgumentError on empty query.
- After the search-input fallback path lands on /search the URL has no f=
  param, so clickProductTabIfNeeded() clicks the matching tab (Latest /
  Photos / Videos with i18n labels) so the SearchTimeline call reads the
  right product. No-op for top.

35 search tests pass (was 6); full twitter suite 157/157 green.
silent-column-drop and typed-error-lint baselines unchanged.
…ommands

Adds a `--top-by-engagement N` flag to twitter/{search, timeline, likes,
bookmarks, list-tweets, tweets, thread}. When N>0 the rows are re-ranked
by weighted engagement and trimmed to the top N; when 0 (the default) the
adapter behaves exactly as before so existing callers see no change.

Formula (single source of truth in clis/twitter/utils.js):

  score = likes×1
        + retweets×3
        + replies×2
        + bookmarks×5
        + log10(views + 1) × 0.5

Rationale:
- Active engagement (bookmarks → retweets → replies → likes) is weighted
  above passive (views). Bookmarks are the strongest signal because they
  cost real intent — only 1–2% of viewers bookmark.
- Views are log-dampened because viral tweets routinely have 4 orders of
  magnitude more views than likes/retweets, which would otherwise drown
  out the active signals entirely.
- log10(views + 1) so views=0 maps to 0 (not -Infinity).
- Missing fields coerce to 0 — search returns no replies/bookmarks,
  bookmarks returns no views, etc. The formula stays well-defined across
  every read command's row shape without per-adapter special cases.

Implementation:
- Two pure helpers in utils.js — computeEngagementScore(row) and
  applyTopByEngagement(rows, topN). Both exported via __test__.
- applyTopByEngagement() is a no-op when topN <= 0 / non-numeric, so
  every existing test that doesn't pass the flag still gets the original
  array reference (and ordering) back unchanged.
- Sort is stable for ties: rows with equal scores keep their original
  order (Array.prototype.sort is guaranteed stable in V8 since 2018).

24 new helper tests cover: per-signal weights, log-dampening, string
coercion, NaN guard, negative-value clamp, real search/bookmarks row
shapes, no-op semantics for default flag, stable tie-break, and the
mixed-signal case where a bookmark-heavy row beats a like-heavy row.

Full twitter suite: 181/181 (was 157/157, +24 utils tests).
silent-column-drop and typed-error-lint baselines unchanged.
X has supported user-created bookmark folders (Premium feature) since
2023. The web UI exposes them under the Bookmarks page; we previously
had no way to read them from the CLI. Adds two commands that match the
shape of lists / list-tweets:

  bookmark-folders             List your bookmark folders. Returns
                               id, name, item count, created_at.
  bookmark-folder <folder-id>  List the tweets inside one folder.
                               Same row shape as bookmarks (id, author,
                               text, likes, retweets, bookmarks,
                               created_at, url) plus --limit and the
                               new --top-by-engagement N flag.

GraphQL plumbing:
- bookmarkFoldersSlice op for the folder list (FALLBACK_QUERY_ID
  i78YDd0Tza-dWKw5H2Y7WA).
- BookmarkFolderTimeline op for folder contents (FALLBACK_QUERY_ID
  13H7EUATwethsj_jZ6QQAQ), variables.bookmark_collection_id scopes
  the existing bookmark_timeline_v2 envelope to one folder.
- Both adapters resolve queryId dynamically: try fa0311/twitter-openapi
  placeholder.json first → scrape client-web bundles second → fall back
  to the pinned constants. Same pattern as bookmarks.js / lists.js.

Parser robustness:
- parseBookmarkFolders walks 3 envelope shapes (viewer.bookmark_collections_slice,
  viewer_v2.user_results.result.bookmark_collections_slice, flat
  bookmark_collections_slice) and 4 item shapes (bookmarkCollection,
  content.bookmarkCollection, content.itemContent.bookmark_collection,
  flat). Tested.
- parseBookmarkFolderTimeline walks 3 envelope shapes
  (bookmark_collection_timeline, bookmark_timeline_v2, bookmark_timeline)
  and the standard tweet result shape with note_tweet long-form fallback.
- bookmark-folder validates folder-id with /^\d+$/ via ArgumentError before
  any browser interaction (typed fail-fast).

20 new helper tests cover envelope variants, item dedup, missing fields,
ArgumentError on non-numeric folder-id, AuthRequiredError on missing
ct0 cookie, and URL builder shape (variables, cursor, includePromotedContent).

Test counts: full twitter suite 201/201 (was 181, +20).
Manifest 799 → 801 (+2 commands).

Baseline note: bookmark-folder.js inherits two pre-existing patterns
from sibling adapters (silent-clamp on the over-fetch Math.min and
silent-sentinel 'unknown' for missing screen_name). Both patterns are
already in the baseline for 7+ sibling adapters (bookmarks/likes/
list-tweets/search/timeline/tweets/thread). This commit adds 2 entries
to typed-error-lint-baseline.json for parity. The line-number drift on
5 other entries is automatic refresh from --update-baseline picking up
imports added in P3/P4 commits earlier on this branch.
@jackwener jackwener force-pushed the twitter-feature-expansion branch 2 times, most recently from 7f93779 to 2a29ecc Compare May 7, 2026 18:26
@jackwener jackwener force-pushed the twitter-feature-expansion branch from 2a29ecc to df4dcd7 Compare May 7, 2026 18:30
@jackwener jackwener merged commit 4475d4e into main May 7, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant