Playwrightでメルカリ出品を一括取得して自作ECに移行する手順

Playwrightでメルカリ出品を一括取得して自作ECに移行する手順

※本記事にはプロモーション(広告・アフィリエイトリンク)を含みます。

メルカリで出品件数が増えてくると、同じ商品を自分のサイトにも並べたくなる。問題は「すでにメルカリへ入力した情報を、もう一度手で打ち直すのか」という手間だ。

古着208件・画像1147枚をメルカリから自作ECサイトへ移行したとき、PlaywrightでAPIレスポンスを傍受することで商品情報の再入力をゼロにした。この記事ではその手順を再現できる形で書く。移行先のスタックはNext.js(App Router)+ Prisma + SQLiteだが、データ取得部分は構成を問わず使える。

この手法の前提を確認する

実行前に3点を押さえておく。

対象は自分の出品データだけ。 Playwrightでアクセスするのはログイン後の自分のマイページで、他人の出品を大量取得する用途ではない。

利用規約を事前に読む。 メルカリの規約は変更されることがあるので、実行前に最新版を確認してほしい。

内部APIは公開仕様ではない。 エンドポイントやレスポンス構造が予告なく変わる可能性がある。ここで示すコードは執筆時点でのものだ。

なぜPlaywrightでAPIインターセプトを選んだか

最初にHTMLの直スクレイピングを検討したが、メルカリの商品一覧はSPAで検索結果がJavaScriptで動的にレンダリングされる。DOMから直接取るより、ページが裏で叩いているAPIのJSONレスポンスを傍受するほうが構造化データをそのまま取れる。

手法 メリット デメリット
HTML直スクレイピング セットアップが簡単 JS動的レンダリングに弱い
APIインターセプト(Playwright) JSON構造で取得できる ブラウザ起動のオーバーヘッド
メルカリ公式API 公式サポート 一般向けには公開されていない(執筆時点)

Playwrightの page.on('response', ...) でネットワーク通信を傍受し、商品データが含まれるAPIレスポンスだけを拾う方針にした。

環境構築

Node.jsが入っていれば次のコマンドで始められる。

npm init -y
npm install playwright
npx playwright install chromium

TypeScriptで書く場合は追加で:

npm install -D typescript ts-node @types/node

検索APIのレスポンスを傍受してデータを取得する

コードを書く前に、ブラウザのDevToolsで事前調査する。メルカリにログインして自分の出品一覧ページを開き、NetworkタブをFetch/XHRでフィルタする。商品データがJSONで返ってくるリクエストのURLパターンを控えておく。この事前調査で傍受すべきエンドポイントのパスパターンが分かる。

import { chromium } from 'playwright';
import * as fs from 'fs';

async function fetchMyListings() {
  const browser = await chromium.launch({ headless: false }); // 最初は動作確認のためfalseで
  const context = await browser.newContext();
  const page = await context.newPage();

  const items: any[] = [];

  // APIレスポンスを傍受
  page.on('response', async (response) => {
    const url = response.url();
    // DevToolsで確認したパターンに合わせて条件を書く
    if (url.includes('/api/') && url.includes('items') && response.status() === 200) {
      try {
        const json = await response.json();
        // レスポンスのフィールド名はDevToolsで必ず確認する
        if (json?.data?.items) {
          items.push(...json.data.items);
          console.log(`取得済み: ${items.length}件`);
        }
      } catch {
        // JSONでないレスポンスは無視
      }
    }
  });

  // ログイン後に自分の出品一覧ページへ
  await page.goto('https://jp.mercari.com');
  // ※ログイン操作はメルカリのUIに合わせて実装する

  await page.goto('https://jp.mercari.com/mypage/listings');

  // 全件ロードするまでスクロール
  let prevCount = 0;
  while (true) {
    await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
    await page.waitForTimeout(2500); // ネットワーク速度で調整
    if (items.length === prevCount) break;
    prevCount = items.length;
  }

  await browser.close();

  fs.writeFileSync('mercari_items.json', JSON.stringify(items, null, 2));
  console.log(`完了: ${items.length}件を保存`);
}

fetchMyListings();

headless: false にすると画面が見えるので、スクロールのタイミングやUIの変化を確認しやすい。問題ないと確認してから true に切り替える。

画像を一括ダウンロードする

APIレスポンスには画像URLが含まれている。取得後すぐにダウンロードするのがポイントで、URLに時間で変わるトークンが付いている場合があるため、セッションを保持したまま処理するほうが安全だ。

import * as https from 'https';
import * as path from 'path';
import * as fs from 'fs';

async function downloadImage(url: string, dest: string): Promise<void> {
  return new Promise((resolve, reject) => {
    const file = fs.createWriteStream(dest);
    https.get(url, (response) => {
      response.pipe(file);
      file.on('finish', () => { file.close(); resolve(); });
    }).on('error', reject);
  });
}

