mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
4475d4efe3
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)
287 lines
12 KiB
JavaScript
287 lines
12 KiB
JavaScript
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 2–4 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,
|
||
};
|