文件预览

pexels.ts

查看 Free Resource 技能包中的文件内容。

文件内容

scripts/pexels.ts

#!/usr/bin/env bun
/**
 * Pexels API CLI - search and download royalty-free photos & videos.
 *
 * Usage:
 *   bun ./scripts/pexels.ts search-photos --query "city skyline" --orientation landscape
 *   bun ./scripts/pexels.ts search-videos --query "ocean waves" --size medium
 *   bun ./scripts/pexels.ts download --url "https://..." --output "/path/to/file.jpg"
 */

import * as path from "path";
import * as fs from "fs";

const BASE_URL = "https://api.pexels.com/v1";
const CONFIG_FILE = path.join(import.meta.dir, "..", "config.json");

// ── helpers ──────────────────────────────────────────────────────────────

function loadConfig(): Record<string, any> {
  if (fs.existsSync(CONFIG_FILE)) {
    try {
      return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
    } catch (e) {
      return {};
    }
  }
  return {};
}

function getApiKey(args: Record<string, string | undefined>): string {
  // Priority: CLI arg > env var > config file
  const key = args["--key"] ?? process.env.PEXELS_API_KEY ?? loadConfig()?.pexels?.api_key;
  if (!key) {
    console.error(
      "Error: API key required. Use --key, set PEXELS_API_KEY env var, or add to config.json."
    );
    process.exit(1);
  }
  return key;
}

function parseArgs(argv: string[]): {
  command: string;
  flags: Record<string, string>;
} {
  const command = argv[0] ?? "";
  const flags: Record<string, string> = {};
  for (let i = 1; i < argv.length; i++) {
    const arg = normalizeFlag(argv[i]);
    if (arg.startsWith("--") && i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
      flags[arg] = argv[++i];
    } else if (arg.startsWith("--")) {
      // boolean flag
      flags[arg] = "true";
    }
  }
  return { command, flags };
}

function normalizeFlag(flag: string): string {
  switch (flag) {
    case "-q":
      return "--query";
    case "-o":
      return "--output";
    default:
      return flag;
  }
}

async function apiRequest(
  endpoint: string,
  params: Record<string, string | number | undefined>,
  key: string
): Promise<any> {
  const qs = new URLSearchParams();
  for (const [k, v] of Object.entries(params)) {
    if (v !== undefined && v !== null && v !== "") {
      qs.set(k, String(v));
    }
  }

  const url = `${BASE_URL}${endpoint}?${qs.toString()}`;
  const resp = await fetch(url, {
    headers: {
      "Authorization": key,
      "User-Agent": "PexelsCLI/0.1",
    },
  });

  const limit = resp.headers.get("X-Ratelimit-Limit");
  const remaining = resp.headers.get("X-Ratelimit-Remaining");
  const reset = resp.headers.get("X-Ratelimit-Reset");
  if (limit) {
    console.error(`Rate limit: ${remaining}/${limit} remaining, resets at ${reset}`);
  }

  if (!resp.ok) {
    const body = await resp.text();
    console.error(`HTTP ${resp.status}: ${body}`);
    process.exit(1);
  }

  return resp.json();
}

async function downloadFile(url: string, output: string): Promise<void> {
  const resp = await fetch(url, {
    headers: { "User-Agent": "PexelsCLI/0.1" },
  });
  if (!resp.ok) {
    console.error(`Download failed - HTTP ${resp.status}`);
    process.exit(1);
  }
  const buf = await resp.arrayBuffer();
  await Bun.write(output, new Uint8Array(buf));
  console.error(`Downloaded: ${output}`);
}

function applyCommonSearchParams(
  flags: Record<string, string>,
  params: Record<string, string | number | undefined>
) {
  if (flags["--query"]) params.query = flags["--query"];
  if (flags["--orientation"]) params.orientation = flags["--orientation"];
  if (flags["--size"]) params.size = flags["--size"];
  if (flags["--locale"]) params.locale = flags["--locale"];
  if (flags["--page"]) params.page = Number(flags["--page"]);
  if (flags["--per-page"]) params.per_page = Number(flags["--per-page"]);
}

// ── commands ─────────────────────────────────────────────────────────────

async function searchPhotos(flags: Record<string, string>) {
  const key = getApiKey(flags);
  if (!flags["--query"]) {
    console.error("Error: --query is required");
    process.exit(1);
  }

  const params: Record<string, string | number | undefined> = {};

  applyCommonSearchParams(flags, params);
  if (flags["--color"]) params.color = flags["--color"];

  const data = await apiRequest("/search", params, key);
  console.error(`Found ${data.total_results ?? 0} photos (showing ${data.photos?.length ?? 0})`);

  const json = JSON.stringify(data, null, 2);
  if (flags["--output"]) {
    await Bun.write(flags["--output"], json);
    console.error(`Results saved to: ${flags["--output"]}`);
  } else {
    console.log(json);
  }
}

async function searchVideos(flags: Record<string, string>) {
  const key = getApiKey(flags);
  if (!flags["--query"]) {
    console.error("Error: --query is required");
    process.exit(1);
  }

  const params: Record<string, string | number | undefined> = {};

  applyCommonSearchParams(flags, params);

  const data = await apiRequest("/videos/search", params, key);
  console.error(`Found ${data.total_results ?? 0} videos (showing ${data.videos?.length ?? 0})`);

  const json = JSON.stringify(data, null, 2);
  if (flags["--output"]) {
    await Bun.write(flags["--output"], json);
    console.error(`Results saved to: ${flags["--output"]}`);
  } else {
    console.log(json);
  }
}

async function download(flags: Record<string, string>) {
  if (!flags["--url"]) {
    console.error("Error: --url is required");
    process.exit(1);
  }
  if (!flags["--output"]) {
    console.error("Error: --output is required");
    process.exit(1);
  }
  await downloadFile(flags["--url"], flags["--output"]);
}

// ── help ─────────────────────────────────────────────────────────────────

function printHelp() {
  console.log(`Pexels API CLI - search and download royalty-free photos & videos

Commands:
  search-photos   Search for photos
  search-videos   Search for videos
  download        Download a file by URL

Common flags:
  --key           Pexels API key (or set PEXELS_API_KEY env var)
  --query / -q    Search term
  --orientation   landscape | portrait | square
  --size          Photos: large | medium | small; Videos: large | medium | small
  --locale        Search locale, e.g. en-US, zh-CN, ja-JP
  --page          Page number (default: 1)
  --per-page      Results per page, 1-80 (default: 15)
  --output / -o   Save JSON to file

Photo-specific:
  --color         red | orange | yellow | green | turquoise | blue | violet | pink | brown | black | gray | white | hex color

Download:
  --url           URL to download
  --output        Local file path to save`);
}

// ── main ─────────────────────────────────────────────────────────────────

const rawArgs = process.argv.slice(2);
if (rawArgs.length === 0 || rawArgs[0] === "--help" || rawArgs[0] === "-h") {
  printHelp();
  process.exit(0);
}

const { command, flags } = parseArgs(rawArgs);

switch (command) {
  case "search-photos":
    await searchPhotos(flags);
    break;
  case "search-videos":
    await searchVideos(flags);
    break;
  case "download":
    await download(flags);
    break;
  default:
    console.error(`Unknown command: ${command}`);
    printHelp();
    process.exit(1);
}