Files
jackwener__opencli/clis/twitter/utils.js
jakevin 4475d4efe3 feat(twitter): P1+P2+P3+P4+P5 — search filters, bookmark folders, engagement scoring, sibling dedupe + help docs (#1406)
Round 21 follow-up to #1400 (P0 write-action symmetry, merged `644d4517`). 5 features + help docs unified into one PR per WAWQAQ "全部合成一个 PR" directive.

## Scope

- **P1** (`cf10c098`): `twitter search` `--from / --has / --exclude / --product` filters, mapping to X `from:` / `filter:` / `-filter:` / `f=` operators; legacy `--filter top|live` preserved (--product win on conflict)
- **P2** (`a484a69a`): new `twitter bookmark-folders` + `bookmark-folder <id>`; X Premium GraphQL `bookmarkFoldersSlice` + `BookmarkFolderTimeline`; queryId 三层 fallback (placeholder.json → client-web bundle → pinned constants)
- **P3** (`f209f914`): `--top-by-engagement N` to 7 tweet-shaped read commands (search/timeline/likes/bookmarks/list-tweets/tweets/thread); single helper in `utils.js`; formula `likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5`; **N=0 reference equality no-op** → existing 157 twitter tests 0 churn
- **P4** (`89283fa0`): `TWITTER_BEARER_TOKEN` + composer image helpers extracted to `utils.js` (12 GraphQL adapter dedup); reply hardening; quote adds `--image`
- **P5** (`a3d10a48`): sibling article-scope helper extracted to `shared.js` (9 write commands reuse, dedup with #1400 P0 invariant)
- **docs** (`2a358d80`): help-doc precision (positional-omitted defaults + download/bookmarks/notifications/timeline/lists description thicken; concurrent #1401/#1403 wording preserved)

47 files / +2594/-470. Tests **96 → 216 (+120)**, manifest 798 → 801 (+3), typed-error-lint 190 → 189 (resolved 1 grandfathered sentinel).

## Iteration history (3 review fix commits on top of 6 author commits)

- `7f93779b` — codex-mini1 lead fix1: 3 blocker bundle (P5 host invariant + P2 safe-id + sentinel removal + P1 fallback fail-fast)
- `2a29ecc6` — codex-mini1 lead fix2: P3 help formula consistency (doc/help text matches actual `log10(views+1)×0.5`)
- `df4dcd76` — codex-mini1 lead fix3 (F-P-1 aux catch): P2 `bookmark-folder --limit` upfront validation (`Number(kwargs.limit ?? 20)` + reject non-positive/non-integer + regression `0/negative/fractional/NaN` + `page.goto` zero-call assert)

## 4 progressive blockers caught (codex-mini1 lead 3 rounds + F-P-1 aux 1 round)

1. **P5 host invariant gap** (lead): article-scope helper preserved exact `/status/<id>` path but ignored link host → off-domain `https://evil.com/alice/status/<target>` would satisfy `__twHasLinkToTarget`. Fixed: `https` + X/Twitter host or subdomain + exact `/status/<id>` or `/i/status/<id>` path; query/hash allowed; off-domain/host-suffix/non-https/path-suffix/substring-id rejected; JSDOM positive + 5 negative anchors.

2. **P2 listing→detail round-trip + sentinel** (lead): `bookmark-folders` accepted opaque IDs but `bookmark-folder <id>` only accepted numeric → round-trip broken; new `author: 'unknown'` sentinel created fabricated author URL. Fixed: `[A-Za-z0-9_-]+` opaque safe-id (rejects `/`, `?`, `%`, spaces) + `resolveTwitterQueryId()` sanitization for queryId resolution; sentinel removed → empty author + canonical `/i/status/<id>` URL.

3. **P1 fallback silent tab miss** (lead): pushState fail → fallback typing into search box, `clickProductTabIfNeeded()` silent return on tab not found → user `--product photos` silently degraded to Top results. Fixed: throw `CommandExecutionError` when requested `--product` tab cannot be selected + invalid `--from` / `--limit` upfront pre-nav reject + double-direction tests.

4. **P2 limit silent normalize** (aux): `const limit = kwargs.limit || 20` → `--limit 0` silent → 20; negative/non-integer pre-IO unchecked. Fixed: `Number(kwargs.limit ?? 20)` + require positive integer before `page.goto` + regression covers `0/negative/fractional/NaN` + `page.goto` zero-call.

## Cultural sediment (Round 21 audit checklist 7 rules / 6 dimensions)

This PR **immediately validated 4 of 7 rules** in review pipeline:
- (b) silent-clamp class — P1 fallback silent tab miss (silent semantic-downgrade) + P2 `|| 20` silent normalize
- (e) ID exact-not-substring — P5 host invariant (was only path-exact, not host-exact)
- (f) grandfathered-not-exempt — P5 helper-refactor boundary lost host invariant + P2 new adapter inherited grandfathered `'unknown'` sentinel
- (g) fallback-must-have-success-criterion — P1 fallback path missing post-condition assertion

7 rules / 6 dimensions:
- (a) cross-grep sibling URL pattern — structural
- (b) silent-clamp class — failure mode (input)
- (c) broad querySelector → article-scoping — scope
- (d) missing-validation early reject — boundary
- (e) ID exact-not-substring — identity
- (f) grandfathered-not-exempt (corollary: applies to new file + new helper-refactor boundary; not original-file line-edit) — time-axis
- (g) fallback-must-have-success-criterion (sub-rule g': fallback unit test must include post-condition assertion, not just "doesn't throw") — failure mode (output)

**Cross-PR validation 4-chain on meta-anchor "Structural exactness for identity matching"**:
- #1391 URL layer (`isFacebookAuthRedirectPath`: top-level anchor + `\.php` + `(/|$)` segment edge)
- #1392 URL parser layer (`parseGrokSessionId`: bare UUID exact / URL host-exact-or-subdomain + path-exact)
- #1400 DOM layer (article-scoping: status-id `/\/status\/${id}(?:\/|$)/` regex / segment-array exact)
- #1406 P5 helper-refactor boundary (full URL invariant in shared helper: host+path re-anchored after extraction)
- Common invariant: boundary-lock structural shape; **fuzzy match is silent-failure 温床**; lesson lifecycle = surface-shift not add-and-forget.

**Audit framework self-discipline**: each rule must have grep-able detection signal, otherwise rule degenerates to mantra. Framework is "7 rules + sub-instance pattern in new surface", not frozen 7 rules.

**Round 17 race-mitigation 第 9 连续 race-free execution**: standard alternation cadence (#1400 A 组 → #1406 B 组), lead final + aux final + `@pr-monitor squash?` trigger, pr-monitor proactive ack + serial squash, lead silent on closeout.

## Validation gates (final head `df4dcd76`)

Local: Twitter adapter tests `25 files / 216 tests`, focused P1/P2/P3/P5 tests `99/99`, `node --check` touched runtime, `npx tsc --noEmit`, `npm run build`, manifest 801 entries, typed-error-lint `189/189`, silent-column-drop `103/103`, doc-coverage `140/140`, docs:build clean, listing-id advisory `13` unchanged (wikipedia/trending residual non-Twitter), `git diff --check` clean.

GitHub: build×3 (ubuntu/macos/windows) SUCCESS, unit-test shards SUCCESS, bun-test SUCCESS, adapter-test SUCCESS, audit SUCCESS, doc-coverage SUCCESS, docs-build SUCCESS, smoke-test skipped, PR `CLEAN/MERGEABLE`.

Reviewers:
- Lead: @codex-mini1 (3 fix rounds, all caught proactively + amend P3 help consistency)
- Aux: @First-principles-1 (better-solution triangulation on P2 queryId 三层 fallback + P5 invariant + P3 N=0 reference no-op + caught P2 limit silent normalize)
- Author: @opencli-user (5-feature scope + 7-rule sediment co-author + corollary contributor)
2026-05-08 02:36:39 +08:00

287 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { ArgumentError } from '@jackwener/opencli/errors';
/**
* Public read-only Twitter web bearer token used by the GraphQL endpoints we
* call from the page context. This is the same token the Twitter web app
* itself uses; centralising it here keeps the 12+ GraphQL adapters from
* drifting when X rotates the value.
*/
export const TWITTER_BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
/** File-input selector used by the X /compose/post route for both posts and replies. */
export const COMPOSER_FILE_INPUT_SELECTOR = 'input[type="file"][data-testid="fileInput"]';
/** Image formats the X composer accepts. */
export const SUPPORTED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
/** 20 MB hard cap. Twitter allows ~5MB images / 15MB GIFs; 20MB is a safety net. */
export const MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024;
const CONTENT_TYPE_TO_EXTENSION = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
};
/**
* Validate a single image path. Throws {@link ArgumentError} on bad input
* (typed input failure surfaces before any browser interaction).
*
* @param {string} imagePath - Local filesystem path, may be relative.
* @returns {string} Absolute resolved path.
*/
export function resolveImagePath(imagePath) {
const absPath = path.resolve(imagePath);
if (!fs.existsSync(absPath)) {
throw new ArgumentError(`Image file not found: ${absPath}`);
}
const ext = path.extname(absPath).toLowerCase();
if (!SUPPORTED_IMAGE_EXTENSIONS.has(ext)) {
throw new ArgumentError(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, gif, webp`);
}
const stat = fs.statSync(absPath);
if (stat.size > MAX_IMAGE_SIZE_BYTES) {
throw new ArgumentError(`Image too large: ${(stat.size / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
}
return absPath;
}
/**
* Resolve the file extension to use when persisting a remote image: prefer
* Content-Type, fall back to URL pathname.
*/
export function resolveImageExtension(url, contentType) {
const normalizedContentType = (contentType || '').split(';')[0].trim().toLowerCase();
if (normalizedContentType && CONTENT_TYPE_TO_EXTENSION[normalizedContentType]) {
return CONTENT_TYPE_TO_EXTENSION[normalizedContentType];
}
try {
const pathname = new URL(url).pathname;
const ext = path.extname(pathname).toLowerCase();
if (SUPPORTED_IMAGE_EXTENSIONS.has(ext))
return ext;
} catch {
// Fall through to the final error below.
}
throw new ArgumentError(
`Unsupported remote image format "${normalizedContentType || 'unknown'}". Supported: jpg, jpeg, png, gif, webp`,
);
}
/**
* Download a remote image to a per-call tmp directory. Returns the absolute
* path on success. Caller owns the tmp dir and must clean it up. Throws
* {@link ArgumentError} on bad input or download failure.
*
* @returns {Promise<{ absPath: string, cleanupDir: string }>}
*/
export async function downloadRemoteImage(imageUrl) {
let parsed;
try {
parsed = new URL(imageUrl);
} catch {
throw new ArgumentError(`Invalid image URL: ${imageUrl}`);
}
if (!/^https?:$/.test(parsed.protocol)) {
throw new ArgumentError(`Unsupported image URL protocol: ${parsed.protocol}`);
}
const response = await fetch(imageUrl);
if (!response.ok) {
throw new ArgumentError(`Image download failed: HTTP ${response.status}`);
}
const contentLength = Number(response.headers.get('content-length') || '0');
if (contentLength > MAX_IMAGE_SIZE_BYTES) {
throw new ArgumentError(`Image too large: ${(contentLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
}
const ext = resolveImageExtension(imageUrl, response.headers.get('content-type'));
const cleanupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-'));
const absPath = path.join(cleanupDir, `image${ext}`);
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > MAX_IMAGE_SIZE_BYTES) {
fs.rmSync(cleanupDir, { recursive: true, force: true });
throw new ArgumentError(`Image too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
}
fs.writeFileSync(absPath, buffer);
return { absPath, cleanupDir };
}
/**
* Attach a single image to the current /compose/post composer. Tries the
* native CDP file-input bridge first; falls back to a base64 DataTransfer
* shim if the bridge is missing or rejects with "Unknown action" /
* "not supported". Throws on hard failures.
*
* After upload it polls the DOM briefly to confirm the preview thumbnail
* actually rendered — without this, a 200 from setFileInput could mask a
* silent-no-attachment post.
*
* @param {object} page - OpenCLI page handle.
* @param {string} absImagePath - Already-validated absolute path.
* @param {string} [fileInputSelector] - Override (post.js historically used
* the same selector; default matches the X composer route).
*/
export async function attachComposerImage(page, absImagePath, fileInputSelector = COMPOSER_FILE_INPUT_SELECTOR) {
let uploaded = false;
if (page.setFileInput) {
try {
await page.setFileInput([absImagePath], fileInputSelector);
uploaded = true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('Unknown action') && !msg.includes('not supported')) {
throw new Error(`Image upload failed: ${msg}`);
}
// setFileInput not supported by extension — fall through to base64 fallback.
}
}
if (!uploaded) {
const ext = path.extname(absImagePath).toLowerCase();
const mimeType = ext === '.png'
? 'image/png'
: ext === '.gif'
? 'image/gif'
: ext === '.webp'
? 'image/webp'
: 'image/jpeg';
const base64 = fs.readFileSync(absImagePath).toString('base64');
if (base64.length > 500_000) {
console.warn(`[warn] Image base64 payload is ${(base64.length / 1024 / 1024).toFixed(1)}MB. ` +
'This may fail with the browser bridge. Update the extension to v1.6+ for CDP-based upload, ' +
'or compress the image before attaching.');
}
const upload = await page.evaluate(`
(() => {
const input = document.querySelector(${JSON.stringify(fileInputSelector)});
if (!input) return { ok: false, error: 'No file input found on page' };
const binary = atob(${JSON.stringify(base64)});
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const dt = new DataTransfer();
const blob = new Blob([bytes], { type: ${JSON.stringify(mimeType)} });
dt.items.add(new File([blob], ${JSON.stringify(path.basename(absImagePath))}, { type: ${JSON.stringify(mimeType)} }));
Object.defineProperty(input, 'files', { value: dt.files, writable: false });
input.dispatchEvent(new Event('change', { bubbles: true }));
input.dispatchEvent(new Event('input', { bubbles: true }));
return { ok: true };
})()
`);
if (!upload?.ok) {
throw new Error(`Image upload failed: ${upload?.error ?? 'unknown error'}`);
}
}
await page.wait(2);
const uploadState = await page.evaluate(`
(() => {
const previewCount = document.querySelectorAll(
'[data-testid="attachments"] img, [data-testid="attachments"] video, [data-testid="tweetPhoto"]'
).length;
const hasMedia = previewCount > 0
|| !!document.querySelector('[data-testid="attachments"]')
|| !!Array.from(document.querySelectorAll('button,[role="button"]')).find((el) =>
/remove media|remove image|remove/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || ''))
);
return { ok: hasMedia, previewCount };
})()
`);
if (!uploadState?.ok) {
throw new Error('Image upload failed: preview did not appear.');
}
}
// ── Engagement scoring (P3) ────────────────────────────────────────────
//
// Used by tweet-shaped read commands (search / timeline / likes / bookmarks /
// list-tweets / tweets / thread). Lets callers ask for the top-N tweets by
// weighted engagement instead of chronological order, so an agent skimming a
// noisy timeline can surface the actually-interesting tweets first.
//
// The weights bias toward "active engagement": bookmarks > retweets > replies
// > likes > views. Views are log-dampened because they often dwarf all other
// signals by 24 orders of magnitude on viral tweets and would otherwise
// drown out the active signals.
//
// Pure synchronous — exported via __test__ for unit coverage. Missing fields
// (some adapters don't surface views/replies/bookmarks) coerce to 0 so the
// formula stays well-defined across every read command's row shape.
const ENGAGEMENT_WEIGHTS = Object.freeze({
likes: 1,
retweets: 3,
replies: 2,
bookmarks: 5,
viewsLog: 0.5,
});
/**
* Compute the weighted engagement score for a tweet-shaped row.
*
* Formula: likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5
*
* - String fields (e.g. views: '12345') are coerced via Number(); non-numeric
* strings become 0 instead of NaN-poisoning the score.
* - log10(views+1) so views=0 maps to 0 (not -Infinity).
* - Missing fields default to 0 — search returns no `replies`/`bookmarks`,
* bookmarks returns no `views`/`replies`, etc.
*
* @param {Record<string, unknown>} row
* @returns {number} Score, rounded to 2 decimals for stable test fixtures.
*/
export function computeEngagementScore(row) {
if (!row || typeof row !== 'object') return 0;
const num = (key) => {
const raw = row[key];
if (raw === undefined || raw === null) return 0;
const n = Number(raw);
return Number.isFinite(n) ? Math.max(0, n) : 0;
};
const score
= num('likes') * ENGAGEMENT_WEIGHTS.likes
+ num('retweets') * ENGAGEMENT_WEIGHTS.retweets
+ num('replies') * ENGAGEMENT_WEIGHTS.replies
+ num('bookmarks') * ENGAGEMENT_WEIGHTS.bookmarks
+ Math.log10(num('views') + 1) * ENGAGEMENT_WEIGHTS.viewsLog;
return Math.round(score * 100) / 100;
}
/**
* Apply --top-by-engagement post-processing. When `topN > 0` the rows are
* sorted DESCENDING by computeEngagementScore() and trimmed to the top N.
* When `topN <= 0` (the default), rows are returned unchanged so adapters
* that don't pass the flag stay backward compatible.
*
* Stable for ties: rows with the same score retain their original order
* (Array.prototype.sort is guaranteed stable in V8 since 2018).
*
* @param {Array<Record<string, unknown>>} rows
* @param {number} topN
* @returns {Array<Record<string, unknown>>}
*/
export function applyTopByEngagement(rows, topN) {
if (!Array.isArray(rows) || rows.length === 0) return rows;
const n = Number(topN);
if (!Number.isFinite(n) || n <= 0) return rows;
return rows
.map((row, idx) => ({ row, idx, score: computeEngagementScore(row) }))
.sort((a, b) => b.score - a.score || a.idx - b.idx)
.slice(0, Math.floor(n))
.map(entry => entry.row);
}
export const __test__ = {
resolveImagePath,
resolveImageExtension,
downloadRemoteImage,
attachComposerImage,
computeEngagementScore,
applyTopByEngagement,
ENGAGEMENT_WEIGHTS,
};