TypeScript NFT 开发实战:从零构建 Pinata IPFS 自动化上传工具 (附完整源码)

TypeScript NFT 开发实战:从零构建 Pinata IPFS 自动化上传工具 (附完整源码)

对于每一位NFT项目开发者来说,将成百上千的图片和对应的元数据(Metadata)上传到IPFS都是一个相当繁琐且容易出错的环节。手动一个个上传不仅耗时耗力,还可能因为网络问题或操作失误导致数据混乱。

我们最初在《NFT 开发核心步骤》一文中,演示了如何与本地IPFS节点 交互,这为理解其原理打下了基础。但要将流程真正投入生产环境,我们需要更专业的解决方案。

为了彻底解决这个痛点,我用 TypeScript 开发并开源了一款专为 Pinata 设计的自动化上传工具 🧰。它不仅能实现图片和元数据的一键批量上传,还能自动生成兼容不同智能合约的元数据文件。

这篇文章将带你从配置、代码实现到实战测试,全面解析这款工具,让你彻底告别繁琐的上传工作,专注于更有创造性的开发!🚀

项目概览

这是一个用于将 NFT 图片和元数据自动化上传到 Pinata IPFS 的 TypeScript 工具。在深入代码之前,我们先看一下它的整体结构和环境配置。

查看项目目录结构

bash 复制代码
➜ tree . -L 6 -I "migrations|mochawesome-report|.anchor|docs|target|node_modules"
.
├── package.json
├── pnpm-lock.yaml
├── README.md
├── src
│   └── index.ts
└── tsconfig.json

2 directories, 5 files

环境配置

创建 .env 文件:

bash 复制代码
# Pinata 配置
PINATA_JWT=your_pinata_jwt_here
PINATA_GATEWAY=your_pinata_gateway_here

# 元数据配置(可选)
# 可选值: .json, .txt, .md, .xml 等
# 默认值: .json
METADATA_SUFFIX=.json

# 上传配置(可选)
UPLOAD_TIMEOUT=300000    # 上传超时时间(毫秒,默认5分钟)
MAX_RETRIES=3           # 最大重试次数(默认3次)
RETRY_DELAY=5000        # 重试延迟(毫秒,默认5秒)

package.json 文件

