Add AnyClip integration tools and extracted source code

- Add authentication scripts with SubtleCrypto password encryption
- Add sourcemap extraction pipeline (update-urls, download-sourcemaps, extract-sources)
- Add Playwright API interception script for monetization endpoints
- Document two-step auth flow with JWT tokens and dual cookies
- Move extracted source from root to anyclip/ directory
- Add project configuration (.env.example, .gitignore, CLAUDE.md)
This commit is contained in:
2026-01-21 10:36:51 +08:00
parent d4fe4800e6
commit e32d475aa9
3463 changed files with 184648 additions and 64341 deletions

275
scripts/auth.ts Normal file
View File

@@ -0,0 +1,275 @@
#!/usr/bin/env bun
/**
* AnyClip Authentication Script
*
* Automates login to AnyClip and returns session credentials.
*
* Usage:
* bun scripts/auth.ts <email> <password>
*
* Or import and use programmatically:
* import { login, getAuthHeaders } from './scripts/auth';
*/
// Load .env file
import { file } from "bun";
const envFile = file(".env");
if (await envFile.exists()) {
const envContent = await envFile.text();
for (const line of envContent.split("\n")) {
const [key, ...valueParts] = line.split("=");
if (key && valueParts.length) {
process.env[key.trim()] = valueParts.join("=").trim();
}
}
}
import { encryptString } from "./crypto-subtle";
const PASS_CRYPTO_SALT = "$2b$04$wwky7rvtr6BFNaCqntwyie";
const EXTERNAL_API = "https://videomanager-api.anyclip.com";
const MAIN_API = "https://videomanager.anyclip.com";
export interface LoginResponse {
cookieName: string;
cookieValue: string;
token: string;
user: string; // base64 encoded JSON
}
export interface AuthSession {
cookies: string; // Both cookies combined
anyclipCookie: string;
sessionCookie: string;
token: string;
user: DecodedUser;
}
export interface DecodedUser {
id: number;
firstName: string;
lastName: string;
email?: string;
roleId: number;
accountId: number;
role: {
id: number;
name: string;
displayName: string;
type: string;
};
account: {
id: number;
name: string;
publisherId?: number;
publishers?: Array<{ id: number; name: string }>;
};
permissions: string[];
publisherIds: number[];
publisherId: number;
slug: string;
}
/**
* Encrypt password using AnyClip's client-side encryption (SubtleCrypto)
*/
export async function encryptPassword(password: string): Promise<string> {
return encryptString(password, PASS_CRYPTO_SALT);
}
/**
* Step 1: Login to external API
*/
async function loginExternal(
email: string,
password: string
): Promise<LoginResponse> {
const encryptedPassword = await encryptPassword(password);
const response = await fetch(`${EXTERNAL_API}/public/auth/login`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
password: encryptedPassword,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`External login failed: ${response.status} - ${error}`);
}
return response.json();
}
/**
* Step 2: Login to main app (returns session cookie)
*/
async function loginMain(
token: string,
cookieName: string,
cookieValue: string
): Promise<string> {
const response = await fetch(`${MAIN_API}/api/auth/login`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
token,
tcname: cookieName,
tcvalue: cookieValue,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Main login failed: ${response.status} - ${error}`);
}
// Extract session cookie from Set-Cookie header
const setCookies = response.headers.getSetCookie?.() || [];
const sessionCookie = setCookies
.find((c) => c.startsWith("session="))
?.split(";")[0];
if (!sessionCookie) {
throw new Error("No session cookie returned from main login");
}
return sessionCookie;
}
/**
* Decode the base64-encoded user object
*/
export function decodeUser(base64User: string): DecodedUser {
const json = Buffer.from(base64User, "base64").toString("utf-8");
return JSON.parse(json);
}
/**
* Full login flow - returns session credentials
*/
export async function login(
email: string,
password: string
): Promise<AuthSession> {
// Step 1: External API login
const externalResponse = await loginExternal(email, password);
const anyclipCookie = `${externalResponse.cookieName}=${externalResponse.cookieValue}`;
// Step 2: Main app login - get session cookie
const sessionCookie = await loginMain(
externalResponse.token,
externalResponse.cookieName,
externalResponse.cookieValue
);
// Decode user
const user = decodeUser(externalResponse.user);
return {
cookies: `${anyclipCookie}; ${sessionCookie}`,
anyclipCookie,
sessionCookie,
token: externalResponse.token,
user,
};
}
/**
* Get headers for authenticated API requests
*/
export function getAuthHeaders(session: AuthSession): Record<string, string> {
return {
Accept: "application/json",
"Content-Type": "application/json",
Cookie: session.cookies,
};
}
/**
* Make an authenticated GraphQL request
*/
export async function graphqlRequest<T = unknown>(
session: AuthSession,
query: string,
variables: Record<string, unknown> = {}
): Promise<T> {
const response = await fetch(`${MAIN_API}/api/graphql`, {
method: "POST",
headers: getAuthHeaders(session),
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`GraphQL request failed: ${response.status} - ${error}`);
}
const result = await response.json();
if (result.errors) {
throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`);
}
return result.data;
}
// CLI usage
if (import.meta.main) {
const [emailArg, passwordArg] = process.argv.slice(2);
const email =
emailArg || process.env.ANYCLIP_EMAIL || process.env.ANYCLIP_USER;
const password = passwordArg || process.env.ANYCLIP_PASSWORD;
if (!email || !password) {
console.error("Usage: bun scripts/auth.ts <email> <password>");
console.error(" Or: set ANYCLIP_EMAIL and ANYCLIP_PASSWORD env vars");
process.exit(1);
}
try {
console.log("Logging in...");
const session = await login(email, password);
console.log("\n✅ Login successful!\n");
console.log("User:", session.user.firstName, session.user.lastName);
console.log("Account:", session.user.account?.name);
console.log("Role:", session.user.role?.displayName);
console.log("\nCookies:", session.cookies.substring(0, 80) + "...");
console.log("\nJWT Token:", session.token.substring(0, 50) + "...");
// Save session to file for other scripts
const sessionFile = "session.json";
await Bun.write(
sessionFile,
JSON.stringify(
{
cookies: session.cookies,
anyclipCookie: session.anyclipCookie,
sessionCookie: session.sessionCookie.substring(0, 100) + "...",
token: session.token,
user: {
id: session.user.id,
name: `${session.user.firstName} ${session.user.lastName}`,
accountId: session.user.accountId,
publisherId: session.user.publisherId,
},
},
null,
2
)
);
console.log(`\nSession saved to ${sessionFile}`);
} catch (error) {
console.error("Login failed:", error);
process.exit(1);
}
}

