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:
275
scripts/auth.ts
Normal file
275
scripts/auth.ts
Normal 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
152
scripts/crypto-subtle.ts
Normal 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
111
scripts/crypto.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
70
scripts/download-sourcemaps.ts
Normal file
70
scripts/download-sourcemaps.ts
Normal 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}`);
|
||||
22
scripts/extract-login-code.ts
Normal file
22
scripts/extract-login-code.ts
Normal 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
197
scripts/extract-sources.ts
Normal 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(", ")}`);
|
||||
244
scripts/intercept-monetization.ts
Normal file
244
scripts/intercept-monetization.ts
Normal 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
112
scripts/test-auth-final.ts
Normal 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
66
scripts/update-urls.ts
Normal 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}`);
|
||||
Reference in New Issue
Block a user