json 复制代码
{
  "name": "pinata-nft-uploader",
  "version": "1.0.0",
  "description": "A TypeScript tool for uploading NFT images and metadata to Pinata IPFS",
  "main": "index.js",
  "scripts": {
    "start": "ts-node src/index.ts",
    "batch": "ts-node src/index.ts batch",
    "batch:no-suffix": "ts-node src/index.ts batch --no-suffix",
    "single": "ts-node src/index.ts single",
    "test": "ts-node src/index.ts test",
    "pin": "ts-node src/index.ts pin",
    "queue": "ts-node src/index.ts queue"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "packageManager": "pnpm@10.8.1",
  "dependencies": {
    "dotenv": "^17.2.1",
    "pinata": "^2.4.9"
  },
  "devDependencies": {
    "@types/node": "^24.1.0",
    "ts-node": "^10.9.2",
    "typescript": "^5.8.3"
  }
}

代码实现src/index.ts

ts 复制代码
import { PinataSDK } from "pinata";
import * as fs from "fs/promises";
import { existsSync } from "fs";
import * as path from "path";
import * as dotenv from "dotenv";

// --- 类型定义 ---
interface UploadResult {
  cid: string;
  size?: number;
  timestamp: string;
}

interface BatchUploadResult {
  timestamp: string;
  imagesFolderCid: string;
  metadataWithSuffixCid?: string;
  metadataWithoutSuffixCid?: string;
  imageCount: number;
  metadataFiles: Array<{
    tokenId: string;
    metadataFileWithSuffix: string;
    metadataFileWithoutSuffix: string;
  }>;
  totalSize: number;
  uploadTime: number;
}

interface SingleUploadResult {
  timestamp: string;
  imageCid: string;
  metadataCid: string;
  imageUrl: string;
  metadataUrl: string;
  gatewayImageUrl: string;
  gatewayMetadataUrl: string;
  metadata: any;
  uploadTime: number;
}

interface Config {
  pinataJwt: string;
  pinataGateway: string;
  metadataSuffix: string;
  uploadTimeout: number;
  maxRetries: number;
  retryDelay: number;
  maxFileSize: number;
  maxTotalSize: number;
}

// --- 配置管理 ---
class ConfigManager {
  private config: Config;

  constructor() {
    dotenv.config();
    this.config = this.loadConfig();
  }

  private loadConfig(): Config {
    const pinataJwt = process.env.PINATA_JWT;
    const pinataGateway = process.env.PINATA_GATEWAY;

    if (!pinataJwt) {
      throw new Error("❌ 请在 .env 文件中设置 PINATA_JWT");
    }
    if (!pinataGateway) {
      throw new Error("❌ 请在 .env 文件中设置 PINATA_GATEWAY");
    }

    return {
      pinataJwt,
      pinataGateway,
      metadataSuffix: process.env.METADATA_SUFFIX || ".json",
      uploadTimeout: parseInt(process.env.UPLOAD_TIMEOUT || "300000"),
      maxRetries: parseInt(process.env.MAX_RETRIES || "3"),
      retryDelay: parseInt(process.env.RETRY_DELAY || "5000"),
      maxFileSize: parseInt(process.env.MAX_FILE_SIZE || "52428800"), // 50MB
      maxTotalSize: parseInt(process.env.MAX_TOTAL_SIZE || "524288000"), // 500MB
    };
  }

  getConfig(): Config {
    return this.config;
  }
}

// --- 日志管理 ---
class Logger {
  private static instance: Logger;
  private logLevel: "info" | "warn" | "error" | "debug" = "info";

  private constructor() {}

  static getInstance(): Logger {
    if (!Logger.instance) {
      Logger.instance = new Logger();
    }
    return Logger.instance;
  }

  setLogLevel(level: "info" | "warn" | "error" | "debug") {
    this.logLevel = level;
  }

  private shouldLog(level: "info" | "warn" | "error" | "debug"): boolean {
    const levels = { debug: 0, info: 1, warn: 2, error: 3 };
    return levels[level] >= levels[this.logLevel];
  }

  log(message: string, level: "info" | "warn" | "error" | "debug" = "info") {
    if (!this.shouldLog(level)) return;

    const timestamp = new Date().toISOString();
    const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`;

    switch (level) {
      case "error":
        console.error(logMessage);
        break;
      case "warn":
        console.warn(logMessage);
        break;
      case "debug":
        console.debug(logMessage);
        break;
      default:
        console.log(logMessage);
    }
  }

  info(message: string) {
    this.log(message, "info");
  }

  warn(message: string) {
    this.log(message, "warn");
  }

  error(message: string) {
    this.log(message, "error");
  }

  debug(message: string) {
    this.log(message, "debug");
  }
}

// --- 进度显示 ---
class ProgressTracker {
  private startTime: number;
  private intervalId?: NodeJS.Timeout;
  private logger: Logger;

  constructor() {
    this.startTime = Date.now();
    this.logger = Logger.getInstance();
  }

  startProgress(message: string) {
    this.logger.info(message);
    this.intervalId = setInterval(() => {
      const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
      this.logger.info(`⏳ 进行中... 已用时: ${elapsed} 秒`);
    }, 10000); // 每10秒显示一次进度
  }

  stopProgress() {
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = undefined;
    }
    const totalTime = Math.floor((Date.now() - this.startTime) / 1000);
    this.logger.info(`✅ 完成! 总用时: ${totalTime} 秒`);
  }

  getElapsedTime(): number {
    return Math.floor((Date.now() - this.startTime) / 1000);
  }
}

// --- 文件工具类 ---
class FileUtils {
  private static logger = Logger.getInstance();

  static async getFileSize(filePath: string): Promise<number> {
    try {
      const stats = await fs.stat(filePath);
      return stats.size;
    } catch (error) {
      this.logger.error(`获取文件大小失败: ${filePath} - ${error}`);
      throw error;
    }
  }

  static async validateFiles(
    files: string[],
    maxFileSize: number,
    maxTotalSize: number
  ): Promise<{
    totalSize: number;
    warnings: string[];
  }> {
    let totalSize = 0;
    const warnings: string[] = [];

    for (const file of files) {
      try {
        const size = await this.getFileSize(file);
        totalSize += size;

        if (size > maxFileSize) {
          warnings.push(
            `文件 ${path.basename(file)} 过大 (${(size / 1024 / 1024).toFixed(
              2
            )} MB)`
          );
        }
      } catch (error) {
        this.logger.warn(`无法获取文件大小: ${file}`);
      }
    }

    if (totalSize > maxTotalSize) {
      warnings.push(
        `总文件大小过大 (${(totalSize / 1024 / 1024).toFixed(2)} MB)`
      );
    }

    return { totalSize, warnings };
  }

  static async createDirectory(dirPath: string): Promise<void> {
    try {
      await fs.mkdir(dirPath, { recursive: true });
    } catch (error) {
      this.logger.error(`创建目录失败: ${dirPath} - ${error}`);
      throw error;
    }
  }

  static async cleanupDirectory(dirPath: string): Promise<void> {
    try {
      if (existsSync(dirPath)) {
        await fs.rm(dirPath, { recursive: true, force: true });
      }
    } catch (error) {
      this.logger.warn(`清理目录失败: ${dirPath} - ${error}`);
    }
  }
}

// --- Pinata 上传器类 ---
class PinataUploader {
  private pinata: PinataSDK;
  private config: Config;
  private logger: Logger;

  constructor(config: Config) {
    this.config = config;
    this.pinata = new PinataSDK({
      pinataJwt: config.pinataJwt,
      pinataGateway: config.pinataGateway,
    });
    this.logger = Logger.getInstance();
  }

  async testAuthentication(): Promise<boolean> {
    try {
      const result = await this.pinata.testAuthentication();
      this.logger.info("✅ Pinata 认证成功!");
      return true;
    } catch (error) {
      this.logger.error(`Pinata 认证失败: ${error}`);
      return false;
    }
  }

  async uploadDirectoryWithRetry(dirPath: string): Promise<string> {
    this.logger.info(`📁 正在上传文件夹: ${dirPath}`);

    let lastError: Error | null = null;

    for (let attempt = 1; attempt <= this.config.maxRetries; attempt++) {
      try {
        const files = await this.readDirectoryFiles(dirPath);
        const totalSizeBytes = files.reduce((sum, file) => sum + file.size, 0);
        let sizeDisplay: string;
        if (totalSizeBytes < 1024) {
          sizeDisplay = `${totalSizeBytes} B`;
        } else if (totalSizeBytes < 1024 * 1024) {
          sizeDisplay = `${(totalSizeBytes / 1024).toFixed(2)} KB`;
        } else {
          sizeDisplay = `${(totalSizeBytes / 1024 / 1024).toFixed(2)} MB`;
        }
        this.logger.info(
          `📁 找到 ${files.length} 个文件,总大小: ${sizeDisplay}`
        );

        const progress = new ProgressTracker();
        progress.startProgress("🚀 开始上传到 Pinata...");

        try {
          const response = (await Promise.race([
            this.pinata.upload.public.fileArray(files),
            new Promise<never>((_, reject) =>
              setTimeout(
                () =>
                  reject(new Error("上传超时,请检查网络连接或尝试压缩文件")),
                this.config.uploadTimeout
              )
            ),
          ])) as any;

          progress.stopProgress();
          this.logger.info(`✅ 文件夹上传成功! CID: ${response.cid}`);
          return response.cid;
        } catch (error) {
          progress.stopProgress();
          throw error;
        }
      } catch (error) {
        lastError = error as Error;
        this.logger.error(`第 ${attempt} 次上传失败: ${error}`);

        if (attempt < this.config.maxRetries) {
          this.logger.info(
            `⏳ ${this.config.retryDelay / 1000} 秒后重试... (${attempt}/${
              this.config.maxRetries
            })`
          );
          await new Promise((resolve) =>
            setTimeout(resolve, this.config.retryDelay)
          );
        }
      }
    }

    this.logger.error(`上传失败,已重试 ${this.config.maxRetries} 次`);
    throw lastError;
  }

  async uploadSingleFile(
    filePath: string,
    options: { name?: string } = {}
  ): Promise<string> {
    this.logger.info(`📁 正在上传单个文件: ${filePath}`);

    try {
      const content = await fs.readFile(filePath);
      const fileName = options.name || path.basename(filePath);
      const file = new File([content], fileName, {
        type: this.getMimeType(fileName),
      });

      const response = await this.pinata.upload.public.file(file);
      this.logger.info(`✅ 单个文件上传成功! CID: ${response.cid}`);
      return response.cid;
    } catch (error) {
      this.logger.error(`单个文件上传失败: ${error}`);
      throw error;
    }
  }

  async uploadMetadata(metadata: any, fileName: string): Promise<string> {
    this.logger.info(`📄 正在上传元数据: ${fileName}`);

    try {
      const content = JSON.stringify(metadata, null, 2);
      const file = new File([content], fileName, {
        type: "application/json",
      });

      const response = await this.pinata.upload.public.file(file);
      this.logger.info(`✅ 元数据上传成功! CID: ${response.cid}`);
      return response.cid;
    } catch (error) {
      this.logger.error(`元数据上传失败: ${error}`);
      throw error;
    }
  }

  async pinByCid(cid: string): Promise<any> {
    this.logger.info(`📌 正在 Pin CID: ${cid}`);

    try {
      const response = await this.pinata.upload.public.cid(cid);
      this.logger.info(`✅ CID Pin 成功!`);
      return response;
    } catch (error) {
      this.logger.error(`CID Pin 失败: ${error}`);
      throw error;
    }
  }

  async checkPinQueue(): Promise<any> {
    this.logger.info(`📊 检查 Pin 队列状态`);

    try {
      const jobs = await this.pinata.files.public.queue().status("prechecking");
      this.logger.info(`✅ 队列状态获取成功!`);
      return jobs;
    } catch (error) {
      this.logger.error(`队列状态获取失败: ${error}`);
      throw error;
    }
  }

  async uploadTestFile(): Promise<string> {
    this.logger.info(`🧪 正在上传测试文件`);

    try {
      const testContent = "Hello Pinata! This is a test file.";
      const testFile = new File([testContent], "test.txt", {
        type: "text/plain",
      });

      const response = await this.pinata.upload.public.file(testFile);
      this.logger.info(`✅ 测试文件上传成功! CID: ${response.cid}`);
      return response.cid;
    } catch (error) {
      this.logger.error(`测试文件上传失败: ${error}`);
      throw error;
    }
  }

  private async readDirectoryFiles(dirPath: string): Promise<File[]> {
    const files: File[] = [];
    const entries = await fs.readdir(dirPath, { withFileTypes: true });

    for (const entry of entries) {
      const fullPath = path.join(dirPath, entry.name);
      if (entry.isDirectory()) {
        files.push(...(await this.readDirectoryFiles(fullPath)));
      } else {
        const content = await fs.readFile(fullPath);
        const fileName = entry.name;
        const file = new File([content], fileName, {
          type: this.getMimeType(fileName),
        });
        files.push(file);
      }
    }
    return files;
  }

  private async readDirectoryFilesWithCustomName(
    dirPath: string,
    customName?: string
  ): Promise<File[]> {
    const files: File[] = [];
    const entries = await fs.readdir(dirPath, { withFileTypes: true });

    for (const entry of entries) {
      const fullPath = path.join(dirPath, entry.name);
      if (entry.isDirectory()) {
        files.push(
          ...(await this.readDirectoryFilesWithCustomName(fullPath, customName))
        );
      } else {
        const content = await fs.readFile(fullPath);
        const fileName = entry.name;
        const file = new File([content], fileName, {
          type: this.getMimeType(fileName),
        });
        files.push(file);
      }
    }
    return files;
  }

  private getMimeType(fileName: string): string {
    const ext = path.extname(fileName).toLowerCase();
    const mimeTypes: Record<string, string> = {
      ".png": "image/png",
      ".jpg": "image/jpeg",
      ".jpeg": "image/jpeg",
      ".gif": "image/gif",
      ".json": "application/json",
      ".txt": "text/plain",
      ".md": "text/markdown",
    };
    return mimeTypes[ext] || "application/octet-stream";
  }
}

// --- 批量处理类 ---
class BatchProcessor {
  private uploader: PinataUploader;
  private config: Config;
  private logger: Logger;

  constructor(uploader: PinataUploader, config: Config) {
    this.uploader = uploader;
    this.config = config;
    this.logger = Logger.getInstance();
  }

  async processBatchCollection(
    generateBothVersions: boolean = true
  ): Promise<BatchUploadResult> {
    this.logger.info("🚀 开始处理批量 NFT 集合");

    const assetsDir = path.resolve(__dirname, "..", "..", "assets");
    const imagesInputDir = path.join(assetsDir, "batch_images");

    if (!existsSync(imagesInputDir)) {
      throw new Error(`❌ 输入目录不存在: ${imagesInputDir}`);
    }

    // 1. 上传图片文件夹
    this.logger.info("📁 正在上传图片文件夹...");
    const imagesFolderCid = await this.uploader.uploadDirectoryWithRetry(
      imagesInputDir
    );
    this.logger.info(`✅ 图片文件夹上传完成! CID: ${imagesFolderCid}`);
    this.logger.info(`📝 文件夹名称说明:`);
    this.logger.info(`   - Pinata 界面显示: 'folder_from_sdk' (SDK 默认行为)`);
    this.logger.info(`   - 实际访问路径: ipfs://${imagesFolderCid}/文件名`);
    this.logger.info(
      `   - 例如: ipfs://${imagesFolderCid}/1.png, ipfs://${imagesFolderCid}/2.png`
    );
    this.logger.info(
      `   - Gateway 访问: https://gateway.pinata.cloud/ipfs/${imagesFolderCid}/`
    );

    // 2. 生成元数据
    const imageFiles = (await fs.readdir(imagesInputDir)).filter((f) =>
      /\.(png|jpg|jpeg|gif)$/i.test(f)
    );

    // 验证文件
    const { totalSize, warnings } = await FileUtils.validateFiles(
      imageFiles.map((f) => path.join(imagesInputDir, f)),
      this.config.maxFileSize,
      this.config.maxTotalSize
    );

    warnings.forEach((warning) => this.logger.warn(warning));

    // 3. 生成元数据文件
    const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
    const outputDir = path.join(
      __dirname,
      "..",
      "output",
      `batch-upload-${timestamp}`
    );
    const resultsDir = path.join(outputDir, "results");

    await FileUtils.createDirectory(outputDir);
    await FileUtils.createDirectory(resultsDir);

    // 重新排序图片文件
    imageFiles.sort(
      (a, b) => parseInt(path.parse(a).name) - parseInt(path.parse(b).name)
    );

    let metadataWithSuffixCid: string | undefined;
    let metadataWithoutSuffixCid: string | undefined;

    if (generateBothVersions) {
      const { withSuffixCid, withoutSuffixCid } =
        await this.generateBothMetadataVersions(
          imageFiles,
          imagesFolderCid,
          outputDir
        );
      metadataWithSuffixCid = withSuffixCid;
      metadataWithoutSuffixCid = withoutSuffixCid;
    } else {
      metadataWithoutSuffixCid = await this.generateSingleMetadataVersion(
        imageFiles,
        imagesFolderCid,
        outputDir
      );
    }

    // 4. 保存结果
    const uploadResult: BatchUploadResult = {
      timestamp: new Date().toISOString(),
      imagesFolderCid,
      metadataWithSuffixCid,
      metadataWithoutSuffixCid,
      imageCount: imageFiles.length,
      metadataFiles: imageFiles.map((fileName) => ({
        tokenId: path.parse(fileName).name,
        metadataFileWithSuffix: `${path.parse(fileName).name}${
          this.config.metadataSuffix
        }`,
        metadataFileWithoutSuffix: `${path.parse(fileName).name}`,
      })),
      totalSize,
      uploadTime: Date.now(),
    };

    await this.saveResults(
      uploadResult,
      resultsDir,
      outputDir,
      generateBothVersions
    );

    this.logger.info("✨ 批量流程完成");
    this.logger.info(`📊 上传统计:`);
    this.logger.info(`   - 图片数量: ${uploadResult.imageCount}`);
    this.logger.info(`   - 图片文件夹 CID: ${uploadResult.imagesFolderCid}`);
    if (generateBothVersions) {
      this.logger.info(
        `   - 带${this.config.metadataSuffix}后缀元数据 CID: ${uploadResult.metadataWithSuffixCid}`
      );
      this.logger.info(
        `   - 不带后缀元数据 CID: ${uploadResult.metadataWithoutSuffixCid}`
      );
      this.logger.info(`📝 根据你的 NFT 合约需求选择 Base URI:`);
      this.logger.info(
        `   - 需要${this.config.metadataSuffix}后缀: ipfs://${uploadResult.metadataWithSuffixCid}/`
      );
      this.logger.info(
        `   - 不需要后缀: ipfs://${uploadResult.metadataWithoutSuffixCid}/`
      );
    } else {
      this.logger.info(
        `   - 不带后缀元数据 CID: ${uploadResult.metadataWithoutSuffixCid}`
      );
      this.logger.info(
        `📝 在合约中将 Base URI 设置为: ipfs://${uploadResult.metadataWithoutSuffixCid}/`
      );
    }
    this.logger.info(
      `📄 详细结果已保存到: ${path.join(resultsDir, "upload-result.json")}`
    );

    return uploadResult;
  }

  private async generateBothMetadataVersions(
    imageFiles: string[],
    imagesFolderCid: string,
    outputDir: string
  ): Promise<{ withSuffixCid: string; withoutSuffixCid: string }> {
    const metadataWithSuffixDir = path.join(outputDir, "metadata-json");
    const metadataWithoutSuffixDir = path.join(outputDir, "metadata");

    await FileUtils.cleanupDirectory(metadataWithSuffixDir);
    await FileUtils.cleanupDirectory(metadataWithoutSuffixDir);
    await FileUtils.createDirectory(metadataWithSuffixDir);
    await FileUtils.createDirectory(metadataWithoutSuffixDir);

    // 生成两种版本的元数据文件
    for (const fileName of imageFiles) {
      const tokenId = path.parse(fileName).name;
      const metadata = this.createMetadata(tokenId, imagesFolderCid, fileName);

      await fs.writeFile(
        path.join(
          metadataWithSuffixDir,
          `${tokenId}${this.config.metadataSuffix}`
        ),
        JSON.stringify(metadata, null, 2)
      );

      await fs.writeFile(
        path.join(metadataWithoutSuffixDir, `${tokenId}`),
        JSON.stringify(metadata, null, 2)
      );
    }

    this.logger.info(
      `📁 正在上传带${this.config.metadataSuffix}后缀的元数据文件夹...`
    );
    const withSuffixCid = await this.uploader.uploadDirectoryWithRetry(
      metadataWithSuffixDir
    );
    this.logger.info(
      `✅ 带${this.config.metadataSuffix}后缀元数据文件夹上传完成! CID: ${withSuffixCid}`
    );

    this.logger.info("📁 正在上传不带后缀的元数据文件夹...");
    const withoutSuffixCid = await this.uploader.uploadDirectoryWithRetry(
      metadataWithoutSuffixDir
    );
    this.logger.info(
      `✅ 不带后缀元数据文件夹上传完成! CID: ${withoutSuffixCid}`
    );

    // 清理临时文件夹
    await FileUtils.cleanupDirectory(metadataWithSuffixDir);
    await FileUtils.cleanupDirectory(metadataWithoutSuffixDir);

    return { withSuffixCid, withoutSuffixCid };
  }

  private async generateSingleMetadataVersion(
    imageFiles: string[],
    imagesFolderCid: string,
    outputDir: string
  ): Promise<string> {
    const metadataWithoutSuffixDir = path.join(outputDir, "metadata");

    await FileUtils.cleanupDirectory(metadataWithoutSuffixDir);
    await FileUtils.createDirectory(metadataWithoutSuffixDir);

    for (const fileName of imageFiles) {
      const tokenId = path.parse(fileName).name;
      const metadata = this.createMetadata(tokenId, imagesFolderCid, fileName);

      await fs.writeFile(
        path.join(metadataWithoutSuffixDir, `${tokenId}`),
        JSON.stringify(metadata, null, 2)
      );
    }

    this.logger.info("📁 正在上传不带后缀的元数据文件夹...");
    const withoutSuffixCid = await this.uploader.uploadDirectoryWithRetry(
      metadataWithoutSuffixDir
    );

    await FileUtils.cleanupDirectory(metadataWithoutSuffixDir);
    return withoutSuffixCid;
  }

  private createMetadata(
    tokenId: string,
    imagesFolderCid: string,
    fileName: string
  ) {
    return {
      name: `MetaCore #${tokenId}`,
      description: "MetaCore 集合中的一个独特成员。",
      image: `ipfs://${imagesFolderCid}/${fileName}`,
      attributes: [{ trait_type: "ID", value: parseInt(tokenId) }],
    };
  }

  private async saveResults(
    uploadResult: BatchUploadResult,
    resultsDir: string,
    outputDir: string,
    generateBothVersions: boolean
  ): Promise<void> {
    const resultFilePath = path.join(resultsDir, "upload-result.json");
    await fs.writeFile(resultFilePath, JSON.stringify(uploadResult, null, 2));
    this.logger.info(`📄 上传结果已保存到: ${resultFilePath}`);

    const readmeContent = this.generateReadmeContent(
      uploadResult,
      generateBothVersions
    );
    const readmePath = path.join(outputDir, "README.md");
    await fs.writeFile(readmePath, readmeContent);
    this.logger.info(`📄 说明文档已保存到: ${readmePath}`);
  }

  private generateReadmeContent(
    uploadResult: BatchUploadResult,
    generateBothVersions: boolean
  ): string {
    return `# Pinata 批量上传结果

## 📅 上传时间
${new Date().toLocaleString()}

## 📊 上传统计
- 图片数量: ${uploadResult.imageCount}
- 图片文件夹 CID: ${uploadResult.imagesFolderCid}
${
  generateBothVersions
    ? `- 带${this.config.metadataSuffix}后缀元数据文件夹 CID: ${uploadResult.metadataWithSuffixCid}
- 不带后缀元数据文件夹 CID: ${uploadResult.metadataWithoutSuffixCid}
- 文件格式: 带${this.config.metadataSuffix}后缀 + 不带后缀(兼容所有 NFT 合约)`
    : `- 不带后缀元数据文件夹 CID: ${uploadResult.metadataWithoutSuffixCid}
- 文件格式: 不带后缀(标准 NFT 合约格式)`
}

## 🔗 访问链接
${
  generateBothVersions
    ? `- 带${this.config.metadataSuffix}后缀 Base URI: ipfs://${uploadResult.metadataWithSuffixCid}/
- 不带后缀 Base URI: ipfs://${uploadResult.metadataWithoutSuffixCid}/
- 带${this.config.metadataSuffix}后缀 Gateway: https://gateway.pinata.cloud/ipfs/${uploadResult.metadataWithSuffixCid}/
- 不带后缀 Gateway: https://gateway.pinata.cloud/ipfs/${uploadResult.metadataWithoutSuffixCid}/`
    : `- Base URI: ipfs://${uploadResult.metadataWithoutSuffixCid}/
- Gateway URL: https://gateway.pinata.cloud/ipfs/${uploadResult.metadataWithoutSuffixCid}/`
}

## 🚀 使用方法
${
  generateBothVersions
    ? `根据你的 NFT 合约需求选择:
- 需要${this.config.metadataSuffix}后缀: 使用 \`ipfs://${uploadResult.metadataWithSuffixCid}/\`
- 不需要后缀: 使用 \`ipfs://${uploadResult.metadataWithoutSuffixCid}/\``
    : `在智能合约中设置 Base URI 为: \`ipfs://${uploadResult.metadataWithoutSuffixCid}/\``
}
`;
  }
}

// --- 单个文件处理类 ---
class SingleFileProcessor {
  private uploader: PinataUploader;
  private logger: Logger;

  constructor(uploader: PinataUploader) {
    this.uploader = uploader;
    this.logger = Logger.getInstance();
  }

  async processSingleFile(): Promise<SingleUploadResult> {
    this.logger.info("🚀 开始处理单个文件上传");

    const assetsDir = path.resolve(__dirname, "..", "..", "assets");
    const singleImageDir = path.join(assetsDir, "image");

    if (!existsSync(singleImageDir)) {
      throw new Error(`⚠️  图片目录不存在: ${singleImageDir}`);
    }

    const imageFiles = (await fs.readdir(singleImageDir)).filter((f) =>
      /\.(png|jpg|jpeg|gif)$/i.test(f)
    );

    if (imageFiles.length === 0) {
      throw new Error("⚠️  image 目录下没有找到图片文件");
    }

    const firstImage = imageFiles[0];
    const singleImagePath = path.join(singleImageDir, firstImage);
    const imageName = path.parse(firstImage).name;
    this.logger.info(`📁 选择图片进行测试: ${firstImage}`);

    const imageCid = await this.uploader.uploadSingleFile(singleImagePath);

    const metadata = {
      name: `MetaCore #${imageName}`,
      description: "单个 NFT 示例",
      image: `ipfs://${imageCid}`,
      attributes: [{ trait_type: "Type", value: "Single" }],
    };

    const metadataFileName = `${imageName}-metadata.json`;
    const metadataCid = await this.uploader.uploadMetadata(
      metadata,
      metadataFileName
    );

    const uploadResult: SingleUploadResult = {
      timestamp: new Date().toISOString(),
      imageCid,
      metadataCid,
      imageUrl: `ipfs://${imageCid}`,
      metadataUrl: `ipfs://${metadataCid}`,
      gatewayImageUrl: `https://gateway.pinata.cloud/ipfs/${imageCid}`,
      gatewayMetadataUrl: `https://gateway.pinata.cloud/ipfs/${metadataCid}`,
      metadata,
      uploadTime: Date.now(),
    };

    await this.saveSingleFileResults(uploadResult);

    return uploadResult;
  }

  private async saveSingleFileResults(
    uploadResult: SingleUploadResult
  ): Promise<void> {
    const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
    const outputDir = path.join(
      __dirname,
      "..",
      "output",
      `single-upload-${timestamp}`
    );
    const resultsDir = path.join(outputDir, "results");

    await FileUtils.createDirectory(outputDir);
    await FileUtils.createDirectory(resultsDir);

    const singleResultFilePath = path.join(resultsDir, "upload-result.json");
    await fs.writeFile(
      singleResultFilePath,
      JSON.stringify(uploadResult, null, 2)
    );
    this.logger.info(`📄 单个文件上传结果已保存到: ${singleResultFilePath}`);

    const readmeContent = this.generateSingleFileReadme(uploadResult);
    const readmePath = path.join(outputDir, "README.md");
    await fs.writeFile(readmePath, readmeContent);
    this.logger.info(`📄 说明文档已保存到: ${readmePath}`);
  }

  private generateSingleFileReadme(uploadResult: SingleUploadResult): string {
    return `# Pinata 单个文件上传结果

## 📅 上传时间
${new Date().toLocaleString()}

## 📊 上传信息
- 图片 CID: ${uploadResult.imageCid}
- 元数据 CID: ${uploadResult.metadataCid}

## 🔗 访问链接
- 图片: https://gateway.pinata.cloud/ipfs/${uploadResult.imageCid}
- 元数据: https://gateway.pinata.cloud/ipfs/${uploadResult.metadataCid}

## 📁 文件结构
\`\`\`
${path.dirname(uploadResult.gatewayImageUrl)}/
├── results/
│   └── upload-result.json     # 详细上传结果
└── README.md                  # 说明文档
\`\`\`
`;
  }
}