152
scripts/crypto-subtle.ts Normal file
View File

@@ -0,0 +1,152 @@
/**
* Reimplementation of string-crypto v2 using SubtleCrypto (Web Crypto API)
* Works in browsers, Bun, Deno, Cloudflare Workers, etc.
*/
const KEYLEN = 256; // bits
async function deriveKey(
password: string,
salt: string = "s41t",
iterations: number = 1
): Promise<CryptoKey> {
const enc = new TextEncoder();
// Import password as raw key material
const keyMaterial = await crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveKey"]
);
// Derive AES-GCM key using PBKDF2
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: enc.encode(salt),
iterations,
hash: "SHA-512",
},
keyMaterial,
{ name: "AES-GCM", length: KEYLEN },
false,
["encrypt", "decrypt"]
);
}
export async function encryptString(
str: string,
password: string
): Promise<string> {
const key = await deriveKey(password);
const enc = new TextEncoder();
// Generate random IV
const iv = crypto.getRandomValues(new Uint8Array(16));
// Encrypt
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
enc.encode(str)
);
// SubtleCrypto appends 16-byte auth tag, but v2 string-crypto ignores it
// Strip the last 16 bytes to match v2 behavior
const encryptedWithoutTag = new Uint8Array(encrypted).slice(0, -16);
// Convert to base64, then to hex (v2 quirk)
const encryptedBase64 = btoa(
String.fromCharCode(...encryptedWithoutTag)
);
const encryptedHex = Buffer.from(encryptedBase64).toString("hex");
// IV to hex
const ivHex = Array.from(iv)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return `${ivHex}:${encryptedHex}`;
}
export async function decryptString(
encryptedStr: string,
password: string
): Promise<string> {
const key = await deriveKey(password);
const [ivHex, encryptedHex] = encryptedStr.split(":");
// Hex to IV
const iv = new Uint8Array(
ivHex.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))
);
// Hex to base64 to bytes
const encryptedBase64 = Buffer.from(encryptedHex, "hex").toString();
const encryptedBytes = Uint8Array.from(atob(encryptedBase64), (c) =>
c.charCodeAt(0)
);
// Decrypt
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
encryptedBytes
);
return new TextDecoder().decode(decrypted);
}
// Test
if (import.meta.main) {
// Load .env
const envFile = Bun.file(".env");
if (await envFile.exists()) {
const envContent = await envFile.text();
for (const line of envContent.split("\n")) {
const [key, ...valueParts] = line.split("=");
if (key && valueParts.length) {
process.env[key.trim()] = valueParts.join("=").trim();
}
}
}
const email = process.env.ANYCLIP_EMAIL || process.env.ANYCLIP_USER;
const plaintext = process.env.ANYCLIP_PASSWORD;
if (!email || !plaintext) {
console.error("Set ANYCLIP_EMAIL and ANYCLIP_PASSWORD in .env");
process.exit(1);
}
const salt = "$2b$04$wwky7rvtr6BFNaCqntwyie";
const encrypted = await encryptString(plaintext, salt);
console.log("Encrypted:", encrypted);
// Test with API
console.log("\nTesting with AnyClip API...");
const response = await fetch(
"https://videomanager-api.anyclip.com/public/auth/login",
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
password: encrypted,
}),
}
);
console.log("Status:", response.status);
if (response.ok) {
console.log("✅ Works with SubtleCrypto!");
} else {
const text = await response.text();
console.log("Response:", text);
}
}