async function downloadAllImages(items: any[]) {
  const dir = './product-images';
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });

  for (const item of items) {
    // フィールド名はJSONを手で確認してから書く
    const photos: string[] = item.thumbnails ?? item.photos ?? [];
    for (let i = 0; i < photos.length; i++) {
      const url = photos[i];
      const filename = `${item.id}_${i}.jpg`;
      const dest = path.join(dir, filename);
      if (fs.existsSync(dest)) continue; // 再実行時に既存はスキップ
      await downloadImage(url, dest);
    }
  }
}

フィールド名(thumbnails か photos か)はAPIのバージョンや仕様変更によって異なる場合がある。まず小規模で流してJSONを手で確認してから本番実行することを勧める。

Prisma + SQLiteへ取り込む

schema.prisma に商品モデルを定義しておく:

model Product {
  id          String   @id
  title       String
  description String?
  price       Int
  status      String
  images      String   // JSON文字列で画像パスを配列保存
  createdAt   DateTime @default(now())
}

取り込みスクリプト:

import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';

const prisma = new PrismaClient();

async function importItems() {
  const items = JSON.parse(fs.readFileSync('mercari_items.json', 'utf-8'));

  for (const item of items) {
    await prisma.product.upsert({
      where: { id: item.id },
      update: {},
      create: {
        id: item.id,
        title: item.name,
        description: item.description ?? '',
        price: item.price,
        status: item.status === 'sold_out' ? 'sold_out' : 'on_sale',
        images: JSON.stringify(
          (item.thumbnails ?? []).map((_: string, i: number) => `/product-images/${item.id}_${i}.jpg`)
        ),
      },
    });
  }

  console.log(`インポート完了: ${items.length}件`);
  await prisma.$disconnect();
}

importItems();

upsert を使うことで再実行しても既存レコードを壊さない。

画像の配信は public/ 直下に置かず、route handlerを経由する構成にした。/api/images/[filename]/route.ts で fs.readFile して返すシンプルなものだが、将来的にアクセス制御を差し込みやすい。古着のネット販売を始める手順でも触れているが、在庫・公開状態の管理は自前DBに一元化すると後の運用が楽になる。

つまずいたポイント

スクロールしても取得件数が止まる: waitForTimeout が短すぎると、APIレスポンスが返ってくる前に次のスクロールが走ってしまう。2500ms前後を目安に調整し、環境のネットワーク速度によってはさらに延ばす必要がある。

画像URLが403になる: メルカリの画像URLはセッションに紐づくトークンが付いていることがある。商品データの取得と画像ダウンロードは同一ブラウザセッション内で完結させるとこの問題を回避しやすい。

フィールド名が想定外: 先に10件だけ処理して mercari_items.json を手で確認する工程を必ず挟む。フィールド名をハードコードしてから全件流すと、構造が違ったときに全データが空になる。

まとめ

Playwrightの page.on('response', ...) を使えば、メルカリの内部APIが返すJSONを構造化データとして受け取れる。あとはPrismaで自作DBに流し込むだけで、手入力なしに商品情報を移行できる。

208件・1147枚の規模であれば、スクリプトを一度書いてしまえば再取得はそのまま流し直すだけで済む。

次のステップ: まずDevToolsを開いて、自分の出品一覧ページが叩くAPIのURLパターンを確認することから始めてほしい。そのパターンさえ分かれば、本記事のコードを雛形に動かせる。

この記事で触れたもの

よくある質問

メルカリの内部APIは公式に公開されていますか?

執筆時点では一般向けに公開されていません。本記事のインターセプト手法はブラウザが実際に通信するAPIを傍受するもので、エンドポイントや構造が予告なく変わる可能性があります。実行前に利用規約も確認してください。

Playwright以外のツールでも同じことができますか?

PuppeteerやSeleniumでも同様のネットワークインターセプトが可能です。ただしPlaywrightはAPIが整理されていてレスポンスのJSONパースが簡潔に書けるため、初めて試す場合はPlaywrightが扱いやすいと考えられます。

画像URLが時間で失効するのはどう対処しますか?

商品データの取得と画像のダウンロードを同一ブラウザセッション内で連続して実行するのが確実です。データ取得後に時間を置いてから別スクリプトでダウンロードすると403になりやすくなります。

全件取得したのに件数が合わない場合はどうすればいいですか?

スクロールの待機時間(waitForTimeout)が短いと、APIレスポンスが来る前に次のスクロールが走って取得を見落とすことがあります。まずwaitForTimeoutを3000ms以上に延ばし、headless: falseで画面を目視確認しながら動作を調整してください。