// --- 主程序 ---
async function main() {
  const startTime = Date.now();
  const logger = Logger.getInstance();

  try {
    // 1. 加载配置
    const configManager = new ConfigManager();
    const config = configManager.getConfig();

    // 2. 初始化上传器
    const uploader = new PinataUploader(config);

    // 3. 测试认证
    const authSuccess = await uploader.testAuthentication();
    if (!authSuccess) {
      logger.error("Pinata 认证失败,程序退出");
      return;
    }

    // 4. 解析命令行参数
    const mode = process.argv[2] || "batch";
    const noSuffix = process.argv.includes("--no-suffix");

    logger.info("📋 上传模式说明:");
    logger.info("  🎯 single: 单个文件模式 - 上传单个图片 + 单个 JSON");
    logger.info("  📦 batch: 批量模式 - 上传整个文件夹 + 批量 JSON");
    logger.info("  🧪 test: 测试模式 - 上传测试文件");
    logger.info("  📌 pin: Pin by CID 模式");
    logger.info("  📊 queue: 检查 Pin 队列状态");

    // 5. 执行相应的模式
    switch (mode) {
      case "single":
        logger.info("🎯 选择: 单个文件模式");
        const singleProcessor = new SingleFileProcessor(uploader);
        await singleProcessor.processSingleFile();
        break;

      case "test":
        logger.info("🧪 选择: 测试模式");
        await uploader.uploadTestFile();
        break;

      case "pin":
        logger.info("📌 选择: Pin by CID 模式");
        const cid = process.argv[3];
        if (!cid) {
          logger.error("❌ 请提供 CID 参数,例如: pnpm pin <CID>");
          return;
        }
        await uploader.pinByCid(cid);
        break;

      case "queue":
        logger.info("📊 选择: 检查队列状态");
        await uploader.checkPinQueue();
        break;

      default:
        logger.info("📦 选择: 批量模式");
        const batchProcessor = new BatchProcessor(uploader, config);

        if (noSuffix) {
          logger.info("📝 模式: 只生成不带后缀的元数据文件");
          await batchProcessor.processBatchCollection(false);
        } else {
          logger.info(
            `📝 模式: 生成带${config.metadataSuffix}后缀和不带后缀的元数据文件`
          );
          await batchProcessor.processBatchCollection(true);
        }
        break;
    }
  } catch (error) {
    logger.error(`脚本执行失败: ${error}`);
    process.exit(1);
  } finally {
    const totalTime = Math.floor((Date.now() - startTime) / 1000);
    logger.info(`脚本总执行时间: ${totalTime} 秒`);
    logger.info("🎉 脚本执行完成,正在退出...");
    process.exit(0);
  }
}

