31313c69ac
Vendor Bird's Twitter GraphQL search client directly into /last30days, eliminating the dependency on `npm install -g @steipete/bird`. X search now works out of the box with just Node.js 22+ and browser cookies. - Add vendored bird-search.mjs wrapper (search-only subset of Bird v0.8.0) - Vendor @steipete/sweet-cookie for browser cookie extraction - Update bird_x.py to call vendored Node.js module instead of `bird` binary - Update README.md and SKILL.md for v2.1 (remove Bird CLI install steps) - Include Bird's MIT LICENSE in vendor directory The fallback chain is: vendored search -> xAI API key -> web-only mode. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
264 lines
9.2 KiB
JavaScript
264 lines
9.2 KiB
JavaScript
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
import { homedir } from 'node:os';
|
|
import path from 'node:path';
|
|
const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json';
|
|
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
const DISCOVERY_PAGES = [
|
|
'https://x.com/?lang=en',
|
|
'https://x.com/explore',
|
|
'https://x.com/notifications',
|
|
'https://x.com/settings/profile',
|
|
];
|
|
const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
|
|
const QUERY_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
const OPERATION_PATTERNS = [
|
|
{
|
|
regex: /e\.exports=\{queryId\s*:\s*["']([^"']+)["']\s*,\s*operationName\s*:\s*["']([^"']+)["']/gs,
|
|
operationGroup: 2,
|
|
queryIdGroup: 1,
|
|
},
|
|
{
|
|
regex: /e\.exports=\{operationName\s*:\s*["']([^"']+)["']\s*,\s*queryId\s*:\s*["']([^"']+)["']/gs,
|
|
operationGroup: 1,
|
|
queryIdGroup: 2,
|
|
},
|
|
{
|
|
regex: /operationName\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)queryId\s*[:=]\s*["']([^"']+)["']/gs,
|
|
operationGroup: 1,
|
|
queryIdGroup: 3,
|
|
},
|
|
{
|
|
regex: /queryId\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)operationName\s*[:=]\s*["']([^"']+)["']/gs,
|
|
operationGroup: 3,
|
|
queryIdGroup: 1,
|
|
},
|
|
];
|
|
const HEADERS = {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36',
|
|
Accept: 'text/html,application/json;q=0.9,*/*;q=0.8',
|
|
'Accept-Language': 'en-US,en;q=0.9',
|
|
};
|
|
async function fetchText(fetchImpl, url) {
|
|
const response = await fetchImpl(url, { headers: HEADERS });
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => '');
|
|
throw new Error(`HTTP ${response.status} for ${url}: ${body.slice(0, 120)}`);
|
|
}
|
|
return response.text();
|
|
}
|
|
function resolveDefaultCachePath() {
|
|
const override = process.env.BIRD_QUERY_IDS_CACHE;
|
|
if (override && override.trim().length > 0) {
|
|
return path.resolve(override.trim());
|
|
}
|
|
return path.join(homedir(), '.config', 'bird', DEFAULT_CACHE_FILENAME);
|
|
}
|
|
function parseSnapshot(raw) {
|
|
if (!raw || typeof raw !== 'object') {
|
|
return null;
|
|
}
|
|
const record = raw;
|
|
const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null;
|
|
const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null;
|
|
const ids = record.ids && typeof record.ids === 'object' ? record.ids : null;
|
|
const discovery = record.discovery && typeof record.discovery === 'object' ? record.discovery : null;
|
|
if (!fetchedAt || !ttlMs || !ids || !discovery) {
|
|
return null;
|
|
}
|
|
const pages = Array.isArray(discovery.pages) ? discovery.pages : null;
|
|
const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null;
|
|
if (!pages || !bundles) {
|
|
return null;
|
|
}
|
|
const normalizedIds = {};
|
|
for (const [key, value] of Object.entries(ids)) {
|
|
if (typeof value === 'string' && value.trim().length > 0) {
|
|
normalizedIds[key] = value.trim();
|
|
}
|
|
}
|
|
return {
|
|
fetchedAt,
|
|
ttlMs,
|
|
ids: normalizedIds,
|
|
discovery: {
|
|
pages: pages.filter((p) => typeof p === 'string'),
|
|
bundles: bundles.filter((b) => typeof b === 'string'),
|
|
},
|
|
};
|
|
}
|
|
async function readSnapshotFromDisk(cachePath) {
|
|
try {
|
|
const raw = await readFile(cachePath, 'utf8');
|
|
return parseSnapshot(JSON.parse(raw));
|
|
}
|
|
catch {
|
|
return null;
|
|
}
|
|
}
|
|
async function writeSnapshotToDisk(cachePath, snapshot) {
|
|
await mkdir(path.dirname(cachePath), { recursive: true });
|
|
await writeFile(cachePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
|
|
}
|
|
async function discoverBundles(fetchImpl) {
|
|
const bundles = new Set();
|
|
for (const page of DISCOVERY_PAGES) {
|
|
try {
|
|
const html = await fetchText(fetchImpl, page);
|
|
for (const match of html.matchAll(BUNDLE_URL_REGEX)) {
|
|
bundles.add(match[0]);
|
|
}
|
|
}
|
|
catch {
|
|
// ignore discovery page failures; other pages often work
|
|
}
|
|
}
|
|
const discovered = [...bundles];
|
|
if (discovered.length === 0) {
|
|
throw new Error('No client bundles discovered; x.com layout may have changed.');
|
|
}
|
|
return discovered;
|
|
}
|
|
function extractOperations(bundleContents, bundleLabel, targets, discovered) {
|
|
for (const pattern of OPERATION_PATTERNS) {
|
|
pattern.regex.lastIndex = 0;
|
|
while (true) {
|
|
const match = pattern.regex.exec(bundleContents);
|
|
if (match === null) {
|
|
break;
|
|
}
|
|
const operationName = match[pattern.operationGroup];
|
|
const queryId = match[pattern.queryIdGroup];
|
|
if (!operationName || !queryId) {
|
|
continue;
|
|
}
|
|
if (!targets.has(operationName)) {
|
|
continue;
|
|
}
|
|
if (!QUERY_ID_REGEX.test(queryId)) {
|
|
continue;
|
|
}
|
|
if (discovered.has(operationName)) {
|
|
continue;
|
|
}
|
|
discovered.set(operationName, { queryId, bundle: bundleLabel });
|
|
if (discovered.size === targets.size) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
async function fetchAndExtract(fetchImpl, bundleUrls, targets) {
|
|
const discovered = new Map();
|
|
const CONCURRENCY = 6;
|
|
for (let i = 0; i < bundleUrls.length; i += CONCURRENCY) {
|
|
const chunk = bundleUrls.slice(i, i + CONCURRENCY);
|
|
await Promise.all(chunk.map(async (url) => {
|
|
if (discovered.size === targets.size) {
|
|
return;
|
|
}
|
|
const label = url.split('/').at(-1) ?? url;
|
|
try {
|
|
const js = await fetchText(fetchImpl, url);
|
|
extractOperations(js, label, targets, discovered);
|
|
}
|
|
catch {
|
|
// ignore failed bundles
|
|
}
|
|
}));
|
|
if (discovered.size === targets.size) {
|
|
break;
|
|
}
|
|
}
|
|
return discovered;
|
|
}
|
|
export function createRuntimeQueryIdStore(options = {}) {
|
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath();
|
|
let memorySnapshot = null;
|
|
let loadOnce = null;
|
|
let refreshInFlight = null;
|
|
const loadSnapshot = async () => {
|
|
if (memorySnapshot) {
|
|
return memorySnapshot;
|
|
}
|
|
if (!loadOnce) {
|
|
loadOnce = (async () => {
|
|
const fromDisk = await readSnapshotFromDisk(cachePath);
|
|
memorySnapshot = fromDisk;
|
|
return fromDisk;
|
|
})();
|
|
}
|
|
return loadOnce;
|
|
};
|
|
const getSnapshotInfo = async () => {
|
|
const snapshot = await loadSnapshot();
|
|
if (!snapshot) {
|
|
return null;
|
|
}
|
|
const fetchedAtMs = new Date(snapshot.fetchedAt).getTime();
|
|
const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY;
|
|
const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs;
|
|
const isFresh = ageMs <= effectiveTtl;
|
|
return { snapshot, cachePath, ageMs, isFresh };
|
|
};
|
|
const getQueryId = async (operationName) => {
|
|
const info = await getSnapshotInfo();
|
|
if (!info) {
|
|
return null;
|
|
}
|
|
return info.snapshot.ids[operationName] ?? null;
|
|
};
|
|
const refresh = async (operationNames, opts = {}) => {
|
|
if (refreshInFlight) {
|
|
return refreshInFlight;
|
|
}
|
|
refreshInFlight = (async () => {
|
|
const current = await getSnapshotInfo();
|
|
if (!opts.force && current?.isFresh) {
|
|
return current;
|
|
}
|
|
const targets = new Set(operationNames);
|
|
const bundleUrls = await discoverBundles(fetchImpl);
|
|
const discovered = await fetchAndExtract(fetchImpl, bundleUrls, targets);
|
|
if (discovered.size === 0) {
|
|
return current ?? null;
|
|
}
|
|
const ids = {};
|
|
for (const name of operationNames) {
|
|
const entry = discovered.get(name);
|
|
if (entry?.queryId) {
|
|
ids[name] = entry.queryId;
|
|
}
|
|
}
|
|
const snapshot = {
|
|
fetchedAt: new Date().toISOString(),
|
|
ttlMs,
|
|
ids,
|
|
discovery: {
|
|
pages: [...DISCOVERY_PAGES],
|
|
bundles: bundleUrls.map((url) => url.split('/').at(-1) ?? url),
|
|
},
|
|
};
|
|
await writeSnapshotToDisk(cachePath, snapshot);
|
|
memorySnapshot = snapshot;
|
|
return getSnapshotInfo();
|
|
})().finally(() => {
|
|
refreshInFlight = null;
|
|
});
|
|
return refreshInFlight;
|
|
};
|
|
return {
|
|
cachePath,
|
|
ttlMs,
|
|
getSnapshotInfo,
|
|
getQueryId,
|
|
refresh,
|
|
clearMemory() {
|
|
memorySnapshot = null;
|
|
loadOnce = null;
|
|
},
|
|
};
|
|
}
|
|
export const runtimeQueryIds = createRuntimeQueryIdStore();
|
|
//# sourceMappingURL=runtime-query-ids.js.map
|