使い方

各スニペットは独立した関数として定義。必要なものだけコピーして使う。
共通の前提として、以下の定数が定義済みとする。

const API_ID = process.env.API_ID;
const AFF_API = process.env.AFFILIATE_ID_API;   // 末尾990〜999
const AFF_SITE = process.env.AFFILIATE_ID_SITE;  // 通常ID(ブログ掲載用)

1. fetchItems — 商品情報の取得

用法: 任意の検索条件で商品情報APIを叩く汎用関数。全スニペットの土台。
思想: パラメータをオブジェクトで受け取り、未指定キーは送信しない。呼び出し側の負担を最小にする。
計算量: O(1)(1回のHTTPリクエスト)

async function fetchItems(opts = {}) {
  const p = new URLSearchParams({
    api_id: API_ID, affiliate_id: AFF_API,
    site: opts.site ?? "FANZA", output: "json",
  });
  for (const k of ["service","floor","hits","offset","sort","keyword","cid","gte_date","lte_date"]) {
    if (opts[k] != null) p.set(k, String(opts[k]));
  }
  // article / article_id は配列対応
  (opts.article ?? []).forEach((v, i) => p.set(`article[${i}]`, v));
  (opts.article_id ?? []).forEach((v, i) => p.set(`article_id[${i}]`, String(v)));

  const res = await fetch(`https://api.dmm.com/affiliate/v3/ItemList?${p}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()).result;
}

2. fetchAll — 全件取得(ページネーション)

用法: fetchItems をラップし、offset を自動で進めて全件(または上限件数)を回収する。
思想: API の 1 リクエスト最大 100 件制限を隠蔽。呼び出し側は件数を気にしなくてよい。
計算量: O(⌈N/100⌉) HTTPリクエスト(N = 全体件数、最大 offset 50000)

async function fetchAll(opts = {}, maxItems = 500) {
  const items = [];
  let offset = 1;
  while (items.length < maxItems) {
    const hits = Math.min(100, maxItems - items.length);
    const result = await fetchItems({ ...opts, hits, offset });
    if (!result.items?.length) break;
    items.push(...result.items);
    offset += hits;
    if (offset > result.total_count) break;
  }
  return items;
}

3. fetchNewReleases — 直近N日の新着取得

用法: fetchNewReleases(7) で過去7日間の新着を発売日順で返す。
思想: アフィリエイトブログは鮮度が命。日次バッチの定形パターンを1行で呼べるようにする。
計算量: O(1)(内部で fetchItems を1回呼ぶ)

async function fetchNewReleases(days = 7, floor = "videoa", hits = 30) {
  const since = new Date();
  since.setDate(since.getDate() - days);
  return fetchItems({
    service: "digital", floor, sort: "date", hits,
    gte_date: since.toISOString().slice(0, 19),
  });
}

4. searchByActress — 女優IDで商品を絞り込む

用法: 女優検索APIで得た actress_id を渡して、その女優の商品一覧を取得する。
思想: 女優単位の特集記事はCVRが高い。女優ID→商品の導線を最短で繋ぐ。
計算量: O(1)

async function searchByActress(actressId, opts = {}) {
  return fetchItems({
    service: "digital", floor: "videoa", sort: opts.sort ?? "date",
    hits: opts.hits ?? 20,
    article: ["actress"],
    article_id: [actressId],
  });
}

5. swapAffiliateId — アフィリエイトIDの差し替え

用法: APIレスポンス内の全URLを、ブログ掲載用の通常IDに一括置換する。
思想: APIリクエスト用ID(990〜999)とリンク掲載用IDは別物。置換を忘れると報酬が入らない。
計算量: O(N)(N = items数)

function swapAffiliateId(items) {
  return items.map((item) => ({
    ...item,
    affiliateURL: item.affiliateURL?.replace(AFF_API, AFF_SITE),
  }));
}

6. filterByReview — レビュー評価でフィルタ

用法: filterByReview(items, 4.0, 3) で「平均4.0以上 かつ レビュー3件以上」の商品だけ残す。
思想: レビュー高評価の商品は信頼性が高く、記事で紹介しやすい。低評価や件数不足のノイズを除外する。
計算量: O(N)

function filterByReview(items, minAvg = 3.5, minCount = 1) {
  return items.filter((item) => {
    const r = item.review;
    return r && Number(r.average) >= minAvg && Number(r.count) >= minCount;
  });
}

7. groupByGenre — ジャンル別グルーピング

用法: 取得した商品配列をジャンルIDごとにまとめる。ジャンル別ランキング記事の素材作りに使う。
思想: 1回のAPI取得結果を複数記事に分割できれば、API呼び出し回数を節約できる。
計算量: O(N × G)(N = items数、G = 1商品あたりの平均ジャンル数。通常 G ≤ 10)

function groupByGenre(items) {
  const map = new Map();
  for (const item of items) {
    for (const g of item.iteminfo?.genre ?? []) {
      if (!map.has(g.id)) map.set(g.id, { name: g.name, items: [] });
      map.get(g.id).items.push(item);
    }
  }
  return map;
}

8. rateLimitedBatch — レートリミット付きバッチ実行

用法: 複数の非同期関数を一定間隔で順次実行する。APIの連続リクエストでBANされないための安全装置。
思想: DMM APIに公式のレートリミット仕様は明記されていないが、短時間に大量リクエストを送るのはマナー違反であり、アカウント停止リスクがある。
計算量: O(N)(N = タスク数。合計実行時間は N × interval ms)

async function rateLimitedBatch(tasks, interval = 1000) {
  const results = [];
  for (const task of tasks) {
    results.push(await task());
    await new Promise((r) => setTimeout(r, interval));
  }
  return results;
}

// 使用例: 複数女優の商品を1秒間隔で取得
// const ids = [15365, 26225, 1044248];
// const results = await rateLimitedBatch(
//   ids.map((id) => () => searchByActress(id)),
//   1000
// );

9. buildItemCard — 商品カードHTML生成

用法: 1商品分の紹介HTMLを生成する。ブログのテンプレートに合わせて編集して使う。
思想: API取得→HTML生成を関数化しておけば、記事の体裁変更はこの関数だけ直せばよい。
計算量: O(1)

function buildItemCard(item) {
  const url = item.affiliateURL ?? "#";
  const img = item.imageURL?.large ?? item.imageURL?.small ?? "";
  const price = item.prices?.price ?? "";
  const review = item.review ? `★${item.review.average}(${item.review.count}件)` : "";
  const genres = (item.iteminfo?.genre ?? []).map((g) => g.name).join(" / ");

  return `<div class="item-card">
  <a href="${url}" rel="nofollow" target="_blank">
    <img src="${img}" alt="${item.title}" loading="lazy" />
  </a>
  <h3><a href="${url}" rel="nofollow">${item.title}</a></h3>
  <ul>
    ${price ? `<li>${price}</li>` : ""}
    ${review ? `<li>${review}</li>` : ""}
    ${genres ? `<li>${genres}</li>` : ""}
    ${item.date ? `<li>${item.date}</li>` : ""}
  </ul>
</div>`;
}

10. fetchFloorMap — フロアコード逆引きマップ生成

用法: フロアAPIの結果をフラットな Map<code, {service, name}> に変換する。サービス・フロアコードの手打ちミスを防ぐ。
思想: DMM のフロア構造はサイト→サービス→フロアの3階層。API を叩く度にコードを調べるのは非効率なので、起動時にマップを1回作ってキャッシュする。
計算量: O(S × F)(S = サービス数、F = サービスあたりのフロア数。全体で数十件程度)

async function fetchFloorMap() {
  const p = new URLSearchParams({
    api_id: API_ID, affiliate_id: AFF_API, output: "json",
  });
  const res = await fetch(`https://api.dmm.com/affiliate/v3/FloorList?${p}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();

  const map = new Map();
  for (const site of data.result.site) {
    for (const svc of site.service ?? []) {
      for (const floor of svc.floor ?? []) {
        map.set(floor.code, {
          site: site.code, service: svc.code,
          floorName: floor.name, serviceName: svc.name,
        });
      }
    }
  }
  return map;
}

// 使用例:
// const floors = await fetchFloorMap();
// const info = floors.get("videoa");
// → { site: "FANZA", service: "digital", floorName: "ビデオ", serviceName: "動画" }

組み合わせ例

上記スニペットを組み合わせた日次バッチの骨格。

async function dailyUpdate() {
  // 1. 過去3日の新着を取得
  const result = await fetchNewReleases(3, "videoa", 100);
  if (!result.items?.length) return;

  // 2. レビュー高評価のみ抽出
  const quality = filterByReview(result.items, 3.5, 2);

  // 3. アフィリエイトID差し替え
  const ready = swapAffiliateId(quality);

  // 4. ジャンル別に分割
  const byGenre = groupByGenre(ready);

  // 5. ジャンル別にHTML生成
  const fs = await import("node:fs/promises");
  await fs.mkdir("./output", { recursive: true });

  for (const [id, { name, items }] of byGenre) {
    const cards = items.map(buildItemCard).join("\n");
    const html = `<h2>${name}</h2>\n<div class="item-grid">\n${cards}\n</div>`;
    await fs.writeFile(`./output/genre-${id}.html`, html, "utf-8");
  }
}

dailyUpdate().catch(console.error);