Files
wehub-resource-sync 9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

324 lines
14 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 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 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,
isRecoverableFileInputError,
waitForComposerMediaReady,
computeEngagementScore,
applyTopByEngagement,
ENGAGEMENT_WEIGHTS,
};