@jackwener/opencli 1.7.14 → 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 +215 -45
- 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 +60 -32
- package/clis/twitter/quote.test.js +96 -8
- package/clis/twitter/reply.js +24 -178
- package/clis/twitter/reply.test.js +29 -11
- package/clis/twitter/retweet.js +9 -14
- package/clis/twitter/retweet.test.js +5 -1
- package/clis/twitter/search.js +175 -23
- package/clis/twitter/search.test.js +266 -1
- package/clis/twitter/shared.js +43 -0
- package/clis/twitter/shared.test.js +107 -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 +6 -13
- package/clis/twitter/unlike.test.js +5 -2
- package/clis/twitter/unretweet.js +9 -14
- package/clis/twitter/unretweet.test.js +5 -1
- 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/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/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 +598 -0
- package/dist/src/help.d.ts +32 -0
- package/dist/src/help.js +145 -0
- 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,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;
|