@jackwener/opencli 1.7.13 → 1.7.15
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/cli-manifest.json +326 -44
- package/clis/bilibili/subtitle.js +1 -1
- package/clis/dianping/cityResolver.js +185 -0
- package/clis/dianping/dianping.test.js +154 -0
- package/clis/dianping/search.js +6 -3
- package/clis/douyin/_shared/browser-fetch.js +14 -2
- package/clis/douyin/_shared/browser-fetch.test.js +13 -0
- package/clis/douyin/stats.js +1 -1
- package/clis/douyin/update.js +1 -1
- package/clis/jike/search.js +1 -1
- package/clis/reddit/search.js +1 -1
- package/clis/reddit/subreddit.js +1 -1
- package/clis/reddit/user-comments.js +1 -1
- package/clis/reddit/user-posts.js +1 -1
- package/clis/reddit/user.js +1 -1
- package/clis/twitter/article.js +2 -1
- package/clis/twitter/bookmark-folder.js +189 -0
- package/clis/twitter/bookmark-folder.test.js +334 -0
- package/clis/twitter/bookmark-folders.js +117 -0
- package/clis/twitter/bookmark-folders.test.js +150 -0
- package/clis/twitter/bookmark.js +15 -6
- package/clis/twitter/bookmark.test.js +74 -0
- package/clis/twitter/bookmarks.js +7 -5
- package/clis/twitter/delete.js +11 -35
- package/clis/twitter/delete.test.js +21 -9
- package/clis/twitter/download.js +5 -5
- package/clis/twitter/followers.js +9 -3
- package/clis/twitter/following.js +11 -5
- package/clis/twitter/hide-reply.js +24 -5
- package/clis/twitter/hide-reply.test.js +76 -0
- package/clis/twitter/like.js +21 -11
- package/clis/twitter/like.test.js +73 -0
- package/clis/twitter/likes.js +8 -6
- package/clis/twitter/list-add.js +4 -4
- package/clis/twitter/list-remove.js +4 -4
- package/clis/twitter/list-tweets.js +6 -4
- package/clis/twitter/lists.js +3 -3
- package/clis/twitter/notifications.js +2 -2
- package/clis/twitter/profile.js +4 -3
- package/clis/twitter/quote.js +167 -0
- package/clis/twitter/quote.test.js +194 -0
- package/clis/twitter/reply.js +24 -178
- package/clis/twitter/reply.test.js +29 -11
- package/clis/twitter/retweet.js +94 -0
- package/clis/twitter/retweet.test.js +73 -0
- package/clis/twitter/search.js +175 -23
- package/clis/twitter/search.test.js +266 -1
- package/clis/twitter/shared.js +81 -0
- package/clis/twitter/shared.test.js +134 -1
- package/clis/twitter/thread.js +6 -4
- package/clis/twitter/timeline.js +8 -6
- package/clis/twitter/tweets.js +5 -3
- package/clis/twitter/unbookmark.js +13 -6
- package/clis/twitter/unbookmark.test.js +73 -0
- package/clis/twitter/unlike.js +80 -0
- package/clis/twitter/unlike.test.js +75 -0
- package/clis/twitter/unretweet.js +94 -0
- package/clis/twitter/unretweet.test.js +73 -0
- package/clis/twitter/utils.js +286 -0
- package/clis/twitter/utils.test.js +169 -0
- package/dist/src/browser/ax-snapshot.d.ts +37 -0
- package/dist/src/browser/ax-snapshot.js +217 -0
- package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
- package/dist/src/browser/ax-snapshot.test.js +91 -0
- package/dist/src/browser/base-page.d.ts +51 -0
- package/dist/src/browser/base-page.js +545 -2
- package/dist/src/browser/base-page.test.js +520 -4
- package/dist/src/browser/bridge.js +47 -45
- package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
- package/dist/src/browser/cdp-click-fixture.test.js +87 -0
- package/dist/src/browser/cdp.js +5 -0
- package/dist/src/browser/cdp.test.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +3 -1
- package/dist/src/browser/find.d.ts +9 -1
- package/dist/src/browser/find.js +219 -0
- package/dist/src/browser/find.test.js +61 -1
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +13 -0
- package/dist/src/browser/page.test.js +28 -0
- package/dist/src/browser/target-errors.d.ts +3 -1
- package/dist/src/browser/target-errors.js +2 -0
- package/dist/src/browser/target-resolver.d.ts +14 -0
- package/dist/src/browser/target-resolver.js +28 -0
- package/dist/src/browser/visual-refs.d.ts +11 -0
- package/dist/src/browser/visual-refs.js +108 -0
- package/dist/src/browser.test.js +18 -0
- package/dist/src/build-manifest.d.ts +23 -0
- package/dist/src/build-manifest.js +34 -0
- package/dist/src/build-manifest.test.js +108 -1
- package/dist/src/cli.js +560 -58
- package/dist/src/cli.test.js +689 -1
- package/dist/src/commanderAdapter.js +23 -4
- package/dist/src/help.d.ts +36 -0
- package/dist/src/help.js +301 -5
- package/dist/src/types.d.ts +82 -0
- package/package.json +1 -1
- package/scripts/typed-error-lint-baseline.json +18 -18
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'twitter',
|
|
7
|
+
name: 'unretweet',
|
|
8
|
+
access: 'write',
|
|
9
|
+
description: 'Undo a retweet on a specific tweet',
|
|
10
|
+
domain: 'x.com',
|
|
11
|
+
strategy: Strategy.UI,
|
|
12
|
+
browser: true,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to unretweet' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['status', 'message'],
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
if (!page)
|
|
19
|
+
throw new CommandExecutionError('Browser session required for twitter unretweet');
|
|
20
|
+
const target = parseTweetUrl(kwargs.url);
|
|
21
|
+
await page.goto(target.url);
|
|
22
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
23
|
+
const result = await page.evaluate(`(async () => {
|
|
24
|
+
try {
|
|
25
|
+
${buildTwitterArticleScopeSource(target.id)}
|
|
26
|
+
// Poll for the tweet to render. State probes scoped to the article
|
|
27
|
+
// matching the requested status id — bare querySelector on a
|
|
28
|
+
// conversation page would silently grab the first article (e.g.
|
|
29
|
+
// the parent tweet) and unretweet the wrong one.
|
|
30
|
+
let attempts = 0;
|
|
31
|
+
let retweetBtn = null;
|
|
32
|
+
let unretweetBtn = null;
|
|
33
|
+
let targetArticle = null;
|
|
34
|
+
|
|
35
|
+
while (attempts < 20) {
|
|
36
|
+
targetArticle = findTargetArticle();
|
|
37
|
+
retweetBtn = targetArticle?.querySelector('[data-testid="retweet"]') || null;
|
|
38
|
+
unretweetBtn = targetArticle?.querySelector('[data-testid="unretweet"]') || null;
|
|
39
|
+
|
|
40
|
+
if (retweetBtn || unretweetBtn) break;
|
|
41
|
+
|
|
42
|
+
await new Promise(r => setTimeout(r, 500));
|
|
43
|
+
attempts++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Already not retweeted: idempotent success
|
|
47
|
+
if (retweetBtn) {
|
|
48
|
+
return { ok: true, message: 'Tweet is not retweeted (already removed).' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!unretweetBtn) {
|
|
52
|
+
return { ok: false, message: 'Could not find the Unretweet button on this tweet after waiting 10 seconds. Are you logged in?' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Step 1: click Unretweet button → opens menu
|
|
56
|
+
unretweetBtn.click();
|
|
57
|
+
|
|
58
|
+
// Step 2: wait for and click the confirm menu item. The confirm
|
|
59
|
+
// popover renders at the document root, not inside the article,
|
|
60
|
+
// so this lookup is intentionally document-scoped.
|
|
61
|
+
let confirmBtn = null;
|
|
62
|
+
for (let i = 0; i < 20; i++) {
|
|
63
|
+
await new Promise(r => setTimeout(r, 250));
|
|
64
|
+
confirmBtn = document.querySelector('[data-testid="unretweetConfirm"]');
|
|
65
|
+
if (confirmBtn) break;
|
|
66
|
+
}
|
|
67
|
+
if (!confirmBtn) {
|
|
68
|
+
return { ok: false, message: 'Unretweet menu opened but the confirm option did not appear.' };
|
|
69
|
+
}
|
|
70
|
+
confirmBtn.click();
|
|
71
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
72
|
+
|
|
73
|
+
// Verify success by checking if the 'retweet' button reappeared
|
|
74
|
+
const verifyArticle = findTargetArticle() || targetArticle;
|
|
75
|
+
const verifyBtn = verifyArticle?.querySelector('[data-testid="retweet"]');
|
|
76
|
+
if (verifyBtn) {
|
|
77
|
+
return { ok: true, message: 'Tweet successfully unretweeted.' };
|
|
78
|
+
} else {
|
|
79
|
+
return { ok: false, message: 'Unretweet action was initiated but UI did not update as expected.' };
|
|
80
|
+
}
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return { ok: false, message: e.toString() };
|
|
83
|
+
}
|
|
84
|
+
})()`);
|
|
85
|
+
if (result.ok) {
|
|
86
|
+
// Wait for the unretweet network request to be processed
|
|
87
|
+
await page.wait(2);
|
|
88
|
+
}
|
|
89
|
+
return [{
|
|
90
|
+
status: result.ok ? 'success' : 'failed',
|
|
91
|
+
message: result.message
|
|
92
|
+
}];
|
|
93
|
+
}
|
|
94
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import './unretweet.js';
|
|
5
|
+
import { createPageMock } from '../test-utils.js';
|
|
6
|
+
|
|
7
|
+
describe('twitter unretweet command', () => {
|
|
8
|
+
it('clicks the unretweet button then the confirm menu item and reports success', async () => {
|
|
9
|
+
const cmd = getRegistry().get('twitter/unretweet');
|
|
10
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
11
|
+
const page = createPageMock([
|
|
12
|
+
{ ok: true, message: 'Tweet successfully unretweeted.' },
|
|
13
|
+
]);
|
|
14
|
+
const result = await cmd.func(page, {
|
|
15
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
16
|
+
});
|
|
17
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com/alice/status/2040254679301718161');
|
|
18
|
+
expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' });
|
|
19
|
+
expect(page.wait).toHaveBeenNthCalledWith(2, 2);
|
|
20
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
21
|
+
// Two-step UI flow must be present:
|
|
22
|
+
// 1) click the unretweet button
|
|
23
|
+
// 2) wait for and click the confirm menu item (data-testid="unretweetConfirm")
|
|
24
|
+
expect(script).toContain('unretweetBtn.click()');
|
|
25
|
+
expect(script).toContain("document.querySelector('[data-testid=\"unretweetConfirm\"]')");
|
|
26
|
+
expect(script).toContain('confirmBtn.click()');
|
|
27
|
+
// Article scoping comes from the shared helper (buildTwitterArticleScopeSource):
|
|
28
|
+
// emits __twHasLinkToTarget + __twGetStatusIdFromHref + the anchored
|
|
29
|
+
// tweet-path regex. JSDOM-level coverage lives in shared.test.js.
|
|
30
|
+
expect(script).toContain('__twHasLinkToTarget');
|
|
31
|
+
expect(script).toContain('__twGetStatusIdFromHref');
|
|
32
|
+
expect(script).toContain("document.querySelectorAll('article')");
|
|
33
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unretweet\"]')");
|
|
34
|
+
// Idempotency probe: when already not retweeted ([data-testid="retweet"] present),
|
|
35
|
+
// the script returns ok:true with an "already removed" message.
|
|
36
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"retweet\"]')");
|
|
37
|
+
expect(result).toEqual([
|
|
38
|
+
{ status: 'success', message: 'Tweet successfully unretweeted.' },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns a failed row when the confirm menu item never appears', async () => {
|
|
43
|
+
const cmd = getRegistry().get('twitter/unretweet');
|
|
44
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
45
|
+
const page = createPageMock([
|
|
46
|
+
{ ok: false, message: 'Unretweet menu opened but the confirm option did not appear.' },
|
|
47
|
+
]);
|
|
48
|
+
const result = await cmd.func(page, {
|
|
49
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
50
|
+
});
|
|
51
|
+
expect(result).toEqual([
|
|
52
|
+
{ status: 'failed', message: 'Unretweet menu opened but the confirm option did not appear.' },
|
|
53
|
+
]);
|
|
54
|
+
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
58
|
+
const cmd = getRegistry().get('twitter/unretweet');
|
|
59
|
+
await expect(cmd.func(undefined, {
|
|
60
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
61
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects invalid tweet URLs before navigation', async () => {
|
|
65
|
+
const cmd = getRegistry().get('twitter/unretweet');
|
|
66
|
+
const page = createPageMock([]);
|
|
67
|
+
await expect(cmd.func(page, {
|
|
68
|
+
url: 'http://x.com/alice/status/2040254679301718161',
|
|
69
|
+
})).rejects.toThrow(ArgumentError);
|
|
70
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
71
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Public read-only Twitter web bearer token used by the GraphQL endpoints we
|
|
8
|
+
* call from the page context. This is the same token the Twitter web app
|
|
9
|
+
* itself uses; centralising it here keeps the 12+ GraphQL adapters from
|
|
10
|
+
* drifting when X rotates the value.
|
|
11
|
+
*/
|
|
12
|
+
export const TWITTER_BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
13
|
+
|
|
14
|
+
/** File-input selector used by the X /compose/post route for both posts and replies. */
|
|
15
|
+
export const COMPOSER_FILE_INPUT_SELECTOR = 'input[type="file"][data-testid="fileInput"]';
|
|
16
|
+
|
|
17
|
+
/** Image formats the X composer accepts. */
|
|
18
|
+
export const SUPPORTED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
19
|
+
|
|
20
|
+
/** 20 MB hard cap. Twitter allows ~5MB images / 15MB GIFs; 20MB is a safety net. */
|
|
21
|
+
export const MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
const CONTENT_TYPE_TO_EXTENSION = {
|
|
24
|
+
'image/jpeg': '.jpg',
|
|
25
|
+
'image/jpg': '.jpg',
|
|
26
|
+
'image/png': '.png',
|
|
27
|
+
'image/gif': '.gif',
|
|
28
|
+
'image/webp': '.webp',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Validate a single image path. Throws {@link ArgumentError} on bad input
|
|
33
|
+
* (typed input failure surfaces before any browser interaction).
|
|
34
|
+
*
|
|
35
|
+
* @param {string} imagePath - Local filesystem path, may be relative.
|
|
36
|
+
* @returns {string} Absolute resolved path.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveImagePath(imagePath) {
|
|
39
|
+
const absPath = path.resolve(imagePath);
|
|
40
|
+
if (!fs.existsSync(absPath)) {
|
|
41
|
+
throw new ArgumentError(`Image file not found: ${absPath}`);
|
|
42
|
+
}
|
|
43
|
+
const ext = path.extname(absPath).toLowerCase();
|
|
44
|
+
if (!SUPPORTED_IMAGE_EXTENSIONS.has(ext)) {
|
|
45
|
+
throw new ArgumentError(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, gif, webp`);
|
|
46
|
+
}
|
|
47
|
+
const stat = fs.statSync(absPath);
|
|
48
|
+
if (stat.size > MAX_IMAGE_SIZE_BYTES) {
|
|
49
|
+
throw new ArgumentError(`Image too large: ${(stat.size / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
|
|
50
|
+
}
|
|
51
|
+
return absPath;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the file extension to use when persisting a remote image: prefer
|
|
56
|
+
* Content-Type, fall back to URL pathname.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveImageExtension(url, contentType) {
|
|
59
|
+
const normalizedContentType = (contentType || '').split(';')[0].trim().toLowerCase();
|
|
60
|
+
if (normalizedContentType && CONTENT_TYPE_TO_EXTENSION[normalizedContentType]) {
|
|
61
|
+
return CONTENT_TYPE_TO_EXTENSION[normalizedContentType];
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const pathname = new URL(url).pathname;
|
|
65
|
+
const ext = path.extname(pathname).toLowerCase();
|
|
66
|
+
if (SUPPORTED_IMAGE_EXTENSIONS.has(ext))
|
|
67
|
+
return ext;
|
|
68
|
+
} catch {
|
|
69
|
+
// Fall through to the final error below.
|
|
70
|
+
}
|
|
71
|
+
throw new ArgumentError(
|
|
72
|
+
`Unsupported remote image format "${normalizedContentType || 'unknown'}". Supported: jpg, jpeg, png, gif, webp`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Download a remote image to a per-call tmp directory. Returns the absolute
|
|
78
|
+
* path on success. Caller owns the tmp dir and must clean it up. Throws
|
|
79
|
+
* {@link ArgumentError} on bad input or download failure.
|
|
80
|
+
*
|
|
81
|
+
* @returns {Promise<{ absPath: string, cleanupDir: string }>}
|
|
82
|
+
*/
|
|
83
|
+
export async function downloadRemoteImage(imageUrl) {
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = new URL(imageUrl);
|
|
87
|
+
} catch {
|
|
88
|
+
throw new ArgumentError(`Invalid image URL: ${imageUrl}`);
|
|
89
|
+
}
|
|
90
|
+
if (!/^https?:$/.test(parsed.protocol)) {
|
|
91
|
+
throw new ArgumentError(`Unsupported image URL protocol: ${parsed.protocol}`);
|
|
92
|
+
}
|
|
93
|
+
const response = await fetch(imageUrl);
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw new ArgumentError(`Image download failed: HTTP ${response.status}`);
|
|
96
|
+
}
|
|
97
|
+
const contentLength = Number(response.headers.get('content-length') || '0');
|
|
98
|
+
if (contentLength > MAX_IMAGE_SIZE_BYTES) {
|
|
99
|
+
throw new ArgumentError(`Image too large: ${(contentLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
|
|
100
|
+
}
|
|
101
|
+
const ext = resolveImageExtension(imageUrl, response.headers.get('content-type'));
|
|
102
|
+
const cleanupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-'));
|
|
103
|
+
const absPath = path.join(cleanupDir, `image${ext}`);
|
|
104
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
105
|
+
if (buffer.byteLength > MAX_IMAGE_SIZE_BYTES) {
|
|
106
|
+
fs.rmSync(cleanupDir, { recursive: true, force: true });
|
|
107
|
+
throw new ArgumentError(`Image too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
|
|
108
|
+
}
|
|
109
|
+
fs.writeFileSync(absPath, buffer);
|
|
110
|
+
return { absPath, cleanupDir };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Attach a single image to the current /compose/post composer. Tries the
|
|
115
|
+
* native CDP file-input bridge first; falls back to a base64 DataTransfer
|
|
116
|
+
* shim if the bridge is missing or rejects with "Unknown action" /
|
|
117
|
+
* "not supported". Throws on hard failures.
|
|
118
|
+
*
|
|
119
|
+
* After upload it polls the DOM briefly to confirm the preview thumbnail
|
|
120
|
+
* actually rendered — without this, a 200 from setFileInput could mask a
|
|
121
|
+
* silent-no-attachment post.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} page - OpenCLI page handle.
|
|
124
|
+
* @param {string} absImagePath - Already-validated absolute path.
|
|
125
|
+
* @param {string} [fileInputSelector] - Override (post.js historically used
|
|
126
|
+
* the same selector; default matches the X composer route).
|
|
127
|
+
*/
|
|
128
|
+
export async function attachComposerImage(page, absImagePath, fileInputSelector = COMPOSER_FILE_INPUT_SELECTOR) {
|
|
129
|
+
let uploaded = false;
|
|
130
|
+
if (page.setFileInput) {
|
|
131
|
+
try {
|
|
132
|
+
await page.setFileInput([absImagePath], fileInputSelector);
|
|
133
|
+
uploaded = true;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
136
|
+
if (!msg.includes('Unknown action') && !msg.includes('not supported')) {
|
|
137
|
+
throw new Error(`Image upload failed: ${msg}`);
|
|
138
|
+
}
|
|
139
|
+
// setFileInput not supported by extension — fall through to base64 fallback.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!uploaded) {
|
|
143
|
+
const ext = path.extname(absImagePath).toLowerCase();
|
|
144
|
+
const mimeType = ext === '.png'
|
|
145
|
+
? 'image/png'
|
|
146
|
+
: ext === '.gif'
|
|
147
|
+
? 'image/gif'
|
|
148
|
+
: ext === '.webp'
|
|
149
|
+
? 'image/webp'
|
|
150
|
+
: 'image/jpeg';
|
|
151
|
+
const base64 = fs.readFileSync(absImagePath).toString('base64');
|
|
152
|
+
if (base64.length > 500_000) {
|
|
153
|
+
console.warn(`[warn] Image base64 payload is ${(base64.length / 1024 / 1024).toFixed(1)}MB. ` +
|
|
154
|
+
'This may fail with the browser bridge. Update the extension to v1.6+ for CDP-based upload, ' +
|
|
155
|
+
'or compress the image before attaching.');
|
|
156
|
+
}
|
|
157
|
+
const upload = await page.evaluate(`
|
|
158
|
+
(() => {
|
|
159
|
+
const input = document.querySelector(${JSON.stringify(fileInputSelector)});
|
|
160
|
+
if (!input) return { ok: false, error: 'No file input found on page' };
|
|
161
|
+
|
|
162
|
+
const binary = atob(${JSON.stringify(base64)});
|
|
163
|
+
const bytes = new Uint8Array(binary.length);
|
|
164
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
165
|
+
|
|
166
|
+
const dt = new DataTransfer();
|
|
167
|
+
const blob = new Blob([bytes], { type: ${JSON.stringify(mimeType)} });
|
|
168
|
+
dt.items.add(new File([blob], ${JSON.stringify(path.basename(absImagePath))}, { type: ${JSON.stringify(mimeType)} }));
|
|
169
|
+
|
|
170
|
+
Object.defineProperty(input, 'files', { value: dt.files, writable: false });
|
|
171
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
172
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
173
|
+
return { ok: true };
|
|
174
|
+
})()
|
|
175
|
+
`);
|
|
176
|
+
if (!upload?.ok) {
|
|
177
|
+
throw new Error(`Image upload failed: ${upload?.error ?? 'unknown error'}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
await page.wait(2);
|
|
181
|
+
const uploadState = await page.evaluate(`
|
|
182
|
+
(() => {
|
|
183
|
+
const previewCount = document.querySelectorAll(
|
|
184
|
+
'[data-testid="attachments"] img, [data-testid="attachments"] video, [data-testid="tweetPhoto"]'
|
|
185
|
+
).length;
|
|
186
|
+
const hasMedia = previewCount > 0
|
|
187
|
+
|| !!document.querySelector('[data-testid="attachments"]')
|
|
188
|
+
|| !!Array.from(document.querySelectorAll('button,[role="button"]')).find((el) =>
|
|
189
|
+
/remove media|remove image|remove/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || ''))
|
|
190
|
+
);
|
|
191
|
+
return { ok: hasMedia, previewCount };
|
|
192
|
+
})()
|
|
193
|
+
`);
|
|
194
|
+
if (!uploadState?.ok) {
|
|
195
|
+
throw new Error('Image upload failed: preview did not appear.');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Engagement scoring (P3) ────────────────────────────────────────────
|
|
200
|
+
//
|
|
201
|
+
// Used by tweet-shaped read commands (search / timeline / likes / bookmarks /
|
|
202
|
+
// list-tweets / tweets / thread). Lets callers ask for the top-N tweets by
|
|
203
|
+
// weighted engagement instead of chronological order, so an agent skimming a
|
|
204
|
+
// noisy timeline can surface the actually-interesting tweets first.
|
|
205
|
+
//
|
|
206
|
+
// The weights bias toward "active engagement": bookmarks > retweets > replies
|
|
207
|
+
// > likes > views. Views are log-dampened because they often dwarf all other
|
|
208
|
+
// signals by 2–4 orders of magnitude on viral tweets and would otherwise
|
|
209
|
+
// drown out the active signals.
|
|
210
|
+
//
|
|
211
|
+
// Pure synchronous — exported via __test__ for unit coverage. Missing fields
|
|
212
|
+
// (some adapters don't surface views/replies/bookmarks) coerce to 0 so the
|
|
213
|
+
// formula stays well-defined across every read command's row shape.
|
|
214
|
+
|
|
215
|
+
const ENGAGEMENT_WEIGHTS = Object.freeze({
|
|
216
|
+
likes: 1,
|
|
217
|
+
retweets: 3,
|
|
218
|
+
replies: 2,
|
|
219
|
+
bookmarks: 5,
|
|
220
|
+
viewsLog: 0.5,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Compute the weighted engagement score for a tweet-shaped row.
|
|
225
|
+
*
|
|
226
|
+
* Formula: likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5
|
|
227
|
+
*
|
|
228
|
+
* - String fields (e.g. views: '12345') are coerced via Number(); non-numeric
|
|
229
|
+
* strings become 0 instead of NaN-poisoning the score.
|
|
230
|
+
* - log10(views+1) so views=0 maps to 0 (not -Infinity).
|
|
231
|
+
* - Missing fields default to 0 — search returns no `replies`/`bookmarks`,
|
|
232
|
+
* bookmarks returns no `views`/`replies`, etc.
|
|
233
|
+
*
|
|
234
|
+
* @param {Record<string, unknown>} row
|
|
235
|
+
* @returns {number} Score, rounded to 2 decimals for stable test fixtures.
|
|
236
|
+
*/
|
|
237
|
+
export function computeEngagementScore(row) {
|
|
238
|
+
if (!row || typeof row !== 'object') return 0;
|
|
239
|
+
const num = (key) => {
|
|
240
|
+
const raw = row[key];
|
|
241
|
+
if (raw === undefined || raw === null) return 0;
|
|
242
|
+
const n = Number(raw);
|
|
243
|
+
return Number.isFinite(n) ? Math.max(0, n) : 0;
|
|
244
|
+
};
|
|
245
|
+
const score
|
|
246
|
+
= num('likes') * ENGAGEMENT_WEIGHTS.likes
|
|
247
|
+
+ num('retweets') * ENGAGEMENT_WEIGHTS.retweets
|
|
248
|
+
+ num('replies') * ENGAGEMENT_WEIGHTS.replies
|
|
249
|
+
+ num('bookmarks') * ENGAGEMENT_WEIGHTS.bookmarks
|
|
250
|
+
+ Math.log10(num('views') + 1) * ENGAGEMENT_WEIGHTS.viewsLog;
|
|
251
|
+
return Math.round(score * 100) / 100;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Apply --top-by-engagement post-processing. When `topN > 0` the rows are
|
|
256
|
+
* sorted DESCENDING by computeEngagementScore() and trimmed to the top N.
|
|
257
|
+
* When `topN <= 0` (the default), rows are returned unchanged so adapters
|
|
258
|
+
* that don't pass the flag stay backward compatible.
|
|
259
|
+
*
|
|
260
|
+
* Stable for ties: rows with the same score retain their original order
|
|
261
|
+
* (Array.prototype.sort is guaranteed stable in V8 since 2018).
|
|
262
|
+
*
|
|
263
|
+
* @param {Array<Record<string, unknown>>} rows
|
|
264
|
+
* @param {number} topN
|
|
265
|
+
* @returns {Array<Record<string, unknown>>}
|
|
266
|
+
*/
|
|
267
|
+
export function applyTopByEngagement(rows, topN) {
|
|
268
|
+
if (!Array.isArray(rows) || rows.length === 0) return rows;
|
|
269
|
+
const n = Number(topN);
|
|
270
|
+
if (!Number.isFinite(n) || n <= 0) return rows;
|
|
271
|
+
return rows
|
|
272
|
+
.map((row, idx) => ({ row, idx, score: computeEngagementScore(row) }))
|
|
273
|
+
.sort((a, b) => b.score - a.score || a.idx - b.idx)
|
|
274
|
+
.slice(0, Math.floor(n))
|
|
275
|
+
.map(entry => entry.row);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const __test__ = {
|
|
279
|
+
resolveImagePath,
|
|
280
|
+
resolveImageExtension,
|
|
281
|
+
downloadRemoteImage,
|
|
282
|
+
attachComposerImage,
|
|
283
|
+
computeEngagementScore,
|
|
284
|
+
applyTopByEngagement,
|
|
285
|
+
ENGAGEMENT_WEIGHTS,
|
|
286
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __test__ } from './utils.js';
|
|
3
|
+
|
|
4
|
+
const { computeEngagementScore, applyTopByEngagement, ENGAGEMENT_WEIGHTS } = __test__;
|
|
5
|
+
|
|
6
|
+
describe('computeEngagementScore', () => {
|
|
7
|
+
it('returns 0 for empty / nullish rows', () => {
|
|
8
|
+
expect(computeEngagementScore(null)).toBe(0);
|
|
9
|
+
expect(computeEngagementScore(undefined)).toBe(0);
|
|
10
|
+
expect(computeEngagementScore({})).toBe(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('weights likes ×1', () => {
|
|
14
|
+
expect(computeEngagementScore({ likes: 10 })).toBe(10);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('weights retweets ×3', () => {
|
|
18
|
+
expect(computeEngagementScore({ retweets: 5 })).toBe(15);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('weights replies ×2', () => {
|
|
22
|
+
expect(computeEngagementScore({ replies: 4 })).toBe(8);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('weights bookmarks ×5', () => {
|
|
26
|
+
expect(computeEngagementScore({ bookmarks: 6 })).toBe(30);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('log-dampens views (log10(v+1) × 0.5)', () => {
|
|
30
|
+
// log10(99+1) * 0.5 = 1.0
|
|
31
|
+
expect(computeEngagementScore({ views: 99 })).toBeCloseTo(1.0, 2);
|
|
32
|
+
// log10(0+1) * 0.5 = 0
|
|
33
|
+
expect(computeEngagementScore({ views: 0 })).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('coerces string-typed views (search/timeline returns views as a string)', () => {
|
|
37
|
+
// log10(9999+1) * 0.5 = 2.0
|
|
38
|
+
expect(computeEngagementScore({ views: '9999' })).toBeCloseTo(2.0, 2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('treats non-numeric strings as 0 instead of NaN-poisoning the score', () => {
|
|
42
|
+
expect(computeEngagementScore({ likes: 'abc', retweets: 5 })).toBe(15);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('clamps negative values at 0 (defensive against bogus payloads)', () => {
|
|
46
|
+
expect(computeEngagementScore({ likes: -100, retweets: 2 })).toBe(6);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('combines all signals additively', () => {
|
|
50
|
+
// likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5
|
|
51
|
+
// 10 + 30 + 8 + 25 + log10(1000)*0.5 = 73 + 1.5 = 74.5
|
|
52
|
+
const row = { likes: 10, retweets: 10, replies: 4, bookmarks: 5, views: 999 };
|
|
53
|
+
expect(computeEngagementScore(row)).toBeCloseTo(74.5, 2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('rounds to 2 decimal places for stable test fixtures', () => {
|
|
57
|
+
// log10(1+1) * 0.5 = 0.5 * log10(2) ≈ 0.150515
|
|
58
|
+
const score = computeEngagementScore({ views: 1 });
|
|
59
|
+
expect(score).toBe(0.15);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('handles real search-row shape (no replies/bookmarks columns)', () => {
|
|
63
|
+
const searchRow = {
|
|
64
|
+
id: '123',
|
|
65
|
+
author: 'alice',
|
|
66
|
+
text: 'hi',
|
|
67
|
+
likes: 100,
|
|
68
|
+
views: '9999',
|
|
69
|
+
};
|
|
70
|
+
// 100 + log10(10000)*0.5 = 100 + 2.0 = 102.0
|
|
71
|
+
expect(computeEngagementScore(searchRow)).toBeCloseTo(102.0, 2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('handles real bookmarks-row shape (no views/replies columns)', () => {
|
|
75
|
+
const bookmarkRow = {
|
|
76
|
+
id: '123',
|
|
77
|
+
author: 'alice',
|
|
78
|
+
text: 'hi',
|
|
79
|
+
likes: 50,
|
|
80
|
+
retweets: 10,
|
|
81
|
+
bookmarks: 3,
|
|
82
|
+
};
|
|
83
|
+
// 50 + 30 + 15 = 95
|
|
84
|
+
expect(computeEngagementScore(bookmarkRow)).toBe(95);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('exposes the documented weight table', () => {
|
|
88
|
+
expect(ENGAGEMENT_WEIGHTS).toEqual({
|
|
89
|
+
likes: 1,
|
|
90
|
+
retweets: 3,
|
|
91
|
+
replies: 2,
|
|
92
|
+
bookmarks: 5,
|
|
93
|
+
viewsLog: 0.5,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('applyTopByEngagement', () => {
|
|
99
|
+
const rows = [
|
|
100
|
+
{ id: 'a', likes: 10 },
|
|
101
|
+
{ id: 'b', likes: 50 },
|
|
102
|
+
{ id: 'c', likes: 30 },
|
|
103
|
+
{ id: 'd', likes: 100 },
|
|
104
|
+
{ id: 'e', likes: 5 },
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
it('returns rows unchanged when topN is 0 (default)', () => {
|
|
108
|
+
expect(applyTopByEngagement(rows, 0)).toBe(rows);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns rows unchanged when topN is negative', () => {
|
|
112
|
+
expect(applyTopByEngagement(rows, -3)).toBe(rows);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('returns rows unchanged when topN is non-numeric', () => {
|
|
116
|
+
expect(applyTopByEngagement(rows, 'foo')).toBe(rows);
|
|
117
|
+
expect(applyTopByEngagement(rows, null)).toBe(rows);
|
|
118
|
+
expect(applyTopByEngagement(rows, undefined)).toBe(rows);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('sorts descending by score and trims to top N when topN > 0', () => {
|
|
122
|
+
const result = applyTopByEngagement(rows, 3);
|
|
123
|
+
expect(result.map(r => r.id)).toEqual(['d', 'b', 'c']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns all rows when topN exceeds row count', () => {
|
|
127
|
+
const result = applyTopByEngagement(rows, 99);
|
|
128
|
+
expect(result.map(r => r.id)).toEqual(['d', 'b', 'c', 'a', 'e']);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('floors fractional topN', () => {
|
|
132
|
+
const result = applyTopByEngagement(rows, 2.9);
|
|
133
|
+
expect(result.map(r => r.id)).toEqual(['d', 'b']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('is stable for ties (preserves original order)', () => {
|
|
137
|
+
const tieRows = [
|
|
138
|
+
{ id: 'first', likes: 10 },
|
|
139
|
+
{ id: 'second', likes: 10 },
|
|
140
|
+
{ id: 'third', likes: 10 },
|
|
141
|
+
{ id: 'fourth', likes: 100 },
|
|
142
|
+
];
|
|
143
|
+
const result = applyTopByEngagement(tieRows, 4);
|
|
144
|
+
// 'fourth' first, then ties retain original order
|
|
145
|
+
expect(result.map(r => r.id)).toEqual(['fourth', 'first', 'second', 'third']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('handles empty / non-array input gracefully', () => {
|
|
149
|
+
expect(applyTopByEngagement([], 5)).toEqual([]);
|
|
150
|
+
expect(applyTopByEngagement(null, 5)).toBeNull();
|
|
151
|
+
expect(applyTopByEngagement(undefined, 5)).toBeUndefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('does not mutate the input array', () => {
|
|
155
|
+
const before = [...rows];
|
|
156
|
+
applyTopByEngagement(rows, 2);
|
|
157
|
+
expect(rows).toEqual(before);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('mixes signals correctly when ranking', () => {
|
|
161
|
+
// bookmark-heavy row should beat like-heavy row even if likes are higher
|
|
162
|
+
const mixed = [
|
|
163
|
+
{ id: 'likes-only', likes: 100 }, // score = 100
|
|
164
|
+
{ id: 'bookmark-heavy', likes: 30, bookmarks: 20 }, // score = 30 + 100 = 130
|
|
165
|
+
];
|
|
166
|
+
const result = applyTopByEngagement(mixed, 2);
|
|
167
|
+
expect(result.map(r => r.id)).toEqual(['bookmark-heavy', 'likes-only']);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AX-backed browser snapshot prototype.
|
|
3
|
+
*
|
|
4
|
+
* This is intentionally additive to the current DOM snapshot. It learns from
|
|
5
|
+
* agent-browser's accessibility-tree refs without changing default `state`
|
|
6
|
+
* output until the AX path proves itself on fixtures and real SaaS workflows.
|
|
7
|
+
*/
|
|
8
|
+
export interface BrowserRef {
|
|
9
|
+
ref: string;
|
|
10
|
+
backendNodeId?: number;
|
|
11
|
+
role: string;
|
|
12
|
+
name: string;
|
|
13
|
+
nth?: number;
|
|
14
|
+
frame?: {
|
|
15
|
+
frameId?: string;
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
url?: string;
|
|
18
|
+
targetUrl?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export interface AxSnapshotTree {
|
|
22
|
+
tree: unknown;
|
|
23
|
+
frame?: BrowserRef['frame'];
|
|
24
|
+
}
|
|
25
|
+
export interface AxSnapshotBuildResult {
|
|
26
|
+
text: string;
|
|
27
|
+
refs: Map<string, BrowserRef>;
|
|
28
|
+
}
|
|
29
|
+
export declare function buildAxSnapshot(axTree: unknown, opts?: {
|
|
30
|
+
maxDepth?: number;
|
|
31
|
+
interactiveOnly?: boolean;
|
|
32
|
+
}): AxSnapshotBuildResult;
|
|
33
|
+
export declare function buildAxSnapshotFromTrees(trees: AxSnapshotTree[], opts?: {
|
|
34
|
+
maxDepth?: number;
|
|
35
|
+
interactiveOnly?: boolean;
|
|
36
|
+
}): AxSnapshotBuildResult;
|
|
37
|
+
export declare function findAxRefReplacement(axTree: unknown, ref: BrowserRef): BrowserRef | null;
|