// 如果直接运行此文件,则执行主程序
if (require.main === module) {
  main();
}

export {
  ConfigManager,
  Logger,
  ProgressTracker,
  FileUtils,
  PinataUploader,
  BatchProcessor,
  SingleFileProcessor,
  type UploadResult,
  type BatchUploadResult,
  type SingleUploadResult,
  type Config,
};

这是一个功能完整的 NFT 元数据上传工具,使用 TypeScript 开发,专门用于将 NFT 图片和元数据批量上传到 Pinata IPFS 平台。该工具采用面向对象的设计架构,包含配置管理、日志系统、进度跟踪、文件验证等核心模块,支持单个文件上传和批量文件夹上传两种模式。工具会自动生成符合 NFT 标准的 JSON 元数据文件,支持自定义文件后缀,并提供带后缀和不带后缀两种格式以兼容不同的智能合约需求。整个系统具备完善的错误处理机制、重试逻辑、超时控制,以及详细的上传结果保存和文档生成功能,确保 NFT 项目的元数据能够稳定可靠地部署到去中心化存储网络中。

现在!让我们一步一步测试验证脚本的各个功能。

第一步:编译检查

TypeScript 类型检查

检查代码语法和类型错误,但不生成 JavaScript 文件

确保代码可以正常编译,相当于"语法检查"