111
scripts/crypto.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* Reimplementation of string-crypto v2 encryption
* Uses Node's built-in crypto module (works in Bun too)
*/
import {
randomBytes,
pbkdf2Sync,
createCipheriv,
createDecipheriv,
} from "crypto";
const KEYLEN = 256 / 8; // 32 bytes for aes-256
interface DeriveKeyOpts {
salt?: string;
iterations?: number;
digest?: string;
}
const defaultOpts: DeriveKeyOpts = {
salt: "s41t",
iterations: 1,
digest: "sha512",
};
export function deriveKey(password: string, options?: DeriveKeyOpts): Buffer {
const { salt, iterations, digest } = { ...defaultOpts, ...options };
return pbkdf2Sync(password, salt!, iterations!, KEYLEN, digest!);
}
export function encryptString(str: string, password: string): string {
const derivedKey = deriveKey(password);
const iv = randomBytes(16);
const cipher = createCipheriv("aes-256-gcm", derivedKey, iv);
let encryptedBase64 = cipher.update(str, "utf8", "base64");
encryptedBase64 += cipher.final("base64");
// Convert base64 to hex (this is the v2 quirk)
const encryptedHex = Buffer.from(encryptedBase64).toString("hex");
const ivHex = iv.toString("hex");
return `${ivHex}:${encryptedHex}`;
}
export function decryptString(encryptedStr: string, password: string): string {
const derivedKey = deriveKey(password);
const [ivHex, encryptedHex] = encryptedStr.split(":");
const iv = Buffer.from(ivHex, "hex");
const encryptedBase64 = Buffer.from(encryptedHex, "hex").toString();
const decipher = createDecipheriv("aes-256-gcm", derivedKey, iv);
let decrypted = decipher.update(encryptedBase64, "base64", "utf8");
return decrypted;
}
// Test
if (import.meta.main) {
// Load .env
const envFile = Bun.file(".env");
if (await envFile.exists()) {
const envContent = await envFile.text();
for (const line of envContent.split("\n")) {
const [key, ...valueParts] = line.split("=");
if (key && valueParts.length) {
process.env[key.trim()] = valueParts.join("=").trim();
}
}
}
const email = process.env.ANYCLIP_EMAIL || process.env.ANYCLIP_USER;
const plaintext = process.env.ANYCLIP_PASSWORD;
if (!email || !plaintext) {
console.error("Set ANYCLIP_EMAIL and ANYCLIP_PASSWORD in .env");
process.exit(1);
}
const salt = "$2b$04$wwky7rvtr6BFNaCqntwyie";
const encrypted = encryptString(plaintext, salt);
console.log("Encrypted:", encrypted);
// Test with API
console.log("\nTesting with AnyClip API...");
const response = await fetch(
"https://videomanager-api.anyclip.com/public/auth/login",
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
password: encrypted,
}),
}
);
console.log("Status:", response.status);
if (response.ok) {
console.log("✅ Works without string-crypto library!");
} else {
const text = await response.text();
console.log("Response:", text);
}
}

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bun
/**
* Download sourcemaps for all JS files listed in urls.txt
*
* Usage:
* bun scripts/update-urls.ts # First, update urls.txt from build manifest
* bun scripts/download-sourcemaps.ts # Then download sourcemaps
*/
import { mkdir } from "fs/promises";
const OUTPUT_DIR = "sourcemaps";
await mkdir(OUTPUT_DIR, { recursive: true });
// Read urls.txt
const urlsFile = Bun.file("urls.txt");
if (!(await urlsFile.exists())) {
console.error("urls.txt not found. Run 'bun scripts/update-urls.ts' first.");
process.exit(1);
}
const urls = (await urlsFile.text())
.split("\n")
.map((line) => line.trim())
.filter((line) => line && line.includes("/_next/") && line.endsWith(".js"));
console.log(`Found ${urls.length} JS files in urls.txt\n`);
let downloaded = 0;
let skipped = 0;
let failed = 0;
for (const jsUrl of urls) {
const filename = jsUrl.split("/").pop()!;
const mapFile = `${OUTPUT_DIR}/${filename}.map`;
// Check if already exists
const exists = await Bun.file(mapFile).exists();
if (exists) {
skipped++;
continue;
}
const mapUrl = `${jsUrl}.map`;
try {
const response = await fetch(mapUrl);
if (response.ok) {
const content = await response.text();
// Verify it's a valid sourcemap (starts with {)
if (content.startsWith("{")) {
await Bun.write(mapFile, content);
console.log(`${filename}.map`);
downloaded++;
} else {
failed++;
}
} else {
failed++;
}
} catch (e) {
failed++;
}
}
console.log(`\n✅ Downloaded: ${downloaded}`);
console.log(`⏭️ Skipped (exists): ${skipped}`);
console.log(`❌ Failed/No sourcemap: ${failed}`);
console.log(`\nTotal sourcemaps: ${downloaded + skipped}`);

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bun
/**
* Extract the login code from sourcemaps to show what they're using
*/
const sourcemap = await Bun.file('sourcemaps/_app-583e09fffc54549d.js.map').text();
// Find the login epic code
const loginPattern = /import StringCrypto[\s\S]{0,2000}public\/auth\/login/g;
const matches = sourcemap.match(loginPattern);
if (matches) {
console.log('=== Found Login Code in Sourcemaps ===\n');
for (const match of matches) {
// Clean up the escaped newlines for readability
const cleaned = match.replace(/\\n/g, '\n').replace(/\\"/g, '"');
console.log(cleaned);
console.log('\n---\n');
}
} else {
console.log('No matches found');
}

197
scripts/extract-sources.ts Normal file
View File

@@ -0,0 +1,197 @@
#!/usr/bin/env bun
/**
* Extract source files from sourcemaps into proper directory structure
*
* Usage:
* bun scripts/extract-sources.ts [options]
*
* Options:
* --output, -o <dir> Output directory (default: anyclip)
* --input, -i <dir> Sourcemaps directory (default: sourcemaps)
* --verbose, -v Verbose output
* --no-clean Don't delete output directory first
*/
import { mkdir, rm, stat, unlink } from "fs/promises";
import { dirname, join, normalize, basename } from "path";
import { parseArgs } from "util";
// Parse CLI arguments
const { values: args } = parseArgs({
args: Bun.argv.slice(2),
options: {
output: { type: "string", short: "o", default: "anyclip" },
input: { type: "string", short: "i", default: "sourcemaps" },
verbose: { type: "boolean", short: "v", default: false },
"no-clean": { type: "boolean", default: false },
},
});
const OUTPUT_DIR = args.output!;
const INPUT_DIR = args.input!;
const VERBOSE = args.verbose!;
const NO_CLEAN = args["no-clean"]!;
interface SourceMap {
version: number;
sources: string[];
sourcesContent: (string | null)[];
names?: string[];
mappings?: string;
}
// Collect all files first, then write (to handle conflicts)
const filesToWrite = new Map<string, string>();
/**
* Sanitize a source path from a sourcemap
* Inspired by sourcemapper and shuji
*/
function sanitizePath(source: string): string | null {
let cleanPath = source
// Remove webpack:// prefix (webpack://package-name/path or webpack:///path)
.replace(/^webpack:\/\/[^/]*\//, "")
// Remove leading ./
.replace(/^\.\//, "")
// Remove query strings (?v=123, ?module, etc)
.replace(/\?[^/]*$/, "")
// Remove Windows-illegal characters
.replace(/[?%*|:"<>]/g, "")
// Replace spaces with hyphens
.replace(/ /g, "-");
// Skip empty paths
if (!cleanPath || cleanPath === "") return null;
// Skip node_modules and external dependencies
if (cleanPath.includes("node_modules") || cleanPath.startsWith("external ")) {
return null;
}
// Handle (webpack) paths - put in webpack/ directory
if (cleanPath.includes("(webpack)")) {
cleanPath = "webpack/" + cleanPath.replace(/\(webpack\)\//g, "");
}
// Normalize path: resolve ../ sequences safely
// Prepend a fake root to prevent escaping, then normalize and strip it
const withRoot = "/" + cleanPath;
const normalized = normalize(withRoot).slice(1); // Remove leading /
// Handle webpack runtime files (no extension) - add .js
if (normalized.startsWith("webpack/") && !basename(normalized).includes(".")) {
return normalized + ".js";
}
// Skip paths without file extension (likely directories)
if (!basename(normalized).includes(".")) {
return null;
}
return normalized;
}
async function extractSourcemap(mapFile: string): Promise<number> {
if (VERBOSE) console.log(`Processing ${mapFile}...`);
const fileContent = await Bun.file(mapFile).text();
let sourcemap: SourceMap;
try {
sourcemap = JSON.parse(fileContent);
} catch (e) {
if (VERBOSE) console.log(` Failed to parse: ${e}`);
return 0;
}
if (!sourcemap.sources || !sourcemap.sourcesContent) {
if (VERBOSE) console.log(` No sources found`);
return 0;
}
let collected = 0;
for (let i = 0; i < sourcemap.sources.length; i++) {
const source = sourcemap.sources[i];
const content = sourcemap.sourcesContent[i];
if (!content) continue;
const cleanPath = sanitizePath(source);
if (!cleanPath) continue;
const outputPath = join(OUTPUT_DIR, cleanPath);
// Keep the larger file if there's a conflict (more complete source)
if (!filesToWrite.has(outputPath) || content.length > filesToWrite.get(outputPath)!.length) {
filesToWrite.set(outputPath, content);
collected++;
}
}
if (VERBOSE) console.log(` Collected ${collected} files`);
return collected;
}
// Delete existing output directory (unless --no-clean)
if (!NO_CLEAN) {
console.log(`Cleaning ${OUTPUT_DIR}/...`);
await rm(OUTPUT_DIR, { recursive: true, force: true });
}
// Process all sourcemaps
const sourcemaps = await Array.fromAsync(new Bun.Glob("*.map").scan(INPUT_DIR));
console.log(`Found ${sourcemaps.length} sourcemaps in ${INPUT_DIR}/`);
for (const map of sourcemaps) {
await extractSourcemap(join(INPUT_DIR, map));
}
// Write all files
console.log(`\nWriting ${filesToWrite.size} files to ${OUTPUT_DIR}/...`);
let written = 0;
let skipped = 0;
let errors = 0;
for (const [outputPath, content] of filesToWrite) {
try {
const dir = dirname(outputPath);
// Handle file/directory conflicts
const parts = dir.split("/");
let currentPath = "";
for (const part of parts) {
currentPath = currentPath ? `${currentPath}/${part}` : part;
try {
const s = await stat(currentPath);
if (s.isFile()) {
await unlink(currentPath);
}
} catch {
// Path doesn't exist yet
}
}
await mkdir(dir, { recursive: true, mode: 0o755 });
await Bun.write(outputPath, content);
written++;
if (VERBOSE) console.log(`${outputPath}`);
} catch (e) {
errors++;
if (VERBOSE || errors <= 3) {
console.log(`${outputPath}: ${e}`);
}
}
}
if (!VERBOSE && errors > 3) {
console.log(` ... and ${errors - 3} more errors`);
}
console.log(`\n✅ Extracted ${written} files to ${OUTPUT_DIR}/`);
if (errors > 0) console.log(`⚠️ ${errors} errors`);
// Show directory summary
const dirs = new Set([...filesToWrite.keys()].map(p => p.split("/").slice(0, 2).join("/")));
console.log(`\nDirectories: ${[...dirs].sort().join(", ")}`);

View File

@@ -0,0 +1,244 @@
#!/usr/bin/env bun
/**
* AnyClip Monetization API Interception Script
*
* This script launches a visible browser, allows manual login,
* and captures all API traffic on the monetization page.
*
* Usage: bun scripts/intercept-monetization.ts
*
* Press Ctrl+C to stop and save captured data.
*/
import { chromium, type Page, type Request, type Response } from "playwright";
import { writeFileSync } from "fs";
interface CapturedRequest {
timestamp: string;
url: string;
method: string;
headers: Record<string, string>;
requestBody: string | null;
responseStatus: number | null;
responseHeaders: Record<string, string> | null;
responseBody: string | null;
duration: number | null;
}
const capturedRequests: CapturedRequest[] = [];
const pendingRequests = new Map<
Request,
{ startTime: number; captured: CapturedRequest }
>();
const OUTPUT_FILE = "captured-apis.json";
const BASE_URL = "https://videomanager.anyclip.com";
const MONETIZATION_PATH = "/analytics-new/monetization";
// Filter for API requests we care about
function isApiRequest(url: string): boolean {
// Capture GraphQL, REST API calls, and data endpoints
const apiPatterns = [
"/graphql",
"/api/",
"/analytics",
"/monetization",
"/v1/",
"/v2/",
"anyclip.com",
];
// Exclude static assets
const excludePatterns = [
".js",
".css",
".png",
".jpg",
".jpeg",
".gif",
".svg",
".ico",
".woff",
".woff2",
".ttf",
".eot",
"/static/",
"fonts.googleapis.com",
"google-analytics.com",
"googletagmanager.com",
];
const urlLower = url.toLowerCase();
if (excludePatterns.some((pattern) => urlLower.includes(pattern))) {
return false;
}
return apiPatterns.some((pattern) => urlLower.includes(pattern));
}
function saveCaptures(): void {
const output = {
capturedAt: new Date().toISOString(),
totalRequests: capturedRequests.length,
requests: capturedRequests,
};
writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2));
console.log(`\n✅ Saved ${capturedRequests.length} requests to ${OUTPUT_FILE}`);
}
async function handleRequest(request: Request): Promise<void> {
const url = request.url();
if (!isApiRequest(url)) {
return;
}
const captured: CapturedRequest = {
timestamp: new Date().toISOString(),
url,
method: request.method(),
headers: request.headers(),
requestBody: request.postData() || null,
responseStatus: null,
responseHeaders: null,
responseBody: null,
duration: null,
};
pendingRequests.set(request, {
startTime: Date.now(),
captured,
});
console.log(`📤 ${request.method()} ${url}`);
}
async function handleResponse(response: Response): Promise<void> {
const request = response.request();
const pending = pendingRequests.get(request);
if (!pending) {
return;
}
const { startTime, captured } = pending;
captured.responseStatus = response.status();
captured.responseHeaders = response.headers();
captured.duration = Date.now() - startTime;
try {
const contentType = response.headers()["content-type"] || "";
if (
contentType.includes("application/json") ||
contentType.includes("text/")
) {
captured.responseBody = await response.text();
} else {
captured.responseBody = `[Binary content: ${contentType}]`;
}
} catch (error) {
captured.responseBody = `[Error reading response: ${error}]`;
}
capturedRequests.push(captured);
pendingRequests.delete(request);
console.log(
`📥 ${response.status()} ${captured.url} (${captured.duration}ms)`
);
}
async function main(): Promise<void> {
console.log("🚀 AnyClip Monetization API Interceptor");
console.log("=========================================\n");
console.log("Launching browser...");
const browser = await chromium.launch({
headless: false,
args: ["--start-maximized"],
});
const context = await browser.newContext({
viewport: null, // Use full window size
});
const page = await context.newPage();
// Set up request/response interception
page.on("request", handleRequest);
page.on("response", handleResponse);
// Navigate to AnyClip
console.log(`\nNavigating to ${BASE_URL}...`);
await page.goto(BASE_URL);
console.log("\n" + "=".repeat(50));
console.log("👤 Please log in manually in the browser window");
console.log("=".repeat(50) + "\n");
// Wait for login - user must navigate to a protected page
console.log("Waiting for authentication...");
console.log("(Navigate to any page after logging in, or the script will auto-detect)\n");
// Wait for URL to change to a protected route (not login/logout)
await page.waitForFunction(
() => {
const path = window.location.pathname;
// Must be on a real app page, not login/logout/root
const protectedRoutes = [
"/dashboard",
"/analytics",
"/video",
"/library",
"/settings",
"/playlists",
"/players",
];
return protectedRoutes.some((route) => path.startsWith(route));
},
{ timeout: 300000 } // 5 minute timeout for login
);
console.log("✅ Authentication detected!\n");
// Navigate to monetization page
const monetizationUrl = `${BASE_URL}${MONETIZATION_PATH}`;
console.log(`Navigating to ${monetizationUrl}...`);
try {
await page.goto(monetizationUrl, { waitUntil: "domcontentloaded" });
} catch (error) {
// Navigation might fail if page redirects - that's okay
console.log("Navigation completed (may have redirected)");
}
// Wait for page to stabilize
await page.waitForTimeout(2000);
await page.waitForLoadState("networkidle").catch(() => {});
console.log("\n" + "=".repeat(50));
console.log("📊 Now capturing API traffic on the Monetization page");
console.log(" Interact with filters, date ranges, etc.");
console.log(" Press Ctrl+C when done to save captured data");
console.log("=".repeat(50) + "\n");
// Handle Ctrl+C gracefully
process.on("SIGINT", async () => {
console.log("\n\n🛑 Stopping capture...");
saveCaptures();
await browser.close();
process.exit(0);
});
// Keep the script running
await new Promise(() => {}); // Never resolves - keeps script alive
}
main().catch((error) => {
console.error("Error:", error);
saveCaptures();
process.exit(1);
});

112
scripts/test-auth-final.ts Normal file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bun
/**
* Final auth test with both cookies
*/
import { encryptString } from "./crypto-subtle";
// Load .env
const envFile = Bun.file(".env");
if (await envFile.exists()) {
const envContent = await envFile.text();
for (const line of envContent.split("\n")) {
const [key, ...valueParts] = line.split("=");
if (key && valueParts.length) {
process.env[key.trim()] = valueParts.join("=").trim();
}
}
}
const EXTERNAL_API = "https://videomanager-api.anyclip.com";
const MAIN_API = "https://videomanager.anyclip.com";
const PASS_CRYPTO_SALT = "$2b$04$wwky7rvtr6BFNaCqntwyie";
async function test() {
const email = process.env.ANYCLIP_USER || process.env.ANYCLIP_EMAIL;
const password = process.env.ANYCLIP_PASSWORD;
console.log("=== Full Auth Test with SubtleCrypto ===\n");
// Step 1: External login
console.log("1. External API login...");
const encryptedPassword = await encryptString(password!, PASS_CRYPTO_SALT);
const externalResponse = await fetch(`${EXTERNAL_API}/public/auth/login`, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ email, password: encryptedPassword }),
});
if (!externalResponse.ok) {
console.error(" ❌ Failed:", await externalResponse.text());
process.exit(1);
}
const loginData = await externalResponse.json();
const anyclipCookie = `${loginData.cookieName}=${loginData.cookieValue}`;
console.log(" ✅ Got anyclip_2020 cookie");
// Step 2: Main login - capture session cookie
console.log("\n2. Main API login...");
const mainLoginResponse = await fetch(`${MAIN_API}/api/auth/login`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
token: loginData.token,
tcname: loginData.cookieName,
tcvalue: loginData.cookieValue,
}),
});
// Extract session cookie from Set-Cookie header
const setCookies = mainLoginResponse.headers.getSetCookie?.() || [];
const sessionCookie = setCookies
.find(c => c.startsWith("session="))
?.split(";")[0];
if (!sessionCookie) {
console.error(" ❌ No session cookie returned");
process.exit(1);
}
console.log(" ✅ Got session cookie");
// Both cookies needed
const fullCookie = `${anyclipCookie}; ${sessionCookie}`;
console.log("\n3. Combined cookies:");
console.log(" anyclip_2020:", anyclipCookie.substring(0, 40) + "...");
console.log(" session:", sessionCookie.substring(0, 40) + "...");
// Test WITHOUT auth
console.log("\n4. Test /studio WITHOUT cookies...");
const noAuthResponse = await fetch(`${MAIN_API}/studio`, {
redirect: "manual",
});
console.log(" Status:", noAuthResponse.status);
console.log(" Redirects?", noAuthResponse.status === 307);
// Test WITH auth
console.log("\n5. Test /studio WITH cookies...");
const authResponse = await fetch(`${MAIN_API}/studio`, {
redirect: "manual",
headers: { Cookie: fullCookie },
});
console.log(" Status:", authResponse.status);
console.log(" Got 200?", authResponse.status === 200);
// Summary
console.log("\n========================================");
if (noAuthResponse.status === 307 && authResponse.status === 200) {
console.log("✅ SubtleCrypto auth VERIFIED");
console.log(" - Without cookies: redirected (307)");
console.log(" - With cookies: authenticated (200)");
} else {
console.log("❌ Auth verification failed");
console.log(" - Without: " + noAuthResponse.status);
console.log(" - With: " + authResponse.status);
}
}
test().catch(console.error);

