frigatebird 0.2.0 → 0.3.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/CHANGELOG.md +18 -0
- package/README.md +154 -119
- package/TASKS.md +1 -1
- package/dist/browser/auth-store.js +105 -10
- package/dist/browser/session-manager.js +89 -9
- package/dist/cli/program.js +5 -0
- package/dist/cli.js +2 -0
- package/dist/client/playwright-client.js +357 -47
- package/dist/commands/handlers.js +27 -0
- package/dist/lib/invocation.js +1 -0
- package/dist/lib/options.js +1 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 - 2026-02-09
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Long-form article publishing command: `article <title> [body] [--body-file <path>]`.
|
|
7
|
+
- Dedicated live mutation CI workflow: `.github/workflows/live-e2e.yml`.
|
|
8
|
+
- Browser session identity hardening with multi-selector `whoami` fallback coverage.
|
|
9
|
+
- Additional Playwright client method tests for retweet and mutation resilience.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- Mutation `retweet` flow now uses bounded resilient click handling and explicit completion checks.
|
|
13
|
+
- Read-only fixture e2e was optimized to run significantly faster while preserving command coverage.
|
|
14
|
+
- CI and release verification workflows now include fixture e2e smoke runs.
|
|
15
|
+
- Live mutation e2e now requires `FRIGATEBIRD_LIVE_LIST_NAME` and always validates list `add/remove/batch` paths.
|
|
16
|
+
|
|
17
|
+
### Tests
|
|
18
|
+
- Expanded unit coverage for session identity parsing and retweet mutation branches.
|
|
19
|
+
- Verified end-to-end live mutation flow (tweet/reply/like/retweet/follow/list mutations) with Safari cookie source.
|
|
20
|
+
|
|
3
21
|
## 0.2.0 - 2026-02-08
|
|
4
22
|
|
|
5
23
|
### Added
|
package/README.md
CHANGED
|
@@ -1,28 +1,21 @@
|
|
|
1
|
-
# frigatebird
|
|
1
|
+
# frigatebird 🐦 — resilient X CLI for posting, articles, replies, reading, and list automation
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`frigatebird` is a Playwright-first X CLI that preserves the familiar `bird` command-line experience while running on browser automation instead of private GraphQL internals.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Why This Exists
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
`bird` set a high bar for fast, scriptable X workflows. After deprecation and de-open-sourcing, teams still needed the same CLI ergonomics without depending on internal API behavior.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
- Keep
|
|
13
|
-
-
|
|
14
|
-
-
|
|
11
|
+
`frigatebird` is the continuity path:
|
|
12
|
+
- Keep the `bird`-style command surface.
|
|
13
|
+
- Keep API-key-free operation via browser session cookies.
|
|
14
|
+
- Keep practical day-to-day workflows for posting, reading, follows, lists, and timeline operations.
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Disclaimer
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|---|---|---|
|
|
20
|
-
| Primary engine | X internal GraphQL + query IDs | Playwright web automation |
|
|
21
|
-
| API keys | Not required | Not required |
|
|
22
|
-
| Auth | Browser cookies / env tokens | Browser cookies / env tokens |
|
|
23
|
-
| `query-ids` | Active GraphQL cache/refresh behavior | Compatibility command in Playwright mode |
|
|
24
|
-
| List manager commands | External project (`x-list-manager`) | Built in (`refresh`, `add`, `remove`, `batch`) |
|
|
25
|
-
| Selector/query fragility | Query ID churn | DOM selector churn |
|
|
18
|
+
Frigatebird automates X’s web UI and relies on selectors/flows that X can change at any time. Expect occasional breakage when X ships UI changes.
|
|
26
19
|
|
|
27
20
|
## Install
|
|
28
21
|
|
|
@@ -31,63 +24,91 @@ npm install
|
|
|
31
24
|
npx playwright install chromium
|
|
32
25
|
```
|
|
33
26
|
|
|
34
|
-
|
|
27
|
+
When published to npm:
|
|
35
28
|
|
|
36
29
|
```bash
|
|
37
|
-
|
|
38
|
-
|
|
30
|
+
npm install -g frigatebird
|
|
31
|
+
```
|
|
39
32
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
## Quickstart
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Show authenticated account
|
|
37
|
+
frigatebird whoami
|
|
38
|
+
|
|
39
|
+
# Read a tweet (URL or ID)
|
|
40
|
+
frigatebird read https://x.com/user/status/1234567890123456789
|
|
41
|
+
frigatebird 1234567890123456789 --json
|
|
42
|
+
|
|
43
|
+
# Post and reply
|
|
44
|
+
frigatebird tweet "hello from frigatebird"
|
|
45
|
+
frigatebird reply 1234567890123456789 "thanks"
|
|
46
|
+
|
|
47
|
+
# Publish a long-form article
|
|
48
|
+
frigatebird article "Launch notes" "Today we shipped..."
|
|
49
|
+
frigatebird article "Draft from file" --body-file ./article.md
|
|
50
|
+
|
|
51
|
+
# Search and mentions
|
|
52
|
+
frigatebird search "from:openai" -n 5
|
|
53
|
+
frigatebird mentions -n 5
|
|
54
|
+
|
|
55
|
+
# Lists and list timeline
|
|
56
|
+
frigatebird lists --json
|
|
57
|
+
frigatebird list-timeline 1234567890 -n 20
|
|
58
|
+
|
|
59
|
+
# Follow graph
|
|
60
|
+
frigatebird following -n 20 --json
|
|
61
|
+
frigatebird followers -n 20 --json
|
|
62
|
+
|
|
63
|
+
# List membership automation
|
|
64
|
+
frigatebird add "AI News" @openai @anthropicai
|
|
65
|
+
frigatebird remove @openai "AI News"
|
|
66
|
+
frigatebird batch accounts.json
|
|
43
67
|
```
|
|
44
68
|
|
|
45
69
|
## Commands
|
|
46
70
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
- `
|
|
50
|
-
- `
|
|
51
|
-
- `
|
|
52
|
-
- `
|
|
53
|
-
- `replies <tweet-id-or-url> [
|
|
54
|
-
- `thread <tweet-id-or-url> [
|
|
55
|
-
- `search <query> [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]`
|
|
56
|
-
- `mentions [
|
|
57
|
-
- `user-tweets <@handle> [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]`
|
|
58
|
-
- `home [-n count] [--following] [--all] [--max-pages n] [--delay ms] [--json] [--json-full]`
|
|
59
|
-
- `bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--cursor str] [--expand-root-only] [--author-chain] [--author-only] [--full-chain-only] [--include-ancestor-branches] [--include-parent] [--thread-meta] [--sort-chronological] [--delay ms] [--json] [--json-full]`
|
|
60
|
-
- `unbookmark <tweet-id-or-url...> [--json]`
|
|
61
|
-
- `like <tweet-id-or-url>`
|
|
62
|
-
- `retweet <tweet-id-or-url>`
|
|
63
|
-
- `likes [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]`
|
|
64
|
-
- `follow <username-or-id>`
|
|
65
|
-
- `unfollow <username-or-id>`
|
|
66
|
-
- `following [--user userId] [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]`
|
|
67
|
-
- `followers [--user userId] [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]`
|
|
68
|
-
- `lists [--member-of] [-n count] [--json] [--json-full]`
|
|
69
|
-
- `list`
|
|
70
|
-
- `list-timeline <list-id-or-url> [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]`
|
|
71
|
-
- `news [-n count] [--ai-only] [--with-tweets] [--tweets-per-item n] [--for-you] [--news-only] [--sports] [--entertainment] [--trending-only] [--json] [--json-full]`
|
|
72
|
-
- `trending`
|
|
73
|
-
- `about <@handle> [--json]`
|
|
74
|
-
- `query-ids [--fresh] [--json]`
|
|
75
|
-
- `whoami [--json]`
|
|
76
|
-
- `check`
|
|
77
|
-
- `
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
- `
|
|
82
|
-
- `add <listName> <handles...> [--no-headless] [--json]`
|
|
83
|
-
- `remove <handle> <listName> [--no-headless] [--json]`
|
|
84
|
-
- `batch <file.json> [--no-headless] [--json]`
|
|
71
|
+
- `frigatebird tweet "<text>"` — post a tweet.
|
|
72
|
+
- `frigatebird post "<text>"` — alias for `tweet`.
|
|
73
|
+
- `frigatebird article "<title>" [body] [--body-file path]` — publish a long-form article.
|
|
74
|
+
- `frigatebird reply <tweet-id-or-url> "<text>"` — reply to a tweet.
|
|
75
|
+
- `frigatebird read <tweet-id-or-url> [--json] [--json-full]` — read one tweet.
|
|
76
|
+
- `frigatebird <tweet-id-or-url> [--json]` — shorthand for `read`.
|
|
77
|
+
- `frigatebird replies <tweet-id-or-url> [--all] [--max-pages n] [--cursor str] [--delay ms] [-n count] [--json] [--json-full]` — list replies.
|
|
78
|
+
- `frigatebird thread <tweet-id-or-url> [--all] [--max-pages n] [--cursor str] [--delay ms] [-n count] [--json] [--json-full]` — show thread/conversation tweets.
|
|
79
|
+
- `frigatebird search "<query>" [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]` — search tweets.
|
|
80
|
+
- `frigatebird mentions [--user @handle] [-n count] [--json] [--json-full]` — mention timeline/search.
|
|
81
|
+
- `frigatebird user-tweets <@handle> [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]` — profile tweets.
|
|
82
|
+
- `frigatebird home [-n count] [--following] [--all] [--max-pages n] [--delay ms] [--json] [--json-full]` — home timeline.
|
|
83
|
+
- `frigatebird bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--cursor str] [--expand-root-only] [--author-chain] [--author-only] [--full-chain-only] [--include-ancestor-branches] [--include-parent] [--thread-meta] [--sort-chronological] [--delay ms] [--json] [--json-full]` — bookmarks + optional thread expansion.
|
|
84
|
+
- `frigatebird unbookmark <tweet-id-or-url...> [--json]` — remove bookmark(s).
|
|
85
|
+
- `frigatebird like <tweet-id-or-url>` — like a tweet.
|
|
86
|
+
- `frigatebird retweet <tweet-id-or-url>` — repost a tweet.
|
|
87
|
+
- `frigatebird likes [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]` — liked tweets.
|
|
88
|
+
- `frigatebird follow <username-or-id>` — follow user.
|
|
89
|
+
- `frigatebird unfollow <username-or-id>` — unfollow user.
|
|
90
|
+
- `frigatebird following [--user userId] [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]` — accounts a user follows.
|
|
91
|
+
- `frigatebird followers [--user userId] [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]` — accounts following a user.
|
|
92
|
+
- `frigatebird lists [--member-of] [-n count] [--json] [--json-full]` — list your lists.
|
|
93
|
+
- `frigatebird list [--member-of] [-n count] [--json] [--json-full]` — alias for `lists`.
|
|
94
|
+
- `frigatebird list-timeline <list-id-or-url> [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]` — timeline for a list.
|
|
95
|
+
- `frigatebird news [-n count] [--ai-only] [--with-tweets] [--tweets-per-item n] [--for-you] [--news-only] [--sports] [--entertainment] [--trending-only] [--json] [--json-full]` — explore/news aggregation.
|
|
96
|
+
- `frigatebird trending` — alias for `news`.
|
|
97
|
+
- `frigatebird about <@handle> [--json]` — profile origin/location metadata.
|
|
98
|
+
- `frigatebird query-ids [--fresh] [--json]` — compatibility command (Playwright mode does not require GraphQL query IDs).
|
|
99
|
+
- `frigatebird whoami [--json]` — active authenticated account.
|
|
100
|
+
- `frigatebird check` — credential/session status.
|
|
101
|
+
- `frigatebird refresh [--json]` — refresh local auth cookie cache.
|
|
102
|
+
- `frigatebird add <listName> <handles...> [--no-headless] [--json]` — add handles to list.
|
|
103
|
+
- `frigatebird remove <handle> <listName> [--no-headless] [--json]` — remove handle from list.
|
|
104
|
+
- `frigatebird batch <file.json> [--no-headless] [--json]` — batch list updates from JSON.
|
|
105
|
+
- `frigatebird help [command]` — command help.
|
|
85
106
|
|
|
86
107
|
## Global Options
|
|
87
108
|
|
|
88
109
|
- `--auth-token <token>`
|
|
89
110
|
- `--ct0 <token>`
|
|
90
|
-
- `--base-url <url>` (
|
|
111
|
+
- `--base-url <url>` (default `https://x.com`, useful for fixture/e2e)
|
|
91
112
|
- `--cookie-source <chrome|firefox|safari|edge>` (repeatable)
|
|
92
113
|
- `--chrome-profile <name>`
|
|
93
114
|
- `--chrome-profile-dir <path>`
|
|
@@ -102,85 +123,99 @@ node dist/cli.js --help
|
|
|
102
123
|
- `--no-color`
|
|
103
124
|
- `--no-headless`
|
|
104
125
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
Media constraints:
|
|
126
|
+
Media rules:
|
|
108
127
|
- up to 4 attachments
|
|
109
|
-
-
|
|
128
|
+
- one video maximum
|
|
110
129
|
- video cannot be mixed with other media
|
|
111
|
-
- supported
|
|
130
|
+
- supported: `jpg`, `jpeg`, `png`, `webp`, `gif`, `mp4`, `m4v`, `mov`
|
|
112
131
|
|
|
113
|
-
##
|
|
132
|
+
## Authentication
|
|
133
|
+
|
|
134
|
+
Frigatebird uses your existing X web session and cookie credentials. No X API key required.
|
|
135
|
+
|
|
136
|
+
Resolution order:
|
|
137
|
+
1. CLI flags (`--auth-token`, `--ct0`)
|
|
138
|
+
2. env vars (`AUTH_TOKEN`, `CT0`, fallbacks below)
|
|
139
|
+
3. browser cookie extraction via `@steipete/sweet-cookie`
|
|
140
|
+
|
|
141
|
+
When `--cookie-source` is explicitly set and differs from cached auth source metadata, Frigatebird clears cached auth and re-resolves cookies from the requested source order.
|
|
142
|
+
|
|
143
|
+
If auth fails:
|
|
144
|
+
```bash
|
|
145
|
+
frigatebird refresh
|
|
146
|
+
frigatebird check
|
|
147
|
+
frigatebird whoami
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Config (JSON5)
|
|
114
151
|
|
|
115
152
|
Precedence: **CLI flags > env vars > project config > global config**.
|
|
116
153
|
|
|
117
154
|
Config files:
|
|
118
|
-
-
|
|
119
|
-
-
|
|
120
|
-
-
|
|
121
|
-
-
|
|
155
|
+
- `~/.config/bird/config.json5`
|
|
156
|
+
- `~/.config/frigatebird/config.json5`
|
|
157
|
+
- `./.birdrc.json5`
|
|
158
|
+
- `./.frigatebirdrc.json5`
|
|
122
159
|
|
|
123
|
-
Supported
|
|
160
|
+
Supported keys:
|
|
124
161
|
- `authToken`, `ct0`, `baseUrl`
|
|
125
162
|
- `cookieSource`
|
|
126
163
|
- `chromeProfile`, `chromeProfileDir`, `firefoxProfile`
|
|
127
164
|
- `cookieTimeoutMs`, `timeoutMs`, `quoteDepth`
|
|
128
165
|
|
|
129
|
-
|
|
130
|
-
- Auth: `AUTH_TOKEN`, `CT0`, `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`
|
|
131
|
-
- Cookie source: `BIRD_COOKIE_SOURCE`, `FRIGATEBIRD_COOKIE_SOURCE`
|
|
132
|
-
- Base URL: `BIRD_BASE_URL`, `FRIGATEBIRD_BASE_URL`
|
|
133
|
-
- Profiles: `BIRD_CHROME_PROFILE`, `BIRD_CHROME_PROFILE_DIR`, `BIRD_FIREFOX_PROFILE` (plus `FRIGATEBIRD_*` variants)
|
|
134
|
-
- Timeouts/depth: `BIRD_TIMEOUT_MS`, `BIRD_COOKIE_TIMEOUT_MS`, `BIRD_QUOTE_DEPTH` (plus `FRIGATEBIRD_*` variants)
|
|
135
|
-
- Output: `NO_COLOR`, `BIRD_PLAIN`, `FRIGATEBIRD_PLAIN`
|
|
136
|
-
|
|
137
|
-
## Shorthand Invocation
|
|
166
|
+
Example:
|
|
138
167
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
168
|
+
```json5
|
|
169
|
+
{
|
|
170
|
+
cookieSource: ["chrome", "safari"],
|
|
171
|
+
chromeProfile: "Default",
|
|
172
|
+
timeoutMs: 20000,
|
|
173
|
+
quoteDepth: 1
|
|
174
|
+
}
|
|
143
175
|
```
|
|
144
176
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
-
|
|
148
|
-
-
|
|
149
|
-
- `
|
|
150
|
-
-
|
|
151
|
-
- `
|
|
152
|
-
|
|
153
|
-
## Skills Files
|
|
177
|
+
Environment vars:
|
|
178
|
+
- auth: `AUTH_TOKEN`, `CT0`, `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`
|
|
179
|
+
- cookie source: `BIRD_COOKIE_SOURCE`, `FRIGATEBIRD_COOKIE_SOURCE`
|
|
180
|
+
- base URL: `BIRD_BASE_URL`, `FRIGATEBIRD_BASE_URL`
|
|
181
|
+
- profiles: `BIRD_CHROME_PROFILE`, `BIRD_CHROME_PROFILE_DIR`, `BIRD_FIREFOX_PROFILE` and `FRIGATEBIRD_*` variants
|
|
182
|
+
- timeouts/depth: `BIRD_TIMEOUT_MS`, `BIRD_COOKIE_TIMEOUT_MS`, `BIRD_QUOTE_DEPTH` and `FRIGATEBIRD_*` variants
|
|
183
|
+
- output: `NO_COLOR`, `BIRD_PLAIN`, `FRIGATEBIRD_PLAIN`
|
|
154
184
|
|
|
155
|
-
|
|
185
|
+
## Output
|
|
156
186
|
|
|
157
|
-
- `
|
|
158
|
-
- `
|
|
159
|
-
- `
|
|
187
|
+
- `--json` gives machine-readable output for read/timeline/list commands.
|
|
188
|
+
- `--json-full` includes raw compatibility payloads where available.
|
|
189
|
+
- `--plain` disables emoji + color for stable scripts.
|
|
160
190
|
|
|
161
|
-
##
|
|
162
|
-
|
|
163
|
-
- `CHANGELOG.md` tracks release notes.
|
|
164
|
-
- `RELEASE.md` contains the release checklist and publish flow.
|
|
165
|
-
- GitHub workflows enforce CI + publish gates:
|
|
166
|
-
- `.github/workflows/ci.yml`
|
|
167
|
-
- `.github/workflows/release.yml`
|
|
168
|
-
- npm publish is triggered by GitHub Release `published` events using npm trusted publishing (OIDC, no npm token secret).
|
|
169
|
-
- `npm run release:check` runs lint + build + full tests + coverage.
|
|
170
|
-
- `npm run smoke:pack-install` validates clean install + binary entrypoint from a packed tarball.
|
|
171
|
-
|
|
172
|
-
## Test
|
|
191
|
+
## Development
|
|
173
192
|
|
|
174
193
|
```bash
|
|
175
|
-
npm
|
|
176
|
-
|
|
177
|
-
npm run
|
|
194
|
+
npm install
|
|
195
|
+
npx playwright install chromium
|
|
196
|
+
npm run build
|
|
197
|
+
npm run lint
|
|
198
|
+
npm run test:run # unit/integration
|
|
199
|
+
npm run test:coverage # unit/integration + coverage
|
|
200
|
+
npm run test:e2e # e2e only
|
|
201
|
+
npm run test:e2e:live # opt-in live mutation e2e (requires env vars below)
|
|
202
|
+
npm run smoke:pack-install
|
|
178
203
|
```
|
|
179
204
|
|
|
180
|
-
|
|
181
|
-
`
|
|
205
|
+
Live mutation e2e env requirements:
|
|
206
|
+
- `FRIGATEBIRD_AUTH_TOKEN`
|
|
207
|
+
- `FRIGATEBIRD_CT0`
|
|
208
|
+
- `FRIGATEBIRD_LIVE_LIST_NAME`
|
|
209
|
+
- optional: `FRIGATEBIRD_LIVE_COOKIE_SOURCE`, `FRIGATEBIRD_LIVE_EXPECTED_HANDLE_PREFIX`, `FRIGATEBIRD_LIVE_TARGET_HANDLE`
|
|
210
|
+
|
|
211
|
+
## Release
|
|
212
|
+
|
|
213
|
+
- CI: `.github/workflows/ci.yml`
|
|
214
|
+
- npm publish: `.github/workflows/release.yml` (triggered by GitHub Release `published`, trusted publishing/OIDC)
|
|
215
|
+
- live mutation CI: `.github/workflows/live-e2e.yml` (manual trigger; validates `FRIGATEBIRD_LIVE_LIST_NAME` + auth secrets before running)
|
|
216
|
+
- release checklist: `RELEASE.md`
|
|
182
217
|
|
|
183
218
|
## Notes
|
|
184
219
|
|
|
185
|
-
-
|
|
186
|
-
-
|
|
220
|
+
- Selector drift is the primary maintenance cost.
|
|
221
|
+
- `query-ids` is intentionally kept for CLI compatibility with historical `bird` workflows.
|
package/TASKS.md
CHANGED
|
@@ -26,4 +26,4 @@
|
|
|
26
26
|
- [x] Add `CHANGELOG.md`.
|
|
27
27
|
- [x] Add `RELEASE.md` release checklist and publish flow.
|
|
28
28
|
- [x] Add `SKILL.md` + `SPEC.md` + `TASKS.md` for agent-oriented usage.
|
|
29
|
-
- [
|
|
29
|
+
- [x] Add mutation-focused e2e coverage against disposable test accounts, including list add/remove/batch checks.
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { getCookies } from "@steipete/sweet-cookie";
|
|
5
|
+
function parseBrowserSourceList(source) {
|
|
6
|
+
if (!source.startsWith("browser:"))
|
|
7
|
+
return null;
|
|
8
|
+
const suffix = source.slice("browser:".length).trim();
|
|
9
|
+
if (!suffix)
|
|
10
|
+
return null;
|
|
11
|
+
return suffix
|
|
12
|
+
.split(",")
|
|
13
|
+
.map((part) => part.trim())
|
|
14
|
+
.filter(Boolean);
|
|
15
|
+
}
|
|
4
16
|
function normalizeDomain(domain) {
|
|
5
17
|
if (!domain)
|
|
6
18
|
return ".x.com";
|
|
@@ -21,10 +33,46 @@ function toPlaywrightCookie(cookie) {
|
|
|
21
33
|
function mapSource(value) {
|
|
22
34
|
return value;
|
|
23
35
|
}
|
|
36
|
+
function formatError(error) {
|
|
37
|
+
if (error instanceof Error)
|
|
38
|
+
return error.message;
|
|
39
|
+
return String(error);
|
|
40
|
+
}
|
|
41
|
+
const SAFARI_COOKIE_STORE_PATH = path.join(homedir(), "Library", "Containers", "com.apple.Safari", "Data", "Library", "Cookies", "Cookies.binarycookies");
|
|
24
42
|
export class AuthStore {
|
|
25
43
|
authFile;
|
|
26
|
-
|
|
44
|
+
cookieExtractor;
|
|
45
|
+
lastDiagnostics = [];
|
|
46
|
+
constructor(authFile = path.join(process.cwd(), "auth.json"), cookieExtractor = getCookies) {
|
|
27
47
|
this.authFile = authFile;
|
|
48
|
+
this.cookieExtractor = cookieExtractor;
|
|
49
|
+
}
|
|
50
|
+
getLastDiagnostics() {
|
|
51
|
+
return [...this.lastDiagnostics];
|
|
52
|
+
}
|
|
53
|
+
setDiagnostics(messages) {
|
|
54
|
+
this.lastDiagnostics = Array.from(new Set(messages.filter(Boolean)));
|
|
55
|
+
}
|
|
56
|
+
diagnoseSafariCookieAccess() {
|
|
57
|
+
if (process.platform !== "darwin")
|
|
58
|
+
return [];
|
|
59
|
+
if (!fs.existsSync(SAFARI_COOKIE_STORE_PATH)) {
|
|
60
|
+
return [`Safari cookie store not found at ${SAFARI_COOKIE_STORE_PATH}.`];
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const fileDescriptor = fs.openSync(SAFARI_COOKIE_STORE_PATH, "r");
|
|
64
|
+
fs.closeSync(fileDescriptor);
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
const nodeError = error;
|
|
69
|
+
if (nodeError.code === "EPERM" || nodeError.code === "EACCES") {
|
|
70
|
+
return [
|
|
71
|
+
"Safari cookies are blocked by macOS privacy controls for this process. Grant Full Disk Access to your terminal/Codex app, then retry.",
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
return [`Safari cookie store could not be read: ${formatError(error)}.`];
|
|
75
|
+
}
|
|
28
76
|
}
|
|
29
77
|
loadFromDisk() {
|
|
30
78
|
if (!fs.existsSync(this.authFile))
|
|
@@ -55,21 +103,53 @@ export class AuthStore {
|
|
|
55
103
|
fs.unlinkSync(this.authFile);
|
|
56
104
|
}
|
|
57
105
|
}
|
|
106
|
+
isSavedSourceCompatible(source, options) {
|
|
107
|
+
const savedBrowsers = parseBrowserSourceList(source);
|
|
108
|
+
if (!savedBrowsers)
|
|
109
|
+
return true;
|
|
110
|
+
const requested = options.cookieSource;
|
|
111
|
+
if (savedBrowsers.length !== requested.length)
|
|
112
|
+
return false;
|
|
113
|
+
return savedBrowsers.every((value, index) => value === requested[index]);
|
|
114
|
+
}
|
|
58
115
|
async extractFromBrowser(options) {
|
|
59
116
|
const browsers = options.cookieSource.map(mapSource);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
117
|
+
let extraction = null;
|
|
118
|
+
try {
|
|
119
|
+
extraction = await this.cookieExtractor({
|
|
120
|
+
url: "https://x.com",
|
|
121
|
+
browsers,
|
|
122
|
+
chromeProfile: options.chromeProfileDir ?? options.chromeProfile,
|
|
123
|
+
firefoxProfile: options.firefoxProfile,
|
|
124
|
+
timeoutMs: options.cookieTimeout,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
const diagnostics = [
|
|
129
|
+
`Browser cookie extraction failed: ${formatError(error)}.`,
|
|
130
|
+
];
|
|
131
|
+
if (options.cookieSource.includes("safari")) {
|
|
132
|
+
diagnostics.push(...this.diagnoseSafariCookieAccess());
|
|
133
|
+
}
|
|
134
|
+
this.setDiagnostics(diagnostics);
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
67
137
|
if (!extraction || extraction.cookies.length === 0) {
|
|
138
|
+
const diagnostics = [
|
|
139
|
+
`No cookies were extracted from sources: ${options.cookieSource.join(", ")}.`,
|
|
140
|
+
];
|
|
141
|
+
if (options.cookieSource.includes("safari")) {
|
|
142
|
+
diagnostics.push(...this.diagnoseSafariCookieAccess());
|
|
143
|
+
}
|
|
144
|
+
this.setDiagnostics(diagnostics);
|
|
68
145
|
return null;
|
|
69
146
|
}
|
|
70
147
|
const authToken = extraction.cookies.find((cookie) => cookie.name === "auth_token");
|
|
71
148
|
const ct0 = extraction.cookies.find((cookie) => cookie.name === "ct0");
|
|
72
149
|
if (!authToken || !ct0) {
|
|
150
|
+
this.setDiagnostics([
|
|
151
|
+
`Cookies were extracted, but required auth cookies are missing (auth_token=${Boolean(authToken)}, ct0=${Boolean(ct0)}).`,
|
|
152
|
+
]);
|
|
73
153
|
return null;
|
|
74
154
|
}
|
|
75
155
|
const playwrightCookies = [
|
|
@@ -127,19 +207,34 @@ export class AuthStore {
|
|
|
127
207
|
};
|
|
128
208
|
}
|
|
129
209
|
async resolve(options, forceRefresh = false) {
|
|
210
|
+
this.setDiagnostics([]);
|
|
130
211
|
const manual = this.fromManualTokens(options);
|
|
131
212
|
if (manual)
|
|
132
213
|
return manual;
|
|
214
|
+
const sourceOverrideNotes = [];
|
|
133
215
|
if (!forceRefresh) {
|
|
134
216
|
const saved = this.loadFromDisk();
|
|
135
|
-
if (saved)
|
|
136
|
-
|
|
217
|
+
if (saved) {
|
|
218
|
+
if (!options.cookieSourceExplicit ||
|
|
219
|
+
this.isSavedSourceCompatible(saved.source, options)) {
|
|
220
|
+
return saved;
|
|
221
|
+
}
|
|
222
|
+
sourceOverrideNotes.push(`Cached auth source "${saved.source}" was ignored because --cookie-source was explicitly set to "${options.cookieSource.join(",")}".`);
|
|
223
|
+
this.clear();
|
|
224
|
+
}
|
|
137
225
|
}
|
|
138
226
|
const extracted = await this.extractFromBrowser(options);
|
|
139
227
|
if (extracted) {
|
|
228
|
+
this.setDiagnostics([]);
|
|
140
229
|
this.save(extracted.cookies, extracted.source);
|
|
141
230
|
return extracted;
|
|
142
231
|
}
|
|
232
|
+
if (sourceOverrideNotes.length > 0) {
|
|
233
|
+
this.setDiagnostics([
|
|
234
|
+
...sourceOverrideNotes,
|
|
235
|
+
...this.getLastDiagnostics(),
|
|
236
|
+
]);
|
|
237
|
+
}
|
|
143
238
|
return null;
|
|
144
239
|
}
|
|
145
240
|
}
|
|
@@ -1,5 +1,64 @@
|
|
|
1
1
|
import { chromium, } from "playwright";
|
|
2
2
|
import { AuthStore } from "./auth-store.js";
|
|
3
|
+
const HANDLE_PATTERN = /^[A-Za-z0-9_]{1,15}$/;
|
|
4
|
+
const RESERVED_HANDLE_SEGMENTS = new Set([
|
|
5
|
+
"compose",
|
|
6
|
+
"download",
|
|
7
|
+
"explore",
|
|
8
|
+
"hashtag",
|
|
9
|
+
"home",
|
|
10
|
+
"i",
|
|
11
|
+
"intent",
|
|
12
|
+
"login",
|
|
13
|
+
"logout",
|
|
14
|
+
"messages",
|
|
15
|
+
"notifications",
|
|
16
|
+
"search",
|
|
17
|
+
"settings",
|
|
18
|
+
"signup",
|
|
19
|
+
"tos",
|
|
20
|
+
]);
|
|
21
|
+
function normalizeHandleCandidate(value) {
|
|
22
|
+
if (!value)
|
|
23
|
+
return null;
|
|
24
|
+
const trimmed = value.trim();
|
|
25
|
+
if (!trimmed)
|
|
26
|
+
return null;
|
|
27
|
+
const handleMatch = trimmed.match(/@([A-Za-z0-9_]{1,15})/);
|
|
28
|
+
if (handleMatch?.[1])
|
|
29
|
+
return handleMatch[1];
|
|
30
|
+
let segment = trimmed;
|
|
31
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
32
|
+
try {
|
|
33
|
+
segment = new URL(trimmed).pathname;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (segment.startsWith("/")) {
|
|
40
|
+
segment = segment.replace(/^\/+/, "").split("/")[0] ?? "";
|
|
41
|
+
}
|
|
42
|
+
segment = segment.split("?")[0]?.split("#")[0]?.trim() ?? "";
|
|
43
|
+
if (!segment)
|
|
44
|
+
return null;
|
|
45
|
+
if (segment.startsWith("@"))
|
|
46
|
+
segment = segment.slice(1);
|
|
47
|
+
if (!segment)
|
|
48
|
+
return null;
|
|
49
|
+
if (RESERVED_HANDLE_SEGMENTS.has(segment.toLowerCase()))
|
|
50
|
+
return null;
|
|
51
|
+
if (!HANDLE_PATTERN.test(segment))
|
|
52
|
+
return null;
|
|
53
|
+
return segment;
|
|
54
|
+
}
|
|
55
|
+
function extractNameFromAccountText(value) {
|
|
56
|
+
const parts = value
|
|
57
|
+
.split("\n")
|
|
58
|
+
.map((item) => item.trim())
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
return parts.find((item) => !item.startsWith("@"));
|
|
61
|
+
}
|
|
3
62
|
export class BrowserSessionManager {
|
|
4
63
|
authStore;
|
|
5
64
|
constructor(authStore = new AuthStore()) {
|
|
@@ -69,34 +128,55 @@ export class BrowserSessionManager {
|
|
|
69
128
|
return url.includes("/home") && !url.includes("/login");
|
|
70
129
|
}
|
|
71
130
|
async whoAmI(page) {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
131
|
+
const profileSelectors = [
|
|
132
|
+
'[data-testid="AppTabBar_Profile_Link"]',
|
|
133
|
+
'a[data-testid$="_Profile_Link"]',
|
|
134
|
+
'[data-testid="SideNav_AccountSwitcher_Button"] a[href]',
|
|
135
|
+
];
|
|
136
|
+
let handle;
|
|
137
|
+
for (const selector of profileSelectors) {
|
|
138
|
+
const href = await page.getAttribute(selector, "href").catch(() => null);
|
|
139
|
+
const candidate = normalizeHandleCandidate(href);
|
|
140
|
+
if (candidate) {
|
|
141
|
+
handle = candidate;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const accountSwitcherSelector = '[data-testid="SideNav_AccountSwitcher_Button"]';
|
|
76
146
|
const accountText = await page
|
|
77
|
-
.locator(
|
|
147
|
+
.locator(accountSwitcherSelector)
|
|
78
148
|
.first()
|
|
79
149
|
.innerText()
|
|
80
150
|
.catch(() => "");
|
|
81
|
-
const
|
|
82
|
-
.
|
|
83
|
-
.
|
|
84
|
-
|
|
151
|
+
const accountAriaLabel = await page
|
|
152
|
+
.getAttribute(accountSwitcherSelector, "aria-label")
|
|
153
|
+
.catch(() => null);
|
|
154
|
+
if (!handle) {
|
|
155
|
+
handle =
|
|
156
|
+
normalizeHandleCandidate(accountText) ??
|
|
157
|
+
normalizeHandleCandidate(accountAriaLabel ?? "") ??
|
|
158
|
+
undefined;
|
|
159
|
+
}
|
|
160
|
+
const name = extractNameFromAccountText(accountText) ??
|
|
161
|
+
extractNameFromAccountText(accountAriaLabel ?? "");
|
|
85
162
|
return { handle, name };
|
|
86
163
|
}
|
|
87
164
|
async createCookieProbe(options) {
|
|
88
165
|
const resolved = await this.authStore.resolve(options);
|
|
166
|
+
const diagnostics = this.authStore.getLastDiagnostics();
|
|
89
167
|
if (!resolved) {
|
|
90
168
|
return {
|
|
91
169
|
hasAuthToken: false,
|
|
92
170
|
hasCt0: false,
|
|
93
171
|
source: "none",
|
|
172
|
+
diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
|
|
94
173
|
};
|
|
95
174
|
}
|
|
96
175
|
return {
|
|
97
176
|
hasAuthToken: resolved.cookies.some((cookie) => cookie.name === "auth_token"),
|
|
98
177
|
hasCt0: resolved.cookies.some((cookie) => cookie.name === "ct0"),
|
|
99
178
|
source: resolved.source,
|
|
179
|
+
diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
|
|
100
180
|
};
|
|
101
181
|
}
|
|
102
182
|
}
|
package/dist/cli/program.js
CHANGED
|
@@ -47,6 +47,11 @@ export function createProgram(handlers, version = "0.2.0") {
|
|
|
47
47
|
.command("post <text>")
|
|
48
48
|
.description("Alias for tweet")
|
|
49
49
|
.action(run((text) => handlers.tweet(text)));
|
|
50
|
+
program
|
|
51
|
+
.command("article <title> [body]")
|
|
52
|
+
.description("Publish a long-form article on X")
|
|
53
|
+
.option("--body-file <path>", "Read article body text from a UTF-8 file")
|
|
54
|
+
.action(run((title, body, options) => handlers.article(title, body, options)));
|
|
50
55
|
program
|
|
51
56
|
.command("reply <tweet-id-or-url> <text>")
|
|
52
57
|
.description("Reply to a tweet")
|
package/dist/cli.js
CHANGED
|
@@ -30,6 +30,8 @@ function collectExplicitCliBooleans(args) {
|
|
|
30
30
|
explicit.emoji = false;
|
|
31
31
|
if (args.includes("--no-headless"))
|
|
32
32
|
explicit.headless = false;
|
|
33
|
+
if (args.includes("--cookie-source"))
|
|
34
|
+
explicit.cookieSourceExplicit = true;
|
|
33
35
|
return explicit;
|
|
34
36
|
}
|
|
35
37
|
function compactObject(input) {
|
|
@@ -57,10 +57,15 @@ function resolveMediaSpecs(media, alt) {
|
|
|
57
57
|
}
|
|
58
58
|
return specs;
|
|
59
59
|
}
|
|
60
|
+
const HEADLESS_RETRY_MARKER = "[FRIGATEBIRD_HEADLESS_RETRY]";
|
|
61
|
+
function markHeadlessRetry(message) {
|
|
62
|
+
return new Error(`${HEADLESS_RETRY_MARKER} ${message}`);
|
|
63
|
+
}
|
|
60
64
|
export class PlaywrightXClient {
|
|
61
65
|
options;
|
|
62
66
|
sessions;
|
|
63
67
|
baseUrl;
|
|
68
|
+
currentSessionHeadless = null;
|
|
64
69
|
constructor(options, sessions = new BrowserSessionManager()) {
|
|
65
70
|
this.options = options;
|
|
66
71
|
this.sessions = sessions;
|
|
@@ -74,10 +79,49 @@ export class PlaywrightXClient {
|
|
|
74
79
|
return this.options;
|
|
75
80
|
return { ...this.options, headless: headlessOverride };
|
|
76
81
|
}
|
|
82
|
+
isHeadlessRetryError(error) {
|
|
83
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
84
|
+
return message.includes(HEADLESS_RETRY_MARKER);
|
|
85
|
+
}
|
|
86
|
+
normalizeError(error) {
|
|
87
|
+
if (error instanceof Error) {
|
|
88
|
+
if (this.isHeadlessRetryError(error)) {
|
|
89
|
+
return new Error(error.message.replace(`${HEADLESS_RETRY_MARKER} `, "").trim());
|
|
90
|
+
}
|
|
91
|
+
return error;
|
|
92
|
+
}
|
|
93
|
+
return new Error(String(error));
|
|
94
|
+
}
|
|
77
95
|
async withPage(task, headlessOverride) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
96
|
+
const effectiveOptions = this.mergedOptions(headlessOverride);
|
|
97
|
+
const runWithMode = async (headless) => {
|
|
98
|
+
return this.sessions.withSession({ ...effectiveOptions, headless }, async ({ page }) => {
|
|
99
|
+
const previous = this.currentSessionHeadless;
|
|
100
|
+
this.currentSessionHeadless = headless;
|
|
101
|
+
try {
|
|
102
|
+
return await task(page);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
this.currentSessionHeadless = previous;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
try {
|
|
110
|
+
return await runWithMode(effectiveOptions.headless);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (effectiveOptions.headless &&
|
|
114
|
+
headlessOverride === undefined &&
|
|
115
|
+
this.isHeadlessRetryError(error)) {
|
|
116
|
+
try {
|
|
117
|
+
return await runWithMode(false);
|
|
118
|
+
}
|
|
119
|
+
catch (secondError) {
|
|
120
|
+
throw this.normalizeError(secondError);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
throw this.normalizeError(error);
|
|
124
|
+
}
|
|
81
125
|
}
|
|
82
126
|
async ensureAuth(page) {
|
|
83
127
|
const loggedIn = await this.sessions.ensureLoggedIn(page, this.baseUrl);
|
|
@@ -96,7 +140,41 @@ export class PlaywrightXClient {
|
|
|
96
140
|
throw new Error("Invalid tweet reference. Provide a tweet URL or numeric tweet ID.");
|
|
97
141
|
}
|
|
98
142
|
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
99
|
-
|
|
143
|
+
try {
|
|
144
|
+
await page.waitForSelector('[data-testid="tweet"]', { timeout: 15000 });
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
const isHeadlessSession = this.currentSessionHeadless ?? this.options.headless;
|
|
148
|
+
const hasTweet = (await page
|
|
149
|
+
.locator('[data-testid="tweet"]')
|
|
150
|
+
.count()
|
|
151
|
+
.catch(() => 0)) > 0;
|
|
152
|
+
const errorPageVisible = (await page
|
|
153
|
+
.locator("text=Something went wrong, but don’t fret")
|
|
154
|
+
.first()
|
|
155
|
+
.isVisible()
|
|
156
|
+
.catch(() => false)) ||
|
|
157
|
+
(await page
|
|
158
|
+
.locator("text=Something went wrong, but don't fret")
|
|
159
|
+
.first()
|
|
160
|
+
.isVisible()
|
|
161
|
+
.catch(() => false)) ||
|
|
162
|
+
(await page
|
|
163
|
+
.locator("text=Something went wrong")
|
|
164
|
+
.first()
|
|
165
|
+
.isVisible()
|
|
166
|
+
.catch(() => false));
|
|
167
|
+
if (!hasTweet && errorPageVisible && isHeadlessSession) {
|
|
168
|
+
throw markHeadlessRetry("X returned a temporary error page while loading the tweet in headless mode. Retrying in headed mode.");
|
|
169
|
+
}
|
|
170
|
+
if (!hasTweet && isHeadlessSession) {
|
|
171
|
+
throw markHeadlessRetry("Tweet content did not render in headless mode. Retrying in headed mode.");
|
|
172
|
+
}
|
|
173
|
+
if (!hasTweet) {
|
|
174
|
+
throw new Error("Tweet content did not render. X may be returning a transient error page or blocking this route right now.");
|
|
175
|
+
}
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
100
178
|
return url;
|
|
101
179
|
}
|
|
102
180
|
async openList(page, listRef) {
|
|
@@ -162,45 +240,157 @@ export class PlaywrightXClient {
|
|
|
162
240
|
}
|
|
163
241
|
return null;
|
|
164
242
|
}
|
|
243
|
+
isPointerInterceptionError(error) {
|
|
244
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
245
|
+
return (message.includes("intercepts pointer events") ||
|
|
246
|
+
(message.includes("Timeout") && message.includes("locator.click")));
|
|
247
|
+
}
|
|
248
|
+
async dismissBlockingLayers(page) {
|
|
249
|
+
const mask = page.locator('[data-testid="twc-cc-mask"]').first();
|
|
250
|
+
if (!(await mask.isVisible().catch(() => false))) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const consentSelectors = [
|
|
254
|
+
'[role="dialog"] [role="button"]:has-text("Accept all cookies")',
|
|
255
|
+
'[role="dialog"] [role="button"]:has-text("Accept all")',
|
|
256
|
+
'[role="button"]:has-text("Accept all cookies")',
|
|
257
|
+
'[role="button"]:has-text("Accept all")',
|
|
258
|
+
'[role="button"]:has-text("Agree and continue")',
|
|
259
|
+
'[role="button"]:has-text("Close")',
|
|
260
|
+
];
|
|
261
|
+
for (const selector of consentSelectors) {
|
|
262
|
+
const button = page.locator(selector).first();
|
|
263
|
+
if (!(await button.isVisible().catch(() => false)))
|
|
264
|
+
continue;
|
|
265
|
+
await button.click().catch(() => { });
|
|
266
|
+
await page.waitForTimeout(120);
|
|
267
|
+
}
|
|
268
|
+
if (await mask.isVisible().catch(() => false)) {
|
|
269
|
+
await page.keyboard.press("Escape").catch(() => { });
|
|
270
|
+
await page.waitForTimeout(120);
|
|
271
|
+
}
|
|
272
|
+
if (await mask.isVisible().catch(() => false)) {
|
|
273
|
+
await mask.click({ force: true }).catch(() => { });
|
|
274
|
+
}
|
|
275
|
+
const deadline = Date.now() + 3000;
|
|
276
|
+
while (Date.now() < deadline) {
|
|
277
|
+
if (!(await mask.isVisible().catch(() => false)))
|
|
278
|
+
break;
|
|
279
|
+
await page.waitForTimeout(120);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
165
282
|
async clickFirstVisible(page, selectors, timeoutMs = 5000) {
|
|
166
283
|
const locator = await this.firstVisibleLocator(page, selectors, timeoutMs);
|
|
167
284
|
if (!locator)
|
|
168
285
|
return false;
|
|
169
|
-
|
|
170
|
-
|
|
286
|
+
const clickTimeout = Math.max(800, Math.min(timeoutMs, 2500));
|
|
287
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
288
|
+
await this.dismissBlockingLayers(page);
|
|
289
|
+
try {
|
|
290
|
+
await locator.click({ timeout: clickTimeout });
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
if (!this.isPointerInterceptionError(error) || attempt === 3) {
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
const forced = await locator
|
|
298
|
+
.click({ force: true, timeout: 2000 })
|
|
299
|
+
.then(() => true)
|
|
300
|
+
.catch(() => false);
|
|
301
|
+
if (forced) {
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
await page.waitForTimeout(200 * attempt);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
171
308
|
}
|
|
172
309
|
async waitForComposer(page, timeoutMs = 15000) {
|
|
173
|
-
const
|
|
310
|
+
const composerSelectors = [
|
|
174
311
|
'[data-testid="tweetTextarea_0"]',
|
|
175
312
|
'div[role="textbox"][data-testid="tweetTextarea_0"]',
|
|
176
313
|
'div[role="textbox"][aria-label*="Post text"]',
|
|
177
314
|
'div[role="textbox"][aria-label*="What is happening"]',
|
|
178
315
|
'div[role="textbox"][aria-label*="What’s happening"]',
|
|
179
|
-
]
|
|
316
|
+
];
|
|
317
|
+
const composer = await this.firstVisibleLocator(page, composerSelectors, timeoutMs);
|
|
180
318
|
if (!composer) {
|
|
181
319
|
throw new Error("Composer text area not found. X may have changed selectors.");
|
|
182
320
|
}
|
|
183
321
|
}
|
|
184
322
|
async fillComposer(page, text) {
|
|
185
|
-
const
|
|
323
|
+
const composerSelectors = [
|
|
186
324
|
'[data-testid="tweetTextarea_0"]',
|
|
187
325
|
'div[role="textbox"][data-testid="tweetTextarea_0"]',
|
|
188
326
|
'div[role="textbox"][aria-label*="Post text"]',
|
|
189
327
|
'div[role="textbox"][aria-label*="What is happening"]',
|
|
190
328
|
'div[role="textbox"][aria-label*="What’s happening"]',
|
|
191
|
-
]
|
|
192
|
-
|
|
329
|
+
];
|
|
330
|
+
const candidateLocators = [];
|
|
331
|
+
for (const selector of composerSelectors) {
|
|
332
|
+
const locators = page.locator(selector);
|
|
333
|
+
const count = await locators.count().catch(() => 0);
|
|
334
|
+
for (let index = 0; index < count; index += 1) {
|
|
335
|
+
const candidate = locators.nth(index);
|
|
336
|
+
if (await candidate.isVisible().catch(() => false)) {
|
|
337
|
+
candidateLocators.push(candidate);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (candidateLocators.length === 0) {
|
|
193
342
|
throw new Error("Composer text area not found.");
|
|
194
343
|
}
|
|
195
|
-
|
|
344
|
+
const submitEnabledSelectors = [
|
|
345
|
+
'[data-testid="tweetButton"]:not([aria-disabled="true"])',
|
|
346
|
+
'[data-testid="tweetButtonInline"]:not([aria-disabled="true"])',
|
|
347
|
+
'[role="button"]:has-text("Post"):not([aria-disabled="true"])',
|
|
348
|
+
'[role="button"]:has-text("Reply"):not([aria-disabled="true"])',
|
|
349
|
+
];
|
|
350
|
+
const isSubmitEnabled = async () => {
|
|
351
|
+
const enabled = await this.firstVisibleLocator(page, submitEnabledSelectors, 800, 100);
|
|
352
|
+
return Boolean(enabled);
|
|
353
|
+
};
|
|
354
|
+
for (const composer of candidateLocators) {
|
|
355
|
+
await composer.click().catch(() => { });
|
|
356
|
+
await composer.fill(text).catch(() => { });
|
|
357
|
+
const injected = await composer
|
|
358
|
+
.evaluate((node) => {
|
|
359
|
+
const element = node;
|
|
360
|
+
return (element.innerText ||
|
|
361
|
+
element.textContent ||
|
|
362
|
+
element.value ||
|
|
363
|
+
"").trim();
|
|
364
|
+
})
|
|
365
|
+
.catch(() => "");
|
|
366
|
+
if (!injected || injected.length < Math.min(6, text.length)) {
|
|
367
|
+
await page.keyboard.type(text).catch(() => { });
|
|
368
|
+
}
|
|
369
|
+
if (await isSubmitEnabled()) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
196
373
|
}
|
|
197
374
|
async submitComposer(page, action) {
|
|
375
|
+
await page.keyboard.press("Meta+Enter").catch(async () => {
|
|
376
|
+
await page.keyboard.press("Control+Enter").catch(() => { });
|
|
377
|
+
});
|
|
378
|
+
await page.waitForTimeout(500);
|
|
379
|
+
const sent = await this.firstVisibleLocator(page, [
|
|
380
|
+
'[role="alert"]:has-text("Your post was sent")',
|
|
381
|
+
'[role="alert"]:has-text("post was sent")',
|
|
382
|
+
'[role="alert"]:has-text("sent")',
|
|
383
|
+
'[data-testid="toast"]',
|
|
384
|
+
], 1800, 100);
|
|
385
|
+
if (sent) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
198
388
|
const clicked = await this.clickFirstVisible(page, [
|
|
199
|
-
'[data-testid="tweetButton"]',
|
|
200
|
-
'[data-testid="tweetButtonInline"]',
|
|
201
|
-
'button[data-testid="tweetButton"]',
|
|
202
|
-
'[role="button"]:has-text("Post")',
|
|
203
|
-
'[role="button"]:has-text("Reply")',
|
|
389
|
+
'[data-testid="tweetButton"]:not([aria-disabled="true"])',
|
|
390
|
+
'[data-testid="tweetButtonInline"]:not([aria-disabled="true"])',
|
|
391
|
+
'button[data-testid="tweetButton"]:not([aria-disabled="true"])',
|
|
392
|
+
'[role="button"]:has-text("Post"):not([aria-disabled="true"])',
|
|
393
|
+
'[role="button"]:has-text("Reply"):not([aria-disabled="true"])',
|
|
204
394
|
]);
|
|
205
395
|
if (!clicked) {
|
|
206
396
|
throw new Error(`Could not locate ${action} submit button. X may have changed selectors.`);
|
|
@@ -368,26 +558,53 @@ export class PlaywrightXClient {
|
|
|
368
558
|
await page.goto(this.absolute(`/${normalized}`), {
|
|
369
559
|
waitUntil: "domcontentloaded",
|
|
370
560
|
});
|
|
371
|
-
const
|
|
372
|
-
'[data-testid="userActions"]',
|
|
373
|
-
'button[aria-label*="More"]',
|
|
374
|
-
'button[aria-label*="more"]',
|
|
375
|
-
], 15000);
|
|
376
|
-
if (!actionsClicked) {
|
|
377
|
-
throw new Error(`Could not open actions menu for @${normalized}. X may have changed selectors.`);
|
|
378
|
-
}
|
|
379
|
-
await page.waitForTimeout(300);
|
|
380
|
-
const menuClicked = await this.clickFirstVisible(page, [
|
|
561
|
+
const listMenuSelectors = [
|
|
381
562
|
'[role="menuitem"]:has-text("Add/remove from Lists")',
|
|
563
|
+
'[role="menuitem"]:has-text("Add/remove")',
|
|
382
564
|
'[role="menuitem"]:has-text("Lists")',
|
|
383
565
|
'button:has-text("Add/remove from Lists")',
|
|
384
|
-
'button:has-text("
|
|
566
|
+
'button:has-text("Add/remove")',
|
|
385
567
|
'a:has-text("Add/remove from Lists")',
|
|
386
|
-
]
|
|
568
|
+
];
|
|
569
|
+
const openProfileMenuWithButton = async (button) => {
|
|
570
|
+
await button.click();
|
|
571
|
+
await page.waitForTimeout(250);
|
|
572
|
+
const menuItem = await this.firstVisibleLocator(page, listMenuSelectors, 1200, 100);
|
|
573
|
+
if (menuItem)
|
|
574
|
+
return true;
|
|
575
|
+
await page.keyboard.press("Escape").catch(() => { });
|
|
576
|
+
await page.waitForTimeout(120);
|
|
577
|
+
return false;
|
|
578
|
+
};
|
|
579
|
+
// Prefer the dedicated profile actions button when available.
|
|
580
|
+
const profileActions = await this.firstVisibleLocator(page, ['main [data-testid="userActions"]', '[data-testid="userActions"]'], 15000);
|
|
581
|
+
let openedListMenu = false;
|
|
582
|
+
if (profileActions) {
|
|
583
|
+
openedListMenu = await openProfileMenuWithButton(profileActions);
|
|
584
|
+
}
|
|
585
|
+
// Fallback for UI variants where userActions is late/missing.
|
|
586
|
+
if (!openedListMenu) {
|
|
587
|
+
const fallbackButtons = page.locator('main button[aria-haspopup="menu"][aria-label*="More"]');
|
|
588
|
+
const fallbackCount = await fallbackButtons.count();
|
|
589
|
+
const scanLimit = Math.min(fallbackCount, 8);
|
|
590
|
+
for (let index = 0; index < scanLimit; index += 1) {
|
|
591
|
+
const button = fallbackButtons.nth(index);
|
|
592
|
+
if (!(await button.isVisible().catch(() => false)))
|
|
593
|
+
continue;
|
|
594
|
+
if (await openProfileMenuWithButton(button)) {
|
|
595
|
+
openedListMenu = true;
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (!openedListMenu) {
|
|
601
|
+
throw new Error(`Could not locate profile list actions for @${normalized}. X may have changed selectors.`);
|
|
602
|
+
}
|
|
603
|
+
const menuClicked = await this.clickFirstVisible(page, listMenuSelectors, 5000);
|
|
387
604
|
if (!menuClicked) {
|
|
388
605
|
throw new Error(`Could not locate "Add/remove from Lists" menu item for @${normalized}.`);
|
|
389
606
|
}
|
|
390
|
-
const dialogSelector = '[role="dialog"]:has(
|
|
607
|
+
const dialogSelector = '[role="dialog"]:has([role="checkbox"]):visible';
|
|
391
608
|
await page.waitForSelector(dialogSelector, { timeout: 15000 });
|
|
392
609
|
return dialogSelector;
|
|
393
610
|
}
|
|
@@ -395,16 +612,18 @@ export class PlaywrightXClient {
|
|
|
395
612
|
try {
|
|
396
613
|
const outcome = await this.retryWithBackoff(async () => {
|
|
397
614
|
const dialogSelector = await this.openListMembershipDialog(page, handle);
|
|
398
|
-
const listCheckboxes = page.locator(`${dialogSelector}
|
|
615
|
+
const listCheckboxes = page.locator(`${dialogSelector} [role="checkbox"]`);
|
|
399
616
|
const count = await listCheckboxes.count();
|
|
400
617
|
if (count === 0) {
|
|
401
618
|
throw new Error("List dialog opened without visible checkboxes.");
|
|
402
619
|
}
|
|
403
620
|
let checkbox = null;
|
|
621
|
+
const desiredName = listName.trim().toLowerCase();
|
|
404
622
|
for (let index = 0; index < count; index += 1) {
|
|
405
623
|
const candidate = listCheckboxes.nth(index);
|
|
406
624
|
const text = await candidate.innerText().catch(() => "");
|
|
407
|
-
|
|
625
|
+
const normalized = text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
626
|
+
if (normalized === desiredName || normalized.includes(desiredName)) {
|
|
408
627
|
checkbox = candidate;
|
|
409
628
|
break;
|
|
410
629
|
}
|
|
@@ -447,6 +666,7 @@ export class PlaywrightXClient {
|
|
|
447
666
|
hasAuthToken: probe.hasAuthToken,
|
|
448
667
|
hasCt0: probe.hasCt0,
|
|
449
668
|
authFile: this.sessions.getAuthStore().authFile,
|
|
669
|
+
diagnostics: probe.diagnostics,
|
|
450
670
|
};
|
|
451
671
|
}
|
|
452
672
|
async whoami() {
|
|
@@ -485,6 +705,87 @@ export class PlaywrightXClient {
|
|
|
485
705
|
return ok("Tweet posted.");
|
|
486
706
|
});
|
|
487
707
|
}
|
|
708
|
+
async publishArticle(title, body) {
|
|
709
|
+
const cleanTitle = title.trim();
|
|
710
|
+
const cleanBody = body.trim();
|
|
711
|
+
if (!cleanTitle) {
|
|
712
|
+
return fail("Article title cannot be empty.");
|
|
713
|
+
}
|
|
714
|
+
if (!cleanBody) {
|
|
715
|
+
return fail("Article body cannot be empty.");
|
|
716
|
+
}
|
|
717
|
+
return this.withPage(async (page) => {
|
|
718
|
+
await this.ensureAuth(page);
|
|
719
|
+
const composePaths = [
|
|
720
|
+
"/i/articles/new",
|
|
721
|
+
"/compose/article",
|
|
722
|
+
"/write",
|
|
723
|
+
"/i/write",
|
|
724
|
+
"/i/notes/new",
|
|
725
|
+
"/compose/post",
|
|
726
|
+
];
|
|
727
|
+
const titleSelectors = [
|
|
728
|
+
'input[data-testid="articleTitleInput"]',
|
|
729
|
+
'textarea[data-testid="articleTitleInput"]',
|
|
730
|
+
'[data-testid="article-editor-title"] [contenteditable="true"]',
|
|
731
|
+
'[role="textbox"][aria-label*="Title"]',
|
|
732
|
+
'input[placeholder*="Title"]',
|
|
733
|
+
'textarea[placeholder*="Title"]',
|
|
734
|
+
'[contenteditable="true"][aria-label*="Title"]',
|
|
735
|
+
];
|
|
736
|
+
const bodySelectors = [
|
|
737
|
+
'[data-testid="articleBodyInput"]',
|
|
738
|
+
'[data-testid="article-editor"] [contenteditable="true"]',
|
|
739
|
+
'.ProseMirror[contenteditable="true"]',
|
|
740
|
+
'[role="textbox"][aria-label*="Write"]',
|
|
741
|
+
'[contenteditable="true"][aria-label*="Write"]',
|
|
742
|
+
'[role="textbox"][aria-label*="Body"]',
|
|
743
|
+
'textarea[placeholder*="Write"]',
|
|
744
|
+
];
|
|
745
|
+
let titleField = null;
|
|
746
|
+
let bodyField = null;
|
|
747
|
+
for (const path of composePaths) {
|
|
748
|
+
await page
|
|
749
|
+
.goto(this.absolute(path), { waitUntil: "domcontentloaded" })
|
|
750
|
+
.catch(() => { });
|
|
751
|
+
await this.dismissBlockingLayers(page);
|
|
752
|
+
await this.clickFirstVisible(page, [
|
|
753
|
+
'[role="tab"]:has-text("Article")',
|
|
754
|
+
'[role="button"]:has-text("Write article")',
|
|
755
|
+
'[data-testid="articleComposerButton"]',
|
|
756
|
+
'a[href*="/i/articles/new"]',
|
|
757
|
+
], 1500).catch(() => false);
|
|
758
|
+
titleField = await this.firstVisibleLocator(page, titleSelectors, 3500, 120);
|
|
759
|
+
bodyField = await this.firstVisibleLocator(page, bodySelectors, 3500, 120);
|
|
760
|
+
if (titleField && bodyField) {
|
|
761
|
+
break;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (!titleField || !bodyField) {
|
|
765
|
+
return fail("Could not locate article composer. Articles may be unavailable for this account or X changed selectors.");
|
|
766
|
+
}
|
|
767
|
+
await titleField.click().catch(() => { });
|
|
768
|
+
await titleField.fill(cleanTitle);
|
|
769
|
+
await bodyField.click().catch(() => { });
|
|
770
|
+
await bodyField.fill(cleanBody);
|
|
771
|
+
const published = await this.clickFirstVisible(page, [
|
|
772
|
+
'[data-testid="articlePublishButton"]:not([aria-disabled="true"])',
|
|
773
|
+
'[data-testid="publishButton"]:not([aria-disabled="true"])',
|
|
774
|
+
'[role="button"]:has-text("Publish"):not([aria-disabled="true"])',
|
|
775
|
+
'[role="button"]:has-text("Post"):not([aria-disabled="true"])',
|
|
776
|
+
], 12000).catch(() => false);
|
|
777
|
+
if (!published) {
|
|
778
|
+
return fail("Could not locate article publish button.");
|
|
779
|
+
}
|
|
780
|
+
await this.clickFirstVisible(page, [
|
|
781
|
+
'[data-testid="confirmationSheetConfirm"]',
|
|
782
|
+
'[role="button"]:has-text("Publish")',
|
|
783
|
+
'[role="button"]:has-text("Post")',
|
|
784
|
+
], 3000).catch(() => false);
|
|
785
|
+
await page.waitForTimeout(1000);
|
|
786
|
+
return ok("Article published.");
|
|
787
|
+
});
|
|
788
|
+
}
|
|
488
789
|
async reply(tweetRef, text) {
|
|
489
790
|
let mediaSpecs = [];
|
|
490
791
|
try {
|
|
@@ -537,26 +838,33 @@ export class PlaywrightXClient {
|
|
|
537
838
|
return this.withPage(async (page) => {
|
|
538
839
|
await this.ensureAuth(page);
|
|
539
840
|
await this.openTweet(page, tweetRef);
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
841
|
+
const undoSelectors = [
|
|
842
|
+
'[data-testid="unretweet"]',
|
|
843
|
+
'button[aria-label*="Undo repost"]',
|
|
844
|
+
'div[aria-label*="Undo repost"]',
|
|
845
|
+
];
|
|
846
|
+
if (await this.firstVisibleLocator(page, undoSelectors, 1200, 150)) {
|
|
544
847
|
return ok("Tweet already reposted.");
|
|
545
848
|
}
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
849
|
+
const repostClicked = await this.clickFirstVisible(page, [
|
|
850
|
+
'[data-testid="retweet"]',
|
|
851
|
+
'button[aria-label*="Repost"]',
|
|
852
|
+
'div[aria-label*="Repost"]',
|
|
853
|
+
]).catch(() => false);
|
|
854
|
+
if (!repostClicked) {
|
|
550
855
|
return fail("Could not locate repost button.");
|
|
551
856
|
}
|
|
552
|
-
await
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
857
|
+
await page.waitForTimeout(250);
|
|
858
|
+
const confirmClicked = await this.clickFirstVisible(page, [
|
|
859
|
+
'[data-testid="retweetConfirm"]',
|
|
860
|
+
'[role="menuitem"]:has-text("Repost")',
|
|
861
|
+
'[role="dialog"] [role="button"]:has-text("Repost")',
|
|
862
|
+
], 2500).catch(() => false);
|
|
863
|
+
const nowReposted = await this.firstVisibleLocator(page, undoSelectors, 5000, 150);
|
|
864
|
+
if (nowReposted || confirmClicked) {
|
|
865
|
+
return ok("Tweet reposted.");
|
|
558
866
|
}
|
|
559
|
-
return
|
|
867
|
+
return fail("Repost action did not complete. X may require additional confirmation.");
|
|
560
868
|
});
|
|
561
869
|
}
|
|
562
870
|
async read(tweetRef) {
|
|
@@ -963,12 +1271,14 @@ export class PlaywrightXClient {
|
|
|
963
1271
|
async refresh() {
|
|
964
1272
|
const refreshed = await this.sessions.refreshAuth(this.options);
|
|
965
1273
|
const loggedIn = await this.withPage(async (page) => this.sessions.ensureLoggedIn(page, this.baseUrl));
|
|
1274
|
+
const diagnostics = this.sessions.getAuthStore().getLastDiagnostics();
|
|
966
1275
|
return {
|
|
967
1276
|
loggedIn,
|
|
968
1277
|
source: refreshed?.source ?? "none",
|
|
969
1278
|
hasAuthToken: Boolean(refreshed?.cookies.some((cookie) => cookie.name === "auth_token")),
|
|
970
1279
|
hasCt0: Boolean(refreshed?.cookies.some((cookie) => cookie.name === "ct0")),
|
|
971
1280
|
authFile: this.sessions.getAuthStore().authFile,
|
|
1281
|
+
diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
|
|
972
1282
|
};
|
|
973
1283
|
}
|
|
974
1284
|
async addToList(listName, handles, headlessOverride) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import { parseBookmarkOptions, parseFollowListOptions, parseJsonFlag, parseListOptions, parseMentionOptions, parseNewsOptions, parsePaginationOptions, parseTimelineOptions, } from "../lib/options.js";
|
|
2
3
|
function parseRawArgs(options) {
|
|
3
4
|
if (options && typeof options === "object") {
|
|
@@ -84,6 +85,11 @@ export function createHandlers({ client, output }) {
|
|
|
84
85
|
output.info(`auth_token: ${status.hasAuthToken ? "present" : "missing"}`);
|
|
85
86
|
output.info(`ct0: ${status.hasCt0 ? "present" : "missing"}`);
|
|
86
87
|
output.info(`Auth file: ${status.authFile}`);
|
|
88
|
+
if (status.diagnostics) {
|
|
89
|
+
for (const diagnostic of status.diagnostics) {
|
|
90
|
+
output.warn(diagnostic);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
87
93
|
},
|
|
88
94
|
whoami: async (options) => {
|
|
89
95
|
const json = parseJsonFlag(parseRawArgs(options));
|
|
@@ -98,6 +104,22 @@ export function createHandlers({ client, output }) {
|
|
|
98
104
|
tweet: async (text) => {
|
|
99
105
|
output.mutation(await client.tweet(text));
|
|
100
106
|
},
|
|
107
|
+
article: async (title, body, options) => {
|
|
108
|
+
const raw = parseRawArgs(options);
|
|
109
|
+
const bodyFile = typeof raw.bodyFile === "string" ? raw.bodyFile.trim() : "";
|
|
110
|
+
let content = body?.trim() ?? "";
|
|
111
|
+
if (bodyFile) {
|
|
112
|
+
const fromFile = fs.readFileSync(bodyFile, "utf8").trim();
|
|
113
|
+
if (!fromFile) {
|
|
114
|
+
throw new Error(`Article body file is empty: ${bodyFile}`);
|
|
115
|
+
}
|
|
116
|
+
content = content ? `${content}\n\n${fromFile}` : fromFile;
|
|
117
|
+
}
|
|
118
|
+
if (!content) {
|
|
119
|
+
throw new Error("Article body is required. Provide [body] or --body-file <path>.");
|
|
120
|
+
}
|
|
121
|
+
output.mutation(await client.publishArticle(title, content));
|
|
122
|
+
},
|
|
101
123
|
reply: async (tweetRef, text) => {
|
|
102
124
|
output.mutation(await client.reply(tweetRef, text));
|
|
103
125
|
},
|
|
@@ -268,6 +290,11 @@ export function createHandlers({ client, output }) {
|
|
|
268
290
|
output.info(`Source: ${result.source}`);
|
|
269
291
|
output.info(`auth_token: ${result.hasAuthToken ? "present" : "missing"}`);
|
|
270
292
|
output.info(`ct0: ${result.hasCt0 ? "present" : "missing"}`);
|
|
293
|
+
if (result.diagnostics) {
|
|
294
|
+
for (const diagnostic of result.diagnostics) {
|
|
295
|
+
output.warn(diagnostic);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
271
298
|
}
|
|
272
299
|
},
|
|
273
300
|
add: async (listName, handles, options) => {
|
package/dist/lib/invocation.js
CHANGED
package/dist/lib/options.js
CHANGED
|
@@ -56,6 +56,7 @@ export function parseGlobalOptions(raw) {
|
|
|
56
56
|
firefoxProfile: raw.firefoxProfile ? String(raw.firefoxProfile) : undefined,
|
|
57
57
|
cookieTimeout: parseOptionalPositiveInt(raw.cookieTimeout),
|
|
58
58
|
cookieSource: parseCookieSources(raw.cookieSource),
|
|
59
|
+
cookieSourceExplicit: Boolean(raw.cookieSourceExplicit),
|
|
59
60
|
timeout: parseOptionalPositiveInt(raw.timeout),
|
|
60
61
|
quoteDepth: parseOptionalPositiveInt(raw.quoteDepth),
|
|
61
62
|
plain: Boolean(raw.plain),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "frigatebird",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Playwright-first X CLI with bird and
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Playwright-first X CLI with bird-style parity and resilient web automation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cli.js",
|
|
7
7
|
"license": "MIT",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"test:run": "vitest run --config vitest.config.ts",
|
|
43
43
|
"test:coverage": "vitest run --config vitest.config.ts --coverage",
|
|
44
44
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
45
|
+
"test:e2e:live": "FRIGATEBIRD_LIVE_E2E=1 vitest run --config vitest.e2e.config.ts tests/e2e/live-mutation-account.e2e.test.ts",
|
|
45
46
|
"lint": "biome check .",
|
|
46
47
|
"lint:fix": "biome check --write .",
|
|
47
48
|
"release:check": "npm run lint && npm run build && npm run test:coverage",
|