const functions = require("firebase-functions"); const express = require("express"); const cors = require("cors"); const axios = require("axios"); const admin = require("firebase-admin"); admin.initializeApp(); const db = admin.firestore(); const app = express(); app.use(cors({ origin: true })); app.use(express.json()); const SP_API_CONFIG = { clientId: functions.config().spapi.client_id, clientSecret: functions.config().spapi.client_secret, refreshToken: functions.config().spapi.refresh_token, endpoint: "https://sellingpartnerapi-fe.amazon.com", tokenUrl: "https://api.amazon.com/auth/o2/token", }; let cachedToken = null; let tokenExpiry = null; async function getAccessToken() { const now = Date.now(); if (cachedToken && tokenExpiry && now < tokenExpiry) { return cachedToken; } try { console.log("Requesting access token..."); const response = await axios.post( SP_API_CONFIG.tokenUrl, new URLSearchParams({ grant_type: "refresh_token", client_id: SP_API_CONFIG.clientId, client_secret: SP_API_CONFIG.clientSecret, refresh_token: SP_API_CONFIG.refreshToken, }), { headers: { "Content-Type": "application/x-www-form-urlencoded" } } ); console.log("Access token obtained"); cachedToken = response.data.access_token; tokenExpiry = now + (response.data.expires_in - 60) * 1000; return cachedToken; } catch (error) { console.error("LWA Token Error:", error.message); throw error; } } async function spApiRequest(method, path, params, data) { const token = await getAccessToken(); const url = SP_API_CONFIG.endpoint + path; const response = await axios({ method: method, url: url, params: params || {}, data: data || null, headers: { "x-amz-access-token": token, "User-Agent": "MySellerTool/1.0", }, }); return response.data; } // ---------------------------------------- // /inventory // ---------------------------------------- app.get("/inventory", async (req, res) => { try { const result = await spApiRequest("GET", "/fba/inventory/v1/summaries", { granularityType: "Marketplace", granularityId: "A1VC38T7YXB528", marketplaceIds: "A1VC38T7YXB528", }, null); res.json({ success: true, data: result }); } catch (error) { console.error("Error:", error.message); res.status(500).json({ success: false, error: error.message }); } }); // ---------------------------------------- // /status-check // 全商品のstatus・quantity内訳を高速確認(価格・手数料取得なし) // ---------------------------------------- app.get("/status-check", async (req, res) => { try { const sellerId = req.query.sellerId || "A3S9S4T21BT6AO"; let allItems = []; let nextToken = null; let pageCount = 0; const maxPages = 20; do { pageCount++; const params = { marketplaceIds: "A1VC38T7YXB528", includedData: "summaries,attributes", }; if (nextToken) params.pageToken = nextToken; const result = await spApiRequest("GET", "/listings/2021-08-01/items/" + sellerId, params, null); const items = result.items || []; for (const item of items) { const summary = item.summaries ? item.summaries[0] : null; if (!summary) continue; const qtyAttr = item.attributes && item.attributes.fulfillment_availability && item.attributes.fulfillment_availability[0]; const quantity = qtyAttr ? qtyAttr.quantity : 0; allItems.push({ sku: item.sku, asin: summary.asin, status: summary.status, condition: summary.conditionType, quantity: quantity, }); } nextToken = result.pagination && result.pagination.nextToken ? result.pagination.nextToken : null; } while (nextToken && pageCount < maxPages); // status別集計 const statusCount = {}; const statusAndQtyCount = { active_with_qty: 0, active_no_qty: 0, inactive_with_qty: 0, inactive_no_qty: 0 }; for (const item of allItems) { const s = item.status || "UNKNOWN"; statusCount[s] = (statusCount[s] || 0) + 1; if (s === "ACTIVE" && item.quantity > 0) statusAndQtyCount.active_with_qty++; if (s === "ACTIVE" && item.quantity === 0) statusAndQtyCount.active_no_qty++; if (s !== "ACTIVE" && item.quantity > 0) statusAndQtyCount.inactive_with_qty++; if (s !== "ACTIVE" && item.quantity === 0) statusAndQtyCount.inactive_no_qty++; } res.json({ success: true, total: allItems.length, statusCount, statusAndQtyCount, items: allItems, }); } catch (error) { console.error("Error:", error.message); res.status(500).json({ success: false, error: error.message }); } }); // ---------------------------------------- // /products-with-pricing // ---------------------------------------- app.get("/products-with-pricing", async (req, res) => { try { const sellerId = req.query.sellerId || "A3S9S4T21BT6AO"; const limit = parseInt(req.query.limit) || 100; const onlyInStock = req.query.onlyInStock === "true"; const listingsResult = await spApiRequest("GET", "/listings/2021-08-01/items/" + sellerId, { marketplaceIds: "A1VC38T7YXB528", includedData: "summaries,attributes,issues,offers", }, null); let items = listingsResult.items || []; if (onlyInStock) { items = items.filter(function(item) { const qtyAttr = item.attributes && item.attributes.fulfillment_availability && item.attributes.fulfillment_availability[0]; return qtyAttr && qtyAttr.quantity > 0; }); } items = items.slice(0, limit); const products = []; for (const item of items) { const summary = item.summaries ? item.summaries[0] : null; if (!summary) continue; const asin = summary.asin; const sku = item.sku; const priceAttr = item.attributes && item.attributes.purchasable_offer && item.attributes.purchasable_offer[0]; const ourPrice = priceAttr && priceAttr.our_price && priceAttr.our_price[0] && priceAttr.our_price[0].schedule && priceAttr.our_price[0].schedule[0] ? priceAttr.our_price[0].schedule[0].value_with_tax : 0; const qtyAttr = item.attributes && item.attributes.fulfillment_availability && item.attributes.fulfillment_availability[0]; const quantity = qtyAttr ? qtyAttr.quantity : 0; let lowestPrice = null; let rivalOffers = []; await new Promise(function(resolve) { setTimeout(resolve, 1000); }); try { const rivalResult = await spApiRequest("GET", "/products/pricing/v0/items/" + asin + "/offers", { MarketplaceId: "A1VC38T7YXB528", ItemCondition: "New" }, null); const offers = rivalResult.payload && rivalResult.payload.Offers ? rivalResult.payload.Offers : []; rivalOffers = offers.map(function(offer) { return { price: offer.ListingPrice && offer.ListingPrice.Amount ? offer.ListingPrice.Amount : 0, shipping: offer.Shipping && offer.Shipping.Amount ? offer.Shipping.Amount : 0, isFBA: offer.IsFulfilledByAmazon, }; }); if (offers.length > 0) { lowestPrice = Math.min.apply(Math, offers.map(function(o) { return (o.ListingPrice && o.ListingPrice.Amount ? o.ListingPrice.Amount : 0) + (o.Shipping && o.Shipping.Amount ? o.Shipping.Amount : 0); })); } } catch (err) { console.log("Rival price error (" + asin + "):", err.message); } let fee = 0; await new Promise(function(resolve) { setTimeout(resolve, 1000); }); try { const feeResult = await spApiRequest("POST", "/products/fees/v0/listings/" + encodeURIComponent(sku) + "/feesEstimate", { marketplaceId: "A1VC38T7YXB528" }, { FeesEstimateRequest: { MarketplaceId: "A1VC38T7YXB528", IsAmazonFulfilled: false, PriceToEstimateFees: { ListingPrice: { CurrencyCode: "JPY", Amount: parseFloat(ourPrice) }, Shipping: { CurrencyCode: "JPY", Amount: 0 } }, Identifier: sku } }); const feeData = feeResult.payload && feeResult.payload.FeesEstimateResult && feeResult.payload.FeesEstimateResult.FeesEstimate && feeResult.payload.FeesEstimateResult.FeesEstimate.TotalFeesEstimate ? feeResult.payload.FeesEstimateResult.FeesEstimate.TotalFeesEstimate.Amount : 0; fee = parseFloat(feeData); } catch (err) { console.log("Fee error (" + asin + "):", err.message); } products.push({ asin, sku, name: summary.itemName, image: summary.mainImage ? summary.mainImage.link : null, condition: summary.conditionType, status: summary.status, ourPrice, quantity, lowestPrice, rivalOffers, fee }); } res.json({ success: true, products, total: products.length, totalAvailable: listingsResult.numberOfResults }); } catch (error) { console.error("Error:", error.message); res.status(500).json({ success: false, error: error.message }); } }); // ---------------------------------------- // /all-in-stock-products // quantity>0 かつ status=ACTIVE のみ取得 // ---------------------------------------- app.get("/all-in-stock-products", async (req, res) => { try { const sellerId = req.query.sellerId || "A3S9S4T21BT6AO"; let allInStockProducts = []; let nextToken = null; let pageCount = 0; const maxPages = 20; do { pageCount++; console.log("Fetching page " + pageCount + "..."); const params = { marketplaceIds: "A1VC38T7YXB528", includedData: "summaries,attributes,issues,offers", }; if (nextToken) params.pageToken = nextToken; const listingsResult = await spApiRequest("GET", "/listings/2021-08-01/items/" + sellerId, params, null); const items = listingsResult.items || []; for (const item of items) { const summary = item.summaries ? item.summaries[0] : null; if (!summary) continue; const qtyAttr = item.attributes && item.attributes.fulfillment_availability && item.attributes.fulfillment_availability[0]; const quantity = qtyAttr ? qtyAttr.quantity : 0; const status = summary.status; // 在庫あり かつ ACTIVEのみ if (quantity <= 0 || status !== "ACTIVE") continue; const asin = summary.asin; const sku = item.sku; const priceAttr = item.attributes && item.attributes.purchasable_offer && item.attributes.purchasable_offer[0]; const ourPrice = priceAttr && priceAttr.our_price && priceAttr.our_price[0] && priceAttr.our_price[0].schedule && priceAttr.our_price[0].schedule[0] ? priceAttr.our_price[0].schedule[0].value_with_tax : 0; let lowestPrice = null; let rivalOffers = []; await new Promise(function(resolve) { setTimeout(resolve, 1000); }); try { const rivalResult = await spApiRequest("GET", "/products/pricing/v0/items/" + asin + "/offers", { MarketplaceId: "A1VC38T7YXB528", ItemCondition: "New" }, null); const offers = rivalResult.payload && rivalResult.payload.Offers ? rivalResult.payload.Offers : []; rivalOffers = offers.map(function(offer) { return { price: offer.ListingPrice && offer.ListingPrice.Amount ? offer.ListingPrice.Amount : 0, shipping: offer.Shipping && offer.Shipping.Amount ? offer.Shipping.Amount : 0, isFBA: offer.IsFulfilledByAmazon, }; }); if (offers.length > 0) { lowestPrice = Math.min.apply(Math, offers.map(function(o) { return (o.ListingPrice && o.ListingPrice.Amount ? o.ListingPrice.Amount : 0) + (o.Shipping && o.Shipping.Amount ? o.Shipping.Amount : 0); })); } } catch (err) { console.log("Rival price error (" + asin + "):", err.message); } let fee = 0; await new Promise(function(resolve) { setTimeout(resolve, 1000); }); try { const feeResult = await spApiRequest("POST", "/products/fees/v0/listings/" + encodeURIComponent(sku) + "/feesEstimate", { marketplaceId: "A1VC38T7YXB528" }, { FeesEstimateRequest: { MarketplaceId: "A1VC38T7YXB528", IsAmazonFulfilled: false, PriceToEstimateFees: { ListingPrice: { CurrencyCode: "JPY", Amount: parseFloat(ourPrice) }, Shipping: { CurrencyCode: "JPY", Amount: 0 } }, Identifier: sku } }); const feeData = feeResult.payload && feeResult.payload.FeesEstimateResult && feeResult.payload.FeesEstimateResult.FeesEstimate && feeResult.payload.FeesEstimateResult.FeesEstimate.TotalFeesEstimate ? feeResult.payload.FeesEstimateResult.FeesEstimate.TotalFeesEstimate.Amount : 0; fee = parseFloat(feeData); } catch (err) { console.log("Fee error (" + asin + "):", err.message); } allInStockProducts.push({ asin, sku, name: summary.itemName, image: summary.mainImage ? summary.mainImage.link : null, condition: summary.conditionType, status, ourPrice, quantity, lowestPrice, rivalOffers, fee }); } nextToken = listingsResult.pagination && listingsResult.pagination.nextToken ? listingsResult.pagination.nextToken : null; } while (nextToken && pageCount < maxPages); res.json({ success: true, products: allInStockProducts, total: allInStockProducts.length, pagesProcessed: pageCount }); } catch (error) { console.error("Error:", error.message); res.status(500).json({ success: false, error: error.message }); } }); // ---------------------------------------- // /unique-in-stock-products // ASIN重複除外(同ASINで最安値SKUを残す) // ---------------------------------------- app.get("/unique-in-stock-products", async (req, res) => { try { const sellerId = req.query.sellerId || "A3S9S4T21BT6AO"; const allResponse = await axios.get("https://asia-northeast1-mysellertool-2e842.cloudfunctions.net/api/all-in-stock-products?sellerId=" + sellerId); const allProducts = allResponse.data.products; const grouped = {}; for (const p of allProducts) { if (!grouped[p.asin]) grouped[p.asin] = []; grouped[p.asin].push(p); } const uniqueProducts = []; for (const asin in grouped) { const products = grouped[asin]; products.sort(function(a, b) { return a.ourPrice - b.ourPrice; }); uniqueProducts.push(products[0]); } res.json({ success: true, products: uniqueProducts, total: uniqueProducts.length }); } catch (error) { console.error("Error:", error.message); res.status(500).json({ success: false, error: error.message }); } }); // ---------------------------------------- // exports は必ずファイル末尾に // ---------------------------------------- exports.api = functions.region("asia-northeast1").runWith({ timeoutSeconds: 540, memory: "1GB" }).https.onRequest(app);