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 MEDIA_UPLOAD_POLL_MS = 500; const MEDIA_UPLOAD_TIMEOUT_MS = 30_000; 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 (!isRecoverableFileInputError(msg)) { 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)} })); let assigned = false; try { Object.defineProperty(input, 'files', { value: dt.files, writable: false, configurable: true }); assigned = input.files && input.files.length > 0; } catch(e) { // files property not redefinable — use nativeInputValueSetter trick try { const nativeInputFileSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'files'); if (nativeInputFileSetter && nativeInputFileSetter.set) { nativeInputFileSetter.set.call(input, dt.files); assigned = input.files && input.files.length > 0; } } catch(e2) { /* ignore */ } } if (!assigned) return { ok: false, error: 'Could not assign files to input' }; 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'}`); } } const uploadState = await waitForComposerMediaReady(page, 1); if (!uploadState?.ok) { throw new Error(uploadState?.message ?? 'Image upload failed: preview did not appear.'); } } export function isRecoverableFileInputError(error) { const msg = error instanceof Error ? error.message : String(error); return /unknown action|not supported|not[-\s]?allowed|notallowederror/i.test(msg); } export async function waitForComposerMediaReady(page, expectedCount = 1) { const iterations = Math.ceil(MEDIA_UPLOAD_TIMEOUT_MS / MEDIA_UPLOAD_POLL_MS); return page.evaluate(` (async () => { const expected = ${JSON.stringify(expectedCount)}; for (let i = 0; i < ${JSON.stringify(iterations)}; i++) { await new Promise(r => setTimeout(r, ${JSON.stringify(MEDIA_UPLOAD_POLL_MS)})); const previewCount = document.querySelectorAll( '[data-testid="attachments"] img, [data-testid="attachments"] video, [data-testid="attachments"] [role="group"], [data-testid="tweetPhoto"]' ).length; const blobCount = document.querySelectorAll('img[src^="blob:"], video[src^="blob:"]').length; const removeButtonCount = Array.from(document.querySelectorAll('button,[role="button"]')).filter((el) => /remove media|remove image|remove|编辑/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || '')) ).length; const explicitPreviewCount = Math.max( previewCount, blobCount, removeButtonCount, document.querySelectorAll('[data-testid="media-upload-preview"], [data-testid="card.layoutLarge.media"]').length ); if (explicitPreviewCount >= expected) return { ok: true, previewCount: explicitPreviewCount }; } return { ok: false, message: 'Image upload timed out (${MEDIA_UPLOAD_TIMEOUT_MS / 1000}s).' }; })() `); } // ── 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} 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>} rows * @param {number} topN * @returns {Array>} */ 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, isRecoverableFileInputError, waitForComposerMediaReady, computeEngagementScore, applyTopByEngagement, ENGAGEMENT_WEIGHTS, };