bash 复制代码
➜ npx tsc --noEmit

✅ 编译检查通过 - 没有语法错误

第二步:测试模式(验证连接)

运行测试模式

上传一个测试文件到 Pinata

验证 Pinata 连接和认证是否正常

bash 复制代码
➜ pnpm test

> pinata-nft-uploader@1.0.0 test /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/typescript
> ts-node src/index.ts test

[dotenv@17.2.1] injecting env (6) from .env -- tip: ⚙️  specify custom .env file path with { path: '/custom/path/.env' }
[2025-08-07T01:59:34.495Z] [INFO] ✅ Pinata 认证成功!
[2025-08-07T01:59:34.495Z] [INFO] 📋 上传模式说明:
[2025-08-07T01:59:34.495Z] [INFO]   🎯 single: 单个文件模式 - 上传单个图片 + 单个 JSON
[2025-08-07T01:59:34.495Z] [INFO]   📦 batch: 批量模式 - 上传整个文件夹 + 批量 JSON
[2025-08-07T01:59:34.495Z] [INFO]   🧪 test: 测试模式 - 上传测试文件
[2025-08-07T01:59:34.495Z] [INFO]   📌 pin: Pin by CID 模式
[2025-08-07T01:59:34.495Z] [INFO]   📊 queue: 检查 Pin 队列状态
[2025-08-07T01:59:34.495Z] [INFO] 🧪 选择: 测试模式
[2025-08-07T01:59:34.496Z] [INFO] 🧪 正在上传测试文件
[2025-08-07T01:59:36.225Z] [INFO] ✅ 测试文件上传成功! CID: bafkreibtmw4qacliibxj2uflkm7hf4bpkusaafkcp6g5opnvmyufymcqyy
[2025-08-07T01:59:36.225Z] [INFO] 脚本总执行时间: 2 秒
[2025-08-07T01:59:36.225Z] [INFO] 🎉 脚本执行完成,正在退出...

✅ 测试模式成功 - Pinata 连接正常,脚本正确退出

第三步:单个文件模式测试

单个文件上传模式

上传单个图片 + 对应的元数据

处理单个 NFT 的上传

bash 复制代码
➜ pnpm single

> pinata-nft-uploader@1.0.0 single /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/typescript
> ts-node src/index.ts single

[dotenv@17.2.1] injecting env (6) from .env -- tip: 🔐 prevent committing .env to code: https://dotenvx.com/precommit
[2025-08-07T02:28:08.520Z] [INFO] ✅ Pinata 认证成功!
[2025-08-07T02:28:08.525Z] [INFO] 📋 上传模式说明:
[2025-08-07T02:28:08.525Z] [INFO]   🎯 single: 单个文件模式 - 上传单个图片 + 单个 JSON
[2025-08-07T02:28:08.525Z] [INFO]   📦 batch: 批量模式 - 上传整个文件夹 + 批量 JSON
[2025-08-07T02:28:08.525Z] [INFO]   🧪 test: 测试模式 - 上传测试文件
[2025-08-07T02:28:08.525Z] [INFO]   📌 pin: Pin by CID 模式
[2025-08-07T02:28:08.525Z] [INFO]   📊 queue: 检查 Pin 队列状态
[2025-08-07T02:28:08.525Z] [INFO] 🎯 选择: 单个文件模式
[2025-08-07T02:28:08.525Z] [INFO] 🚀 开始处理单个文件上传
[2025-08-07T02:28:08.526Z] [INFO] 📁 选择图片进行测试: IMG_20210626_180340.jpg
[2025-08-07T02:28:08.526Z] [INFO] 📁 正在上传单个文件: /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/assets/image/IMG_20210626_180340.jpg
[2025-08-07T02:28:57.759Z] [INFO] ✅ 单个文件上传成功! CID: bafybeifwvvo7qacd5ksephyxbqkqjih2dmm2ffgqa6u732b2evw5iijppi
[2025-08-07T02:28:57.760Z] [INFO] 📄 正在上传元数据: IMG_20210626_180340-metadata.json
[2025-08-07T02:28:58.834Z] [INFO] ✅ 元数据上传成功! CID: bafkreiddeekjrptrgdtuicyzgtwknvrweiqltlhs7bx7naj7s2mq3fo6um
[2025-08-07T02:28:58.836Z] [INFO] 📄 单个文件上传结果已保存到: /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/typescript/output/single-upload-2025-08-07T02-28-58-834Z/results/upload-result.json
[2025-08-07T02:28:58.872Z] [INFO] 📄 说明文档已保存到: /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/typescript/output/single-upload-2025-08-07T02-28-58-834Z/README.md
[2025-08-07T02:28:58.872Z] [INFO] 脚本总执行时间: 51 秒
[2025-08-07T02:28:58.872Z] [INFO] 🎉 脚本执行完成,正在退出...

✅ 单个文件模式测试成功!

从结果可以看到:

  1. ✅ 认证成功 - Pinata 连接正常
  2. ✅ 图片上传成功 - CID: bafybeifwvvo7qacd5ksephyxbqkqjih2dmm2ffgqa6u732b2evw5iijppi
  3. ✅ 元数据文件名关联 - IMG_20210626_180340-metadata.json
  4. ✅ 元数据上传成功 - CID: bafkreiddeekjrptrgdtuicyzgtwknvrweiqltlhs7bx7naj7s2mq3fo6um
  5. ✅ 结果保存 - 自动保存到本地文件
  6. ✅ 脚本正确退出 - 没有卡住

第四步:批量模式测试(不带后缀)

bash 复制代码
➜ pnpm batch:no-suffix

> pinata-nft-uploader@1.0.0 batch:no-suffix /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/typescript
> ts-node src/index.ts batch --no-suffix

[dotenv@17.2.1] injecting env (6) from .env -- tip: ⚙️  enable debug logging with { debug: true }
[2025-08-07T02:33:17.412Z] [INFO] ✅ Pinata 认证成功!
[2025-08-07T02:33:17.415Z] [INFO] 📋 上传模式说明:
[2025-08-07T02:33:17.415Z] [INFO]   🎯 single: 单个文件模式 - 上传单个图片 + 单个 JSON
[2025-08-07T02:33:17.415Z] [INFO]   📦 batch: 批量模式 - 上传整个文件夹 + 批量 JSON
[2025-08-07T02:33:17.415Z] [INFO]   🧪 test: 测试模式 - 上传测试文件
[2025-08-07T02:33:17.415Z] [INFO]   📌 pin: Pin by CID 模式
[2025-08-07T02:33:17.415Z] [INFO]   📊 queue: 检查 Pin 队列状态
[2025-08-07T02:33:17.415Z] [INFO] 📦 选择: 批量模式
[2025-08-07T02:33:17.415Z] [INFO] 📝 模式: 只生成不带后缀的元数据文件
[2025-08-07T02:33:17.416Z] [INFO] 🚀 开始处理批量 NFT 集合
[2025-08-07T02:33:17.416Z] [INFO] 📁 正在上传图片文件夹...
[2025-08-07T02:33:17.416Z] [INFO] 📁 正在上传文件夹: /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/assets/batch_images
[2025-08-07T02:33:17.430Z] [INFO] 📁 找到 3 个文件,总大小: 16.40 MB
[2025-08-07T02:33:17.430Z] [INFO] 🚀 开始上传到 Pinata...
[2025-08-07T02:33:27.431Z] [INFO] ⏳ 进行中... 已用时: 10 秒
[2025-08-07T02:33:37.432Z] [INFO] ⏳ 进行中... 已用时: 20 秒
[2025-08-07T02:33:47.433Z] [INFO] ⏳ 进行中... 已用时: 30 秒
[2025-08-07T02:33:48.234Z] [INFO] ✅ 完成! 总用时: 30 秒
[2025-08-07T02:33:48.235Z] [INFO] ✅ 文件夹上传成功! CID: bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e
[2025-08-07T02:33:48.235Z] [INFO] ✅ 图片文件夹上传完成! CID: bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e
[2025-08-07T02:33:48.235Z] [INFO] 📝 文件夹名称说明:
[2025-08-07T02:33:48.235Z] [INFO]    - Pinata 界面显示: 'folder_from_sdk' (SDK 默认行为)
[2025-08-07T02:33:48.235Z] [INFO]    - 实际访问路径: ipfs://bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e/文件名
[2025-08-07T02:33:48.235Z] [INFO]    - 例如: ipfs://bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e/1.png, ipfs://bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e/2.png
[2025-08-07T02:33:48.235Z] [INFO]    - Gateway 访问: https://gateway.pinata.cloud/ipfs/bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e/
[2025-08-07T02:33:48.238Z] [INFO] 📁 正在上传不带后缀的元数据文件夹...
[2025-08-07T02:33:48.238Z] [INFO] 📁 正在上传文件夹: /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/typescript/output/batch-upload-2025-08-07T02-33-48-236Z/metadata
[2025-08-07T02:33:48.239Z] [INFO] 📁 找到 3 个文件,总大小: 765 B
[2025-08-07T02:33:48.239Z] [INFO] 🚀 开始上传到 Pinata...
[2025-08-07T02:33:48.906Z] [INFO] ✅ 完成! 总用时: 0 秒
[2025-08-07T02:33:48.906Z] [INFO] ✅ 文件夹上传成功! CID: bafybeihnyl6zp4q4xusvpt77nzl7ljg3ec6xhbgaflzrn6bzrpo7nivgzq
[2025-08-07T02:33:48.908Z] [INFO] 📄 上传结果已保存到: /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/typescript/output/batch-upload-2025-08-07T02-33-48-236Z/results/upload-result.json
[2025-08-07T02:33:48.948Z] [INFO] 📄 说明文档已保存到: /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/typescript/output/batch-upload-2025-08-07T02-33-48-236Z/README.md
[2025-08-07T02:33:48.948Z] [INFO] ✨ 批量流程完成
[2025-08-07T02:33:48.948Z] [INFO] 📊 上传统计:
[2025-08-07T02:33:48.948Z] [INFO]    - 图片数量: 3
[2025-08-07T02:33:48.948Z] [INFO]    - 图片文件夹 CID: bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e
[2025-08-07T02:33:48.948Z] [INFO]    - 不带后缀元数据 CID: bafybeihnyl6zp4q4xusvpt77nzl7ljg3ec6xhbgaflzrn6bzrpo7nivgzq
[2025-08-07T02:33:48.948Z] [INFO] 📝 在合约中将 Base URI 设置为: ipfs://bafybeihnyl6zp4q4xusvpt77nzl7ljg3ec6xhbgaflzrn6bzrpo7nivgzq/
[2025-08-07T02:33:48.948Z] [INFO] 📄 详细结果已保存到: /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/typescript/output/batch-upload-2025-08-07T02-33-48-236Z/results/upload-result.json
[2025-08-07T02:33:48.948Z] [INFO] 脚本总执行时间: 33 秒
[2025-08-07T02:33:48.948Z] [INFO] 🎉 脚本执行完成,正在退出...