66
scripts/update-urls.ts Normal file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bun
/**
* Fetch all JS URLs from AnyClip's build manifest and pages, write to urls.txt
*/
const BASE_URL = "https://videomanager.anyclip.com";
const BUILD_MANIFEST_URL =
"https://videomanager.anyclip.com/_next/static/pBwdVlmlgRKGbPM_149Sv/_buildManifest.js";
// Pages to scrape for additional JS files
const PAGES_TO_SCRAPE = [
"https://videomanager.anyclip.com/login",
];
const jsUrls = new Set<string>();
// 1. Fetch build manifest
console.log("Fetching build manifest...");
const manifestResponse = await fetch(BUILD_MANIFEST_URL);
const manifestText = await manifestResponse.text();
// Extract all JS chunk paths from manifest
const manifestChunks = [...manifestText.matchAll(/static\/chunks\/[^"'\s,\)]+\.js/g)]
.map((m) => `${BASE_URL}/_next/${m[0]}`);
manifestChunks.forEach((url) => jsUrls.add(url));
console.log(` Found ${manifestChunks.length} chunks in manifest`);
// 2. Scrape pages for additional JS files
for (const pageUrl of PAGES_TO_SCRAPE) {
console.log(`Scraping ${pageUrl}...`);
try {
const response = await fetch(pageUrl);
const html = await response.text();
// Extract JS URLs from page
const pageJsUrls = [
...html.matchAll(/src="(\/_next\/static\/[^"]+\.js)"/g),
].map((m) => `${BASE_URL}${m[1]}`);
pageJsUrls.forEach((url) => jsUrls.add(url));
console.log(` Found ${pageJsUrls.length} JS files`);
} catch (e) {
console.log(` Failed to scrape: ${e}`);
}
}
// 3. Read existing urls.txt and merge
const existingUrls = new Set<string>();
const urlsFile = Bun.file("urls.txt");
if (await urlsFile.exists()) {
const content = await urlsFile.text();
content.split("\n").filter(Boolean).forEach((url) => existingUrls.add(url.trim()));
console.log(`\nExisting urls.txt has ${existingUrls.size} URLs`);
}
// Merge
const allUrls = new Set([...existingUrls, ...jsUrls]);
const newUrls = [...jsUrls].filter((url) => !existingUrls.has(url));
// 4. Write to urls.txt
const sortedUrls = [...allUrls].sort();
await Bun.write("urls.txt", sortedUrls.join("\n") + "\n");
console.log(`\n✅ urls.txt updated`);
console.log(` Total URLs: ${allUrls.size}`);
console.log(` New URLs added: ${newUrls.length}`);