✅ 批量模式(不带后缀)测试成功!

从结果可以看到:

  1. ✅ 图片文件夹上传成功 - CID: bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e
  2. ✅ 文件大小显示正确 - 16.40 MB (图片) / 765 B (元数据)
  3. ✅ 文件夹名称说明 - 清楚解释了 folder_from_sdk 的问题
  4. ✅ 元数据文件夹上传成功 - CID: bafybeihnyl6zp4q4xusvpt77nzl7ljg3ec6xhbgaflzrn6bzrpo7nivgzq
  5. ✅ 进度显示正常 - 每10秒显示一次进度
  6. ✅ 详细统计信息 - 显示所有 CID 和 Base URI
  7. ✅ 脚本正确退出 - 没有卡住

第五步:批量模式测试(带后缀)

bash 复制代码
➜ pnpm batch

> pinata-nft-uploader@1.0.0 batch /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/typescript
> ts-node src/index.ts batch

[dotenv@17.2.1] injecting env (6) from .env -- tip: 🔐 prevent committing .env to code: https://dotenvx.com/precommit
[2025-08-07T02:37:34.559Z] [INFO] ✅ Pinata 认证成功!
[2025-08-07T02:37:34.563Z] [INFO] 📋 上传模式说明:
[2025-08-07T02:37:34.563Z] [INFO]   🎯 single: 单个文件模式 - 上传单个图片 + 单个 JSON
[2025-08-07T02:37:34.563Z] [INFO]   📦 batch: 批量模式 - 上传整个文件夹 + 批量 JSON
[2025-08-07T02:37:34.563Z] [INFO]   🧪 test: 测试模式 - 上传测试文件
[2025-08-07T02:37:34.563Z] [INFO]   📌 pin: Pin by CID 模式
[2025-08-07T02:37:34.563Z] [INFO]   📊 queue: 检查 Pin 队列状态
[2025-08-07T02:37:34.563Z] [INFO] 📦 选择: 批量模式
[2025-08-07T02:37:34.564Z] [INFO] 📝 模式: 生成带.json后缀和不带后缀的元数据文件
[2025-08-07T02:37:34.564Z] [INFO] 🚀 开始处理批量 NFT 集合
[2025-08-07T02:37:34.564Z] [INFO] 📁 正在上传图片文件夹...
[2025-08-07T02:37:34.565Z] [INFO] 📁 正在上传文件夹: /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/assets/batch_images
[2025-08-07T02:37:34.583Z] [INFO] 📁 找到 3 个文件,总大小: 16.40 MB
[2025-08-07T02:37:34.583Z] [INFO] 🚀 开始上传到 Pinata...
[2025-08-07T02:37:44.584Z] [INFO] ⏳ 进行中... 已用时: 10 秒
[2025-08-07T02:37:54.585Z] [INFO] ⏳ 进行中... 已用时: 20 秒
[2025-08-07T02:38:04.586Z] [INFO] ⏳ 进行中... 已用时: 30 秒
[2025-08-07T02:38:14.587Z] [INFO] ⏳ 进行中... 已用时: 40 秒
[2025-08-07T02:38:24.588Z] [INFO] ⏳ 进行中... 已用时: 50 秒
[2025-08-07T02:38:34.590Z] [INFO] ⏳ 进行中... 已用时: 60 秒
[2025-08-07T02:38:44.591Z] [INFO] ⏳ 进行中... 已用时: 70 秒
[2025-08-07T02:38:54.592Z] [INFO] ⏳ 进行中... 已用时: 80 秒
[2025-08-07T02:39:04.593Z] [INFO] ⏳ 进行中... 已用时: 90 秒
[2025-08-07T02:39:14.594Z] [INFO] ⏳ 进行中... 已用时: 100 秒
[2025-08-07T02:39:24.594Z] [INFO] ⏳ 进行中... 已用时: 110 秒
[2025-08-07T02:39:34.596Z] [INFO] ⏳ 进行中... 已用时: 120 秒
[2025-08-07T02:39:44.597Z] [INFO] ⏳ 进行中... 已用时: 130 秒
[2025-08-07T02:39:54.598Z] [INFO] ⏳ 进行中... 已用时: 140 秒
[2025-08-07T02:40:04.600Z] [INFO] ⏳ 进行中... 已用时: 150 秒
[2025-08-07T02:40:14.601Z] [INFO] ⏳ 进行中... 已用时: 160 秒
[2025-08-07T02:40:24.602Z] [INFO] ⏳ 进行中... 已用时: 170 秒
[2025-08-07T02:40:34.604Z] [INFO] ⏳ 进行中... 已用时: 180 秒
[2025-08-07T02:40:44.604Z] [INFO] ⏳ 进行中... 已用时: 190 秒
[2025-08-07T02:40:54.605Z] [INFO] ⏳ 进行中... 已用时: 200 秒
[2025-08-07T02:41:04.605Z] [INFO] ⏳ 进行中... 已用时: 210 秒
[2025-08-07T02:41:14.553Z] [INFO] ⏳ 进行中... 已用时: 219 秒
[2025-08-07T02:41:24.554Z] [INFO] ⏳ 进行中... 已用时: 229 秒
[2025-08-07T02:41:32.290Z] [INFO] ✅ 完成! 总用时: 237 秒
[2025-08-07T02:41:32.290Z] [INFO] ✅ 文件夹上传成功! CID: bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e
[2025-08-07T02:41:32.290Z] [INFO] ✅ 图片文件夹上传完成! CID: bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e
[2025-08-07T02:41:32.290Z] [INFO] 📝 文件夹名称说明:
[2025-08-07T02:41:32.290Z] [INFO]    - Pinata 界面显示: 'folder_from_sdk' (SDK 默认行为)
[2025-08-07T02:41:32.290Z] [INFO]    - 实际访问路径: ipfs://bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e/文件名
[2025-08-07T02:41:32.290Z] [INFO]    - 例如: ipfs://bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e/1.png, ipfs://bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e/2.png
[2025-08-07T02:41:32.290Z] [INFO]    - Gateway 访问: https://gateway.pinata.cloud/ipfs/bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e/
[2025-08-07T02:41:32.305Z] [INFO] 📁 正在上传带.json后缀的元数据文件夹...
[2025-08-07T02:41:32.305Z] [INFO] 📁 正在上传文件夹: /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/typescript/output/batch-upload-2025-08-07T02-41-32-295Z/metadata-json
[2025-08-07T02:41:32.307Z] [INFO] 📁 找到 3 个文件,总大小: 765 B
[2025-08-07T02:41:32.307Z] [INFO] 🚀 开始上传到 Pinata...
[2025-08-07T02:41:33.014Z] [INFO] ✅ 完成! 总用时: 0 秒
[2025-08-07T02:41:33.014Z] [INFO] ✅ 文件夹上传成功! CID: bafybeiguvcmspmkhyheyh5c7wmixuiiysjpcrw4hjvvydmfhqmwsopvjk4
[2025-08-07T02:41:33.014Z] [INFO] ✅ 带.json后缀元数据文件夹上传完成! CID: bafybeiguvcmspmkhyheyh5c7wmixuiiysjpcrw4hjvvydmfhqmwsopvjk4
[2025-08-07T02:41:33.014Z] [INFO] 📁 正在上传不带后缀的元数据文件夹...
[2025-08-07T02:41:33.014Z] [INFO] 📁 正在上传文件夹: /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/typescript/output/batch-upload-2025-08-07T02-41-32-295Z/metadata
[2025-08-07T02:41:33.015Z] [INFO] 📁 找到 3 个文件,总大小: 765 B
[2025-08-07T02:41:33.015Z] [INFO] 🚀 开始上传到 Pinata...
[2025-08-07T02:41:33.591Z] [INFO] ✅ 完成! 总用时: 0 秒
[2025-08-07T02:41:33.592Z] [INFO] ✅ 文件夹上传成功! CID: bafybeihnyl6zp4q4xusvpt77nzl7ljg3ec6xhbgaflzrn6bzrpo7nivgzq
[2025-08-07T02:41:33.592Z] [INFO] ✅ 不带后缀元数据文件夹上传完成! CID: bafybeihnyl6zp4q4xusvpt77nzl7ljg3ec6xhbgaflzrn6bzrpo7nivgzq
[2025-08-07T02:41:33.596Z] [INFO] 📄 上传结果已保存到: /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/typescript/output/batch-upload-2025-08-07T02-41-32-295Z/results/upload-result.json
[2025-08-07T02:41:33.641Z] [INFO] 📄 说明文档已保存到: /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/typescript/output/batch-upload-2025-08-07T02-41-32-295Z/README.md
[2025-08-07T02:41:33.641Z] [INFO] ✨ 批量流程完成
[2025-08-07T02:41:33.641Z] [INFO] 📊 上传统计:
[2025-08-07T02:41:33.641Z] [INFO]    - 图片数量: 3
[2025-08-07T02:41:33.641Z] [INFO]    - 图片文件夹 CID: bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e
[2025-08-07T02:41:33.641Z] [INFO]    - 带.json后缀元数据 CID: bafybeiguvcmspmkhyheyh5c7wmixuiiysjpcrw4hjvvydmfhqmwsopvjk4
[2025-08-07T02:41:33.641Z] [INFO]    - 不带后缀元数据 CID: bafybeihnyl6zp4q4xusvpt77nzl7ljg3ec6xhbgaflzrn6bzrpo7nivgzq
[2025-08-07T02:41:33.642Z] [INFO] 📝 根据你的 NFT 合约需求选择 Base URI:
[2025-08-07T02:41:33.642Z] [INFO]    - 需要.json后缀: ipfs://bafybeiguvcmspmkhyheyh5c7wmixuiiysjpcrw4hjvvydmfhqmwsopvjk4/
[2025-08-07T02:41:33.642Z] [INFO]    - 不需要后缀: ipfs://bafybeihnyl6zp4q4xusvpt77nzl7ljg3ec6xhbgaflzrn6bzrpo7nivgzq/
[2025-08-07T02:41:33.642Z] [INFO] 📄 详细结果已保存到: /Users/qiaopengjun/Code/Solidity/YuanqiGenesis/polyglot-pinata-uploader/typescript/output/batch-upload-2025-08-07T02-41-32-295Z/results/upload-result.json
[2025-08-07T02:41:33.642Z] [INFO] 脚本总执行时间: 240 秒
[2025-08-07T02:41:33.642Z] [INFO] 🎉 脚本执行完成,正在退出...

✅ 批量模式(带后缀)测试成功!

从结果可以看到:

  1. ✅ 图片文件夹上传成功 - CID: bafybeia22ed2lhakgwu76ojojhuavlxkccpclciy6hgqsmn6o7ur7cw44e
  2. ✅ 带后缀元数据上传成功 - CID: bafybeiguvcmspmkhyheyh5c7wmixuiiysjpcrw4hjvvydmfhqmwsopvjk4
  3. ✅ 不带后缀元数据上传成功 - CID: bafybeihnyl6zp4q4xusvpt77nzl7ljg3ec6xhbgaflzrn6bzrpo7nivgzq
  4. ✅ 进度显示正常 - 每10秒显示一次进度
  5. ✅ 详细统计信息 - 显示所有 CID 和 Base URI 选项
  6. ✅ 脚本正确退出 - 没有卡住

�� 测试总结

所有功能都测试成功!

✅ 测试结果:

  • 编译检查 ✅ - 无语法错误

  • 测试模式 ✅ - Pinata 连接正常

  • 单个文件模式 ✅ - 图片和元数据上传成功

  • 批量模式(不带后缀) ✅ - 批量上传成功

  • 批量模式(带后缀) ✅ - 批量上传成功

脚本现在运行得很好,所有功能都正常工作!

总结

这篇文章详细介绍并演示了一款功能完备的 Pinata NFT 元数据上传工具。它不仅仅是一个简单的脚本,而是一个采用 TypeScript 构建、基于面向对象思想设计的综合解决方案。

其核心优势在于:

  • 自动化与高效率:无论是单个 NFT 还是上千个文件的集合,工具都能一键完成图片上传、元数据生成和元数据上传的全过程,并自动生成清晰的结果报告和说明文档。
  • 高度灵活性 :支持单个文件和批量文件夹两种模式,并且在批量模式下,能同时生成带 .json 后缀和不带后缀的两种元数据文件夹,灵活适配各类智能合约对 baseURI 的不同要求。
  • 稳健可靠:内置了完善的配置管理、日志系统、进度跟踪、上传超时控制和自动重试机制,确保在处理大量文件或网络不稳定时,任务依然能可靠地完成。

通过文中的多轮实战测试可以看出,该工具在连接认证、单文件处理、批量处理等各个环节都表现出色,运行稳定。对于任何需要将NFT资产部署到 IPFS 的开发者来说,这都是一个可以直接用于生产环境、显著提升开发效率的开源利器。✨

参考

推荐阅读

  • 《NFT 开发核心步骤:本地 IPFS 节点搭建与元数据上传实战》
  • 《Python x IPFS:构建生产级的 NFT 元数据自动化流程》
  • 《从命令行到官方库:用 Go 语言精通 NFT 元数据 IPFS 上传》
  • 《Rust x IPFS:从命令行到官方库,精通NFT元数据上传》
相关推荐
我是不会赢的14 分钟前
使用 decimal 包解决 go float 浮点数运算失真
开发语言·后端·golang·浮点数
yuqifang30 分钟前
写一个简单的Java示例
java·后端
Re27531 分钟前
分库分表后主键总“撞车”?5种全局唯一ID方案让你不再头疼
后端
陈随易1 小时前
VSCode v1.103发布,AI编程任务列表,可用GPT 5和Claude 4.1
前端·后端·程序员
中等生1 小时前
Python的隐形枷锁:GIL如何"绑架"了你的多线程梦想
后端·python
Pitayafruit1 小时前
【📕分布式锁通关指南 12】源码剖析redisson如何利用Redis数据结构实现Semaphore和CountDownLatch
redis·分布式·后端
哈基米喜欢哈哈哈1 小时前
Netty入门(二)——网络传输
java·开发语言·网络·后端
尘心不灭1 小时前
Spring Boot 项目代码笔记
spring boot·笔记·后端
小高0072 小时前
GPT-5震撼登场!从单一模型到协作系统,AI架构的革命性突破
前端·后端·chatgpt
不老刘2 小时前
AI助力前端开发:Claude生成Element UI + TinyMCE集成135编辑器的代码
后端