Compare commits
8 Commits
b8280ef1a0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a355359427 | ||
|
|
31cf992e77 | ||
|
|
506e2344b7 | ||
|
|
313677692b | ||
|
|
9b45546476 | ||
|
|
35087a5b2f | ||
|
|
5dbff33032 | ||
|
|
517833413e |
@@ -10,7 +10,28 @@
|
|||||||
"KillShell",
|
"KillShell",
|
||||||
"Bash(bunx *)",
|
"Bash(bunx *)",
|
||||||
"Bash(git *)",
|
"Bash(git *)",
|
||||||
"Bash(ls *)"
|
"Bash(ls *)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run db:migrate 2>&1 || true)",
|
||||||
|
"Bash(git --no-pager diff -- src/web/frontend.tsx src/web/styles.css 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"additionalDirectories": [
|
||||||
|
"/Users/nvictor/.abacusai/tmp/codellm-prompt-djc6Bc"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(grep -v \"^$\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
23
drizzle/0001_product_distributor_research.sql
Normal file
23
drizzle/0001_product_distributor_research.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE "product_distributor_research" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"product_asin" text NOT NULL,
|
||||||
|
"run_item_id" integer,
|
||||||
|
"inventory_item_id" integer,
|
||||||
|
"provider" text DEFAULT 'claude' NOT NULL,
|
||||||
|
"model" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'completed' NOT NULL,
|
||||||
|
"query_context_json" text,
|
||||||
|
"distributors_json" text,
|
||||||
|
"raw_response" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "product_distributor_research" ADD CONSTRAINT "product_distributor_research_product_asin_products_asin_fk" FOREIGN KEY ("product_asin") REFERENCES "public"."products"("asin") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "product_distributor_research" ADD CONSTRAINT "product_distributor_research_run_item_id_run_items_id_fk" FOREIGN KEY ("run_item_id") REFERENCES "public"."run_items"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "product_distributor_research" ADD CONSTRAINT "product_distributor_research_inventory_item_id_stalker_inventory_items_id_fk" FOREIGN KEY ("inventory_item_id") REFERENCES "public"."stalker_inventory_items"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_distributor_research_asin_time" ON "product_distributor_research" USING btree ("product_asin","created_at");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_distributor_research_run_item" ON "product_distributor_research" USING btree ("run_item_id");
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
"when": 1779726518779,
|
"when": 1779726518779,
|
||||||
"tag": "0000_adorable_shiver_man",
|
"tag": "0000_adorable_shiver_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780000000000,
|
||||||
|
"tag": "0001_product_distributor_research",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -510,7 +510,7 @@ async function discoverCategories(
|
|||||||
maxCategories: number,
|
maxCategories: number,
|
||||||
): Promise<CategoryInfo[]> {
|
): Promise<CategoryInfo[]> {
|
||||||
const data = await keepaGetJson(
|
const data = await keepaGetJson(
|
||||||
`/category?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&category=0`,
|
`/category?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&category=0&parents=0`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const categories = normalizeCategoryList(data);
|
const categories = normalizeCategoryList(data);
|
||||||
|
|||||||
@@ -845,7 +845,7 @@ async function discoverCategories(
|
|||||||
maxCategories: number,
|
maxCategories: number,
|
||||||
): Promise<CategoryInfo[]> {
|
): Promise<CategoryInfo[]> {
|
||||||
const data = await keepaGetJson(
|
const data = await keepaGetJson(
|
||||||
`/category?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&category=0`,
|
`/category?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&category=0&parents=0`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const categories = normalizeCategoryList(data);
|
const categories = normalizeCategoryList(data);
|
||||||
|
|||||||
@@ -542,7 +542,7 @@ async function discoverCategories(
|
|||||||
maxCategories: number,
|
maxCategories: number,
|
||||||
): Promise<CategoryInfo[]> {
|
): Promise<CategoryInfo[]> {
|
||||||
const data = await keepaGetJson(
|
const data = await keepaGetJson(
|
||||||
`/category?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&category=0`,
|
`/category?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&category=0&parents=0`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const categories = normalizeCategoryList(data);
|
const categories = normalizeCategoryList(data);
|
||||||
|
|||||||
@@ -439,3 +439,34 @@ export const stalkerInventoryItems = pgTable(
|
|||||||
index("idx_stalker_inventory_product_asin").on(t.productAsin),
|
index("idx_stalker_inventory_product_asin").on(t.productAsin),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const productDistributorResearch = pgTable(
|
||||||
|
"product_distributor_research",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
productAsin: text("product_asin")
|
||||||
|
.notNull()
|
||||||
|
.references(() => products.asin, { onDelete: "cascade" }),
|
||||||
|
runItemId: integer("run_item_id").references(
|
||||||
|
(): AnyPgColumn => runItems.id,
|
||||||
|
{ onDelete: "set null" },
|
||||||
|
),
|
||||||
|
inventoryItemId: integer("inventory_item_id").references(
|
||||||
|
(): AnyPgColumn => stalkerInventoryItems.id,
|
||||||
|
{ onDelete: "set null" },
|
||||||
|
),
|
||||||
|
provider: text("provider").notNull().default("claude"),
|
||||||
|
model: text("model").notNull(),
|
||||||
|
status: text("status").notNull().default("completed"),
|
||||||
|
queryContextJson: text("query_context_json"),
|
||||||
|
distributorsJson: text("distributors_json"),
|
||||||
|
rawResponse: text("raw_response"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("idx_distributor_research_asin_time").on(t.productAsin, t.createdAt),
|
||||||
|
index("idx_distributor_research_run_item").on(t.runItemId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
|
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
|
||||||
import { lookupKeepaUpcs, mapUpcsToAsins } from "./keepa.ts";
|
import { fetchKeepaDataBatch, lookupKeepaUpcs, mapUpcsToAsins } from "./keepa.ts";
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
@@ -215,6 +215,7 @@ test("lookupKeepaUpcs uses lightweight query params for code mapping", async ()
|
|||||||
expect(url.searchParams.has("stats")).toBe(false);
|
expect(url.searchParams.has("stats")).toBe(false);
|
||||||
expect(url.searchParams.has("buybox")).toBe(false);
|
expect(url.searchParams.has("buybox")).toBe(false);
|
||||||
expect(url.searchParams.has("days")).toBe(false);
|
expect(url.searchParams.has("days")).toBe(false);
|
||||||
|
expect(url.searchParams.get("history")).toBe("0");
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -240,3 +241,49 @@ test("lookupKeepaUpcs uses lightweight query params for code mapping", async ()
|
|||||||
expect(details.get(targetUpc)?.status).toBe("found");
|
expect(details.get(targetUpc)?.status).toBe("found");
|
||||||
expect(details.get(targetUpc)?.asin).toBe("B000LGT001");
|
expect(details.get(targetUpc)?.asin).toBe("B000LGT001");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fetchKeepaDataBatch uses token-efficient params", async () => {
|
||||||
|
const targetAsin = "B000EFF001";
|
||||||
|
const fetchMock = mock(async (input: string | URL | Request) => {
|
||||||
|
const rawUrl =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
expect(url.searchParams.get("asin")).toBe(targetAsin);
|
||||||
|
expect(url.searchParams.get("stats")).toBe("90");
|
||||||
|
expect(url.searchParams.get("days")).toBe("90");
|
||||||
|
expect(url.searchParams.get("history")).toBe("0");
|
||||||
|
expect(url.searchParams.has("buybox")).toBe(false);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
asin: targetAsin,
|
||||||
|
stats: {
|
||||||
|
current: [1999, null, null, 1234, null, null, null, null, null, null, null, 8],
|
||||||
|
avg: [2099, null, null, 1300],
|
||||||
|
min: [1799],
|
||||||
|
max: [2299],
|
||||||
|
},
|
||||||
|
csv: [[1, 1999]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokensLeft: 9,
|
||||||
|
refillRate: 21,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const details = await fetchKeepaDataBatch([targetAsin]);
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.length).toBe(1);
|
||||||
|
expect(details.get(targetAsin)?.currentPrice).toBe(19.99);
|
||||||
|
});
|
||||||
|
|||||||
@@ -57,11 +57,13 @@ function buildProductUrl(
|
|||||||
options?: {
|
options?: {
|
||||||
includeStats?: boolean;
|
includeStats?: boolean;
|
||||||
includeBuybox?: boolean;
|
includeBuybox?: boolean;
|
||||||
|
includeHistory?: boolean;
|
||||||
days?: number;
|
days?: number;
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const includeStats = options?.includeStats ?? true;
|
const includeStats = options?.includeStats ?? true;
|
||||||
const includeBuybox = options?.includeBuybox ?? true;
|
const includeBuybox = options?.includeBuybox ?? true;
|
||||||
|
const includeHistory = options?.includeHistory ?? true;
|
||||||
const days = options?.days ?? 90;
|
const days = options?.days ?? 90;
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -78,6 +80,10 @@ function buildProductUrl(
|
|||||||
params.set("buybox", "1");
|
params.set("buybox", "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!includeHistory) {
|
||||||
|
params.set("history", "0");
|
||||||
|
}
|
||||||
|
|
||||||
params.set(queryParam, values.join(","));
|
params.set(queryParam, values.join(","));
|
||||||
return `${KEEPA_BASE}/product?${params.toString()}`;
|
return `${KEEPA_BASE}/product?${params.toString()}`;
|
||||||
}
|
}
|
||||||
@@ -242,7 +248,8 @@ export async function fetchKeepaDataBatch(
|
|||||||
const chunk = canonicalAsins.slice(i, i + MAX_ASINS_PER_REQUEST);
|
const chunk = canonicalAsins.slice(i, i + MAX_ASINS_PER_REQUEST);
|
||||||
const url = buildProductUrl("asin", chunk, {
|
const url = buildProductUrl("asin", chunk, {
|
||||||
includeStats: true,
|
includeStats: true,
|
||||||
includeBuybox: true,
|
includeBuybox: false,
|
||||||
|
includeHistory: false,
|
||||||
days: 90,
|
days: 90,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -302,6 +309,7 @@ export async function lookupKeepaUpcs(
|
|||||||
const url = buildProductUrl("code", chunk, {
|
const url = buildProductUrl("code", chunk, {
|
||||||
includeStats: false,
|
includeStats: false,
|
||||||
includeBuybox: false,
|
includeBuybox: false,
|
||||||
|
includeHistory: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
642
src/server.ts
642
src/server.ts
@@ -2,14 +2,20 @@ import index from "./web/index.html";
|
|||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
import { normalizeAsin } from "./asin.ts";
|
import { normalizeAsin } from "./asin.ts";
|
||||||
import { db, client } from "./db/index.ts";
|
import { db, client } from "./db/index.ts";
|
||||||
import { analysisRevisions } from "./db/schema.ts";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { analysisRevisions, productDistributorResearch } from "./db/schema.ts";
|
||||||
import { insertObservation, refreshRunStats } from "./db/persistence.ts";
|
import { insertObservation, refreshRunStats } from "./db/persistence.ts";
|
||||||
|
import { config } from "./config.ts";
|
||||||
import {
|
import {
|
||||||
fetchKeepaDataBatch,
|
fetchKeepaDataBatch,
|
||||||
lookupKeepaUpcs,
|
lookupKeepaUpcs,
|
||||||
mapUpcsToAsins,
|
mapUpcsToAsins,
|
||||||
} from "./integrations/keepa.ts";
|
} from "./integrations/keepa.ts";
|
||||||
import { analyzeProducts } from "./integrations/llm.ts";
|
import { analyzeProducts } from "./integrations/llm.ts";
|
||||||
|
import {
|
||||||
|
searchAsinOffers,
|
||||||
|
type SearxngOfferSearchResult,
|
||||||
|
} from "./integrations/searxng.ts";
|
||||||
import {
|
import {
|
||||||
fetchSellabilityBatch,
|
fetchSellabilityBatch,
|
||||||
fetchSpApiPricingAndFees,
|
fetchSpApiPricingAndFees,
|
||||||
@@ -37,7 +43,10 @@ async function pgGet<T extends Record<string, unknown>>(
|
|||||||
query: string,
|
query: string,
|
||||||
params: unknown[] = [],
|
params: unknown[] = [],
|
||||||
): Promise<T | null> {
|
): Promise<T | null> {
|
||||||
const rows = await client.unsafe<T[]>(toPostgresSql(query), params as never[]);
|
const rows = await client.unsafe<T[]>(
|
||||||
|
toPostgresSql(query),
|
||||||
|
params as never[],
|
||||||
|
);
|
||||||
return (rows[0] as T) ?? null;
|
return (rows[0] as T) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +54,10 @@ async function pgAll<T extends Record<string, unknown>>(
|
|||||||
query: string,
|
query: string,
|
||||||
params: unknown[] = [],
|
params: unknown[] = [],
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
return client.unsafe<T[]>(toPostgresSql(query), params as never[]) as unknown as T[];
|
return client.unsafe<T[]>(
|
||||||
|
toPostgresSql(query),
|
||||||
|
params as never[],
|
||||||
|
) as unknown as T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pgRun(query: string, params: unknown[] = []): Promise<number> {
|
async function pgRun(query: string, params: unknown[] = []): Promise<number> {
|
||||||
@@ -118,7 +130,10 @@ function safeSort(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function splitRawUpcValues(input: string): string[] {
|
function splitRawUpcValues(input: string): string[] {
|
||||||
return input.split(/[\s,;|]+/).map((v) => v.trim()).filter(Boolean);
|
return input
|
||||||
|
.split(/[\s,;|]+/)
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectUpcs(value: unknown, target: string[]): void {
|
function collectUpcs(value: unknown, target: string[]): void {
|
||||||
@@ -279,7 +294,13 @@ async function getRuns(filters: URLSearchParams) {
|
|||||||
[...params, pageSize, offset],
|
[...params, pageSize, offset],
|
||||||
);
|
);
|
||||||
const total = Number(totalRow?.total ?? 0);
|
const total = Number(totalRow?.total ?? 0);
|
||||||
return { items, page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) };
|
return {
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRun(runId: number) {
|
async function getRun(runId: number) {
|
||||||
@@ -403,7 +424,11 @@ const ITEM_SORTS: Record<string, string> = {
|
|||||||
async function getRunItems(runId: number, filters: URLSearchParams) {
|
async function getRunItems(runId: number, filters: URLSearchParams) {
|
||||||
const { page, pageSize, offset } = pageInput(filters);
|
const { page, pageSize, offset } = pageInput(filters);
|
||||||
const { where, params } = itemFilters(filters, runId);
|
const { where, params } = itemFilters(filters, runId);
|
||||||
const orderBy = safeSort(filters.get("sort"), ITEM_SORTS, "monthly_sold DESC NULLS LAST, asin ASC");
|
const orderBy = safeSort(
|
||||||
|
filters.get("sort"),
|
||||||
|
ITEM_SORTS,
|
||||||
|
"monthly_sold DESC NULLS LAST, asin ASC",
|
||||||
|
);
|
||||||
const totalRow = await pgGet<{ total: string }>(
|
const totalRow = await pgGet<{ total: string }>(
|
||||||
`SELECT COUNT(*) AS total FROM (${ITEM_ROWS}) item_rows ${where}`,
|
`SELECT COUNT(*) AS total FROM (${ITEM_ROWS}) item_rows ${where}`,
|
||||||
params,
|
params,
|
||||||
@@ -413,29 +438,60 @@ async function getRunItems(runId: number, filters: URLSearchParams) {
|
|||||||
[...params, pageSize, offset],
|
[...params, pageSize, offset],
|
||||||
);
|
);
|
||||||
const total = Number(totalRow?.total ?? 0);
|
const total = Number(totalRow?.total ?? 0);
|
||||||
return { items, page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) };
|
return {
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportRunItems(runId: number, filters: URLSearchParams) {
|
async function exportRunItems(runId: number, filters: URLSearchParams) {
|
||||||
const { where, params } = itemFilters(filters, runId);
|
const { where, params } = itemFilters(filters, runId);
|
||||||
const orderBy = safeSort(filters.get("sort"), ITEM_SORTS, "monthly_sold DESC NULLS LAST, asin ASC");
|
const orderBy = safeSort(
|
||||||
|
filters.get("sort"),
|
||||||
|
ITEM_SORTS,
|
||||||
|
"monthly_sold DESC NULLS LAST, asin ASC",
|
||||||
|
);
|
||||||
const rows = await pgAll<Record<string, unknown>>(
|
const rows = await pgAll<Record<string, unknown>>(
|
||||||
`SELECT * FROM (${ITEM_ROWS}) item_rows ${where} ORDER BY ${orderBy}`,
|
`SELECT * FROM (${ITEM_ROWS}) item_rows ${where} ORDER BY ${orderBy}`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
const headers = [
|
const headers = [
|
||||||
"run_id", "asin", "product_name", "brand", "category", "unit_cost",
|
"run_id",
|
||||||
"current_price", "avg_price_90d", "sales_rank_avg_90d", "seller_count",
|
"asin",
|
||||||
"amazon_is_seller", "amazon_buybox_share_pct_90d", "monthly_sold",
|
"product_name",
|
||||||
"sellability_status", "verdict", "confidence", "reasoning", "fetched_at",
|
"brand",
|
||||||
|
"category",
|
||||||
|
"unit_cost",
|
||||||
|
"current_price",
|
||||||
|
"avg_price_90d",
|
||||||
|
"sales_rank_avg_90d",
|
||||||
|
"seller_count",
|
||||||
|
"amazon_is_seller",
|
||||||
|
"amazon_buybox_share_pct_90d",
|
||||||
|
"monthly_sold",
|
||||||
|
"sellability_status",
|
||||||
|
"verdict",
|
||||||
|
"confidence",
|
||||||
|
"reasoning",
|
||||||
|
"fetched_at",
|
||||||
];
|
];
|
||||||
return [headers.join(","), ...rows.map((row) => headers.map((h) => escapeCsvValue(row[h])).join(","))].join("\n");
|
return [
|
||||||
|
headers.join(","),
|
||||||
|
...rows.map((row) => headers.map((h) => escapeCsvValue(row[h])).join(",")),
|
||||||
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getProducts(filters: URLSearchParams) {
|
async function getProducts(filters: URLSearchParams) {
|
||||||
const { page, pageSize, offset } = pageInput(filters);
|
const { page, pageSize, offset } = pageInput(filters);
|
||||||
const { where, params } = itemFilters(filters);
|
const { where, params } = itemFilters(filters);
|
||||||
const orderBy = safeSort(filters.get("sort"), ITEM_SORTS, "fetched_at DESC NULLS LAST, asin ASC");
|
const orderBy = safeSort(
|
||||||
|
filters.get("sort"),
|
||||||
|
ITEM_SORTS,
|
||||||
|
"fetched_at DESC NULLS LAST, asin ASC",
|
||||||
|
);
|
||||||
const base = `
|
const base = `
|
||||||
SELECT product.asin, product.asin AS product_asin,
|
SELECT product.asin, product.asin AS product_asin,
|
||||||
latest.item_id, latest.run_id AS "runId", latest.process_type AS "processType",
|
latest.item_id, latest.run_id AS "runId", latest.process_type AS "processType",
|
||||||
@@ -457,23 +513,28 @@ async function getProducts(filters: URLSearchParams) {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) latest ON TRUE`;
|
) latest ON TRUE`;
|
||||||
const total = Number(
|
const total = Number(
|
||||||
(await pgGet<{ total: string }>(
|
(
|
||||||
`SELECT COUNT(*) AS total FROM (${base}) products ${where}`,
|
await pgGet<{ total: string }>(
|
||||||
params,
|
`SELECT COUNT(*) AS total FROM (${base}) products ${where}`,
|
||||||
))?.total ?? 0,
|
params,
|
||||||
|
)
|
||||||
|
)?.total ?? 0,
|
||||||
);
|
);
|
||||||
const items = await pgAll(
|
const items = await pgAll(
|
||||||
`SELECT * FROM (${base}) products ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
|
`SELECT * FROM (${base}) products ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
|
||||||
[...params, pageSize, offset],
|
[...params, pageSize, offset],
|
||||||
);
|
);
|
||||||
return { items, page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) };
|
return {
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getProduct(asin: string) {
|
async function getProduct(asin: string) {
|
||||||
const product = await pgGet(
|
const product = await pgGet(`SELECT * FROM products WHERE asin = ?`, [asin]);
|
||||||
`SELECT * FROM products WHERE asin = ?`,
|
|
||||||
[asin],
|
|
||||||
);
|
|
||||||
if (!product) return null;
|
if (!product) return null;
|
||||||
const observations = await pgAll(
|
const observations = await pgAll(
|
||||||
`SELECT observation.*, run.type AS run_type
|
`SELECT observation.*, run.type AS run_type
|
||||||
@@ -492,10 +553,71 @@ async function getProduct(asin: string) {
|
|||||||
ORDER BY revision.analyzed_at DESC`,
|
ORDER BY revision.analyzed_at DESC`,
|
||||||
[asin],
|
[asin],
|
||||||
);
|
);
|
||||||
return { product, observations, analyses };
|
const distributorResearchRows = await pgAll<Record<string, unknown>>(
|
||||||
|
`SELECT id, run_item_id, inventory_item_id, provider, model, status, distributors_json, raw_response, created_at
|
||||||
|
FROM product_distributor_research
|
||||||
|
WHERE product_asin = ?
|
||||||
|
ORDER BY created_at DESC, id DESC`,
|
||||||
|
[asin],
|
||||||
|
);
|
||||||
|
const distributorResearch = distributorResearchRows.map((row) => {
|
||||||
|
const distributors = (() => {
|
||||||
|
try {
|
||||||
|
return normalizeDistributorCandidates(
|
||||||
|
JSON.parse(String(row.distributors_json ?? "[]")),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
run_item_id: row.run_item_id == null ? null : Number(row.run_item_id),
|
||||||
|
inventory_item_id:
|
||||||
|
row.inventory_item_id == null ? null : Number(row.inventory_item_id),
|
||||||
|
provider: String(row.provider ?? ""),
|
||||||
|
model: String(row.model ?? ""),
|
||||||
|
status: String(row.status ?? ""),
|
||||||
|
created_at: String(row.created_at ?? ""),
|
||||||
|
distributors,
|
||||||
|
raw_response: row.raw_response == null ? null : String(row.raw_response),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { product, observations, analyses, distributorResearch };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reanalyzeRunItem(itemId: number) {
|
async function findLatestRunItemIdByAsin(asin: string): Promise<number | null> {
|
||||||
|
const row = await pgGet<{ id: number }>(
|
||||||
|
`SELECT ri.id
|
||||||
|
FROM run_items ri
|
||||||
|
WHERE ri.product_asin = ?
|
||||||
|
ORDER BY ri.id DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[asin],
|
||||||
|
);
|
||||||
|
return row?.id == null ? null : Number(row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reanalyzeStalkerProductByAsin(
|
||||||
|
asin: string,
|
||||||
|
useClaude = USE_CLAUDE,
|
||||||
|
) {
|
||||||
|
const runItemId = await findLatestRunItemIdByAsin(asin);
|
||||||
|
if (runItemId == null) {
|
||||||
|
throw new Error("Stalker product item not found");
|
||||||
|
}
|
||||||
|
return reanalyzeRunItem(runItemId, useClaude);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDistributorsForStalkerProductByAsin(asin: string) {
|
||||||
|
const runItemId = await findLatestRunItemIdByAsin(asin);
|
||||||
|
if (runItemId == null) {
|
||||||
|
throw new Error("Stalker product item not found");
|
||||||
|
}
|
||||||
|
return findDistributorsForStalkerProduct(runItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reanalyzeRunItem(itemId: number, useClaude = USE_CLAUDE) {
|
||||||
const row = await pgGet<Record<string, any>>(
|
const row = await pgGet<Record<string, any>>(
|
||||||
`SELECT ri.id, ri.run_id, ri.product_asin AS asin, r.type,
|
`SELECT ri.id, ri.run_id, ri.product_asin AS asin, r.type,
|
||||||
COALESCE(p.name, si.supplied_name, ri.product_asin) AS product_name,
|
COALESCE(p.name, si.supplied_name, ri.product_asin) AS product_name,
|
||||||
@@ -505,7 +627,8 @@ async function reanalyzeRunItem(itemId: number) {
|
|||||||
si.fba_net_sheet, si.gross_profit_dollar, si.gross_profit_pct,
|
si.fba_net_sheet, si.gross_profit_dollar, si.gross_profit_pct,
|
||||||
si.net_profit_sheet, si.roi_sheet, si.moq, si.moq_cost,
|
si.net_profit_sheet, si.roi_sheet, si.moq, si.moq_cost,
|
||||||
si.qty_available, si.supplier, si.source_url, si.asin_link,
|
si.qty_available, si.supplier, si.source_url, si.asin_link,
|
||||||
si.promo_coupon_code, si.notes, si.lead_date
|
si.promo_coupon_code, si.notes, si.lead_date,
|
||||||
|
ri.source_inventory_item_id
|
||||||
FROM run_items ri JOIN runs r ON r.id = ri.run_id
|
FROM run_items ri JOIN runs r ON r.id = ri.run_id
|
||||||
LEFT JOIN products p ON p.asin = ri.product_asin
|
LEFT JOIN products p ON p.asin = ri.product_asin
|
||||||
LEFT JOIN sourcing_inputs si ON si.run_item_id = ri.id
|
LEFT JOIN sourcing_inputs si ON si.run_item_id = ri.id
|
||||||
@@ -514,7 +637,9 @@ async function reanalyzeRunItem(itemId: number) {
|
|||||||
);
|
);
|
||||||
if (!row) throw new Error("Run item not found");
|
if (!row) throw new Error("Run item not found");
|
||||||
if (row.type === "supplier_upc") {
|
if (row.type === "supplier_upc") {
|
||||||
throw new Error("Supplier scoring revisions are produced by the supplier pipeline");
|
throw new Error(
|
||||||
|
"Supplier scoring revisions are produced by the supplier pipeline",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const record: ProductRecord = {
|
const record: ProductRecord = {
|
||||||
asin: row.asin,
|
asin: row.asin,
|
||||||
@@ -555,15 +680,18 @@ async function reanalyzeRunItem(itemId: number) {
|
|||||||
spApi: spApi as SpApiData,
|
spApi: spApi as SpApiData,
|
||||||
fetchedAt: new Date().toISOString(),
|
fetchedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const verdict =
|
const verdict = (await analyzeProducts([enriched], { useClaude }))[0] ?? {
|
||||||
(await analyzeProducts([enriched], { useClaude: USE_CLAUDE }))[0] ?? {
|
asin: row.asin,
|
||||||
asin: row.asin,
|
verdict: "SKIP" as const,
|
||||||
verdict: "SKIP" as const,
|
confidence: 0,
|
||||||
confidence: 0,
|
reasoning: "LLM analysis returned no verdict",
|
||||||
reasoning: "LLM analysis returned no verdict",
|
};
|
||||||
};
|
|
||||||
const result: AnalysisResult = { product: enriched, verdict };
|
const result: AnalysisResult = { product: enriched, verdict };
|
||||||
const observationId = await insertObservation(row.run_id, result, "reanalysis");
|
const observationId = await insertObservation(
|
||||||
|
row.run_id,
|
||||||
|
result,
|
||||||
|
"reanalysis",
|
||||||
|
);
|
||||||
await db.insert(analysisRevisions).values({
|
await db.insert(analysisRevisions).values({
|
||||||
runItemId: itemId,
|
runItemId: itemId,
|
||||||
observationId,
|
observationId,
|
||||||
@@ -574,7 +702,244 @@ async function reanalyzeRunItem(itemId: number) {
|
|||||||
analyzedAt: new Date(enriched.fetchedAt),
|
analyzedAt: new Date(enriched.fetchedAt),
|
||||||
});
|
});
|
||||||
await refreshRunStats(row.run_id);
|
await refreshRunStats(row.run_id);
|
||||||
return { itemId, runId: row.run_id, asin: row.asin, fetchedAt: enriched.fetchedAt };
|
return {
|
||||||
|
itemId,
|
||||||
|
runId: row.run_id,
|
||||||
|
asin: row.asin,
|
||||||
|
fetchedAt: enriched.fetchedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type DistributorCandidate = {
|
||||||
|
name: string;
|
||||||
|
website: string;
|
||||||
|
rationale: string;
|
||||||
|
confidence: number;
|
||||||
|
reputation: string;
|
||||||
|
contactInfo: string;
|
||||||
|
outreachDraft: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampDistributorConfidence(value: unknown): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) return 0;
|
||||||
|
return Math.max(0, Math.min(100, Math.round(parsed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDistributorCandidates(
|
||||||
|
payload: unknown,
|
||||||
|
): DistributorCandidate[] {
|
||||||
|
if (!Array.isArray(payload)) return [];
|
||||||
|
return payload
|
||||||
|
.filter(
|
||||||
|
(item): item is Record<string, unknown> =>
|
||||||
|
item != null && typeof item === "object",
|
||||||
|
)
|
||||||
|
.map((item) => ({
|
||||||
|
name: String(item.name ?? "").trim(),
|
||||||
|
website: String(item.website ?? "").trim(),
|
||||||
|
rationale: String(item.rationale ?? "").trim(),
|
||||||
|
confidence: clampDistributorConfidence(item.confidence),
|
||||||
|
reputation: String(item.reputation ?? "").trim(),
|
||||||
|
contactInfo: String(item.contact_info ?? item.contactInfo ?? "").trim(),
|
||||||
|
outreachDraft: String(
|
||||||
|
item.outreach_draft ?? item.outreachDraft ?? "",
|
||||||
|
).trim(),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.name.length > 0 && item.website.length > 0)
|
||||||
|
.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJsonArrayFromText(text: string): string {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||||
|
const candidate = fence ? (fence[1]?.trim() ?? "") : trimmed;
|
||||||
|
const start = candidate.indexOf("[");
|
||||||
|
const end = candidate.lastIndexOf("]");
|
||||||
|
if (start >= 0 && end > start) {
|
||||||
|
return candidate.slice(start, end + 1);
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestClaudeDistributorCandidates(
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
if (!config.anthropicApiKey) {
|
||||||
|
throw new Error("Missing required env var: ANTHROPIC_API_KEY");
|
||||||
|
}
|
||||||
|
// const model = (config.anthropicModel ?? "claude-sonnet-4-6").trim() || "claude-sonnet-4-6";
|
||||||
|
const model = "claude-sonnet-4-6";
|
||||||
|
const system = [
|
||||||
|
"You are a wholesale sourcing researcher who identifies authorized U.S. distributors for Amazon products.",
|
||||||
|
"For each candidate you research their reputation, locate real point-of-contact details, and draft a concise cold-outreach message.",
|
||||||
|
"Return only raw JSON — no prose, no markdown fences.",
|
||||||
|
].join(" ");
|
||||||
|
const prompt = [
|
||||||
|
"Analyze the Amazon product context below and identify up to 5 likely authorized U.S. wholesale distributors.",
|
||||||
|
"",
|
||||||
|
"For each distributor:",
|
||||||
|
"1. Identify whether they are an official brand distributor, authorized reseller, or national wholesaler.",
|
||||||
|
"2. Investigate their reputation: check for BBB accreditation, industry tenure, any known complaints or red flags, and whether they appear on the brand's own authorized-distributor list.",
|
||||||
|
"3. Find the most direct point-of-contact for opening a new wholesale account. Search the distributor's website for a dedicated wholesale, reseller, or new-account page. Return AS MANY of these as you can find: full name and title of the wholesale/vendor relations contact, direct email address (e.g. wholesale@..., newaccounts@..., sales@...), direct phone number, and the URL of the wholesale application or inquiry page. If a named contact is not publicly listed, return the best department email and phone. Do NOT return a generic contact form URL as the only answer.",
|
||||||
|
"4. Draft a short, professional cold-outreach message (3–5 sentences) I can copy-paste and send. Tone: warm, genuine, and business-oriented — the goal is to start a relationship, not close a deal. Rules: (a) Praise the brand's reputation, quality, or market position sincerely — make it specific to what this brand is known for. (b) Frame the inquiry as a mutual growth opportunity; express eagerness to carry their line and help it reach more customers. (c) Do NOT mention Amazon, FBA, or online marketplaces anywhere in the message — present yourself simply as a retailer / reseller interested in carrying their products. (d) Ask about wholesale account requirements and invite them to share terms or an application. (e) Keep it concise and human — avoid corporate filler phrases.",
|
||||||
|
"",
|
||||||
|
"Return a raw JSON array. Each object must have exactly these keys:",
|
||||||
|
' "name" — distributor company name',
|
||||||
|
' "website" — full URL (https://...)',
|
||||||
|
' "rationale" — why this distributor is a strong candidate (1–2 sentences)',
|
||||||
|
' "confidence" — integer 0–100 reflecting how confident you are this is a real authorized source',
|
||||||
|
' "reputation" — summary of reputation findings (BBB status, years in business, any red flags)',
|
||||||
|
' "contact_info" — structured string with all contact details found: "Name: ..., Title: ..., Email: ..., Phone: ..., Wholesale page: ..."',
|
||||||
|
' "outreach_draft"— complete ready-to-send message addressed to the specific contact',
|
||||||
|
"",
|
||||||
|
"Product context:",
|
||||||
|
JSON.stringify(context, null, 2),
|
||||||
|
].join("\n");
|
||||||
|
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": config.anthropicApiKey,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
system,
|
||||||
|
messages: [{ role: "user", content: prompt }],
|
||||||
|
temperature: 0.2,
|
||||||
|
max_tokens: 4096,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const raw = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Claude API error ${response.status}: ${raw.slice(0, 300)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let contentText = "";
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
content?: Array<{ type?: string; text?: string }>;
|
||||||
|
};
|
||||||
|
contentText = (parsed.content ?? [])
|
||||||
|
.filter(
|
||||||
|
(block) => block?.type === "text" && typeof block.text === "string",
|
||||||
|
)
|
||||||
|
.map((block) => block.text ?? "")
|
||||||
|
.join("\n");
|
||||||
|
} catch {
|
||||||
|
contentText = raw;
|
||||||
|
}
|
||||||
|
const arrayText = extractJsonArrayFromText(contentText);
|
||||||
|
let candidates: DistributorCandidate[] = [];
|
||||||
|
try {
|
||||||
|
candidates = normalizeDistributorCandidates(JSON.parse(arrayText));
|
||||||
|
} catch {
|
||||||
|
candidates = [];
|
||||||
|
}
|
||||||
|
return { model, rawResponse: contentText, candidates };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDistributorsForStalkerProduct(runItemId: number) {
|
||||||
|
const row = await pgGet<Record<string, any>>(
|
||||||
|
`SELECT ri.id AS run_item_id, ri.product_asin AS asin, ri.source_inventory_item_id,
|
||||||
|
p.name AS product_title, p.brand, p.category,
|
||||||
|
observation.current_price, observation.avg_price_90d, observation.sales_rank,
|
||||||
|
observation.monthly_sold, observation.seller_count, observation.amazon_is_seller,
|
||||||
|
observation.can_sell, observation.sellability_status, observation.sellability_reason,
|
||||||
|
latest_analysis.decision AS verdict, latest_analysis.confidence, latest_analysis.reasoning,
|
||||||
|
seller.seller_id, seller.seller_name, seller.rating, seller.rating_count
|
||||||
|
FROM run_items ri
|
||||||
|
JOIN products p ON p.asin = ri.product_asin
|
||||||
|
LEFT JOIN product_observations observation ON observation.id = (
|
||||||
|
SELECT obs.id
|
||||||
|
FROM product_observations obs
|
||||||
|
WHERE obs.product_asin = ri.product_asin
|
||||||
|
ORDER BY obs.fetched_at DESC, obs.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT revision.decision, revision.confidence, revision.reasoning
|
||||||
|
FROM analysis_revisions revision
|
||||||
|
WHERE revision.run_item_id = ri.id
|
||||||
|
ORDER BY revision.analyzed_at DESC, revision.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest_analysis ON TRUE
|
||||||
|
LEFT JOIN stalker_inventory_items si ON si.id = ri.source_inventory_item_id
|
||||||
|
LEFT JOIN sellers seller ON seller.seller_id = si.seller_id
|
||||||
|
WHERE ri.id = ?`,
|
||||||
|
[runItemId],
|
||||||
|
);
|
||||||
|
if (!row?.asin) {
|
||||||
|
throw new Error("Stalker product item not found");
|
||||||
|
}
|
||||||
|
const offerResults = await searchAsinOffers(row.asin, {
|
||||||
|
maxResults: 12,
|
||||||
|
includeUnmatchedAsinResults: true,
|
||||||
|
}).catch(() => [] as SearxngOfferSearchResult[]);
|
||||||
|
const promptContext = {
|
||||||
|
asin: row.asin,
|
||||||
|
productTitle: row.product_title ?? null,
|
||||||
|
brand: row.brand ?? null,
|
||||||
|
category: row.category ?? null,
|
||||||
|
metrics: {
|
||||||
|
currentPrice: row.current_price ?? null,
|
||||||
|
avgPrice90d: row.avg_price_90d ?? null,
|
||||||
|
salesRank: row.sales_rank ?? null,
|
||||||
|
monthlySold: row.monthly_sold ?? null,
|
||||||
|
sellerCount: row.seller_count ?? null,
|
||||||
|
amazonIsSeller: row.amazon_is_seller ?? null,
|
||||||
|
canSell: row.can_sell ?? null,
|
||||||
|
sellabilityStatus: row.sellability_status ?? null,
|
||||||
|
sellabilityReason: row.sellability_reason ?? null,
|
||||||
|
verdict: row.verdict ?? null,
|
||||||
|
confidence: row.confidence ?? null,
|
||||||
|
reasoning: row.reasoning ?? null,
|
||||||
|
},
|
||||||
|
seller: {
|
||||||
|
sellerId: row.seller_id ?? null,
|
||||||
|
sellerName: row.seller_name ?? null,
|
||||||
|
rating: row.rating ?? null,
|
||||||
|
ratingCount: row.rating_count ?? null,
|
||||||
|
},
|
||||||
|
offerResearch: offerResults.map((result) => ({
|
||||||
|
title: result.title,
|
||||||
|
url: result.url,
|
||||||
|
domain: result.domain,
|
||||||
|
snippet: result.snippet,
|
||||||
|
score: result.score,
|
||||||
|
rank: result.rank,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const claude = await requestClaudeDistributorCandidates(promptContext);
|
||||||
|
await db
|
||||||
|
.delete(productDistributorResearch)
|
||||||
|
.where(eq(productDistributorResearch.runItemId, runItemId));
|
||||||
|
const [saved] = await db
|
||||||
|
.insert(productDistributorResearch)
|
||||||
|
.values({
|
||||||
|
productAsin: row.asin,
|
||||||
|
runItemId: runItemId,
|
||||||
|
inventoryItemId: row.source_inventory_item_id ?? null,
|
||||||
|
provider: "claude",
|
||||||
|
model: claude.model,
|
||||||
|
status: claude.candidates.length ? "completed" : "empty",
|
||||||
|
queryContextJson: JSON.stringify(promptContext),
|
||||||
|
distributorsJson: JSON.stringify(claude.candidates),
|
||||||
|
rawResponse: claude.rawResponse,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: productDistributorResearch.id,
|
||||||
|
createdAt: productDistributorResearch.createdAt,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
asin: row.asin,
|
||||||
|
runItemId: runItemId,
|
||||||
|
researchId: saved?.id ?? null,
|
||||||
|
createdAt: saved?.createdAt ?? null,
|
||||||
|
distributors: claude.candidates,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function stalkerBaseWhere(filters: URLSearchParams, product = false) {
|
function stalkerBaseWhere(filters: URLSearchParams, product = false) {
|
||||||
@@ -616,7 +981,10 @@ function stalkerBaseWhere(filters: URLSearchParams, product = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (product) {
|
if (product) {
|
||||||
conditions.push("observation.can_sell = true", "observation.sellability_status = 'available'");
|
conditions.push(
|
||||||
|
"observation.can_sell = true",
|
||||||
|
"observation.sellability_status = 'available'",
|
||||||
|
);
|
||||||
const verdict = filters.get("verdict")?.toUpperCase();
|
const verdict = filters.get("verdict")?.toUpperCase();
|
||||||
if (verdict === "FBA" || verdict === "FBM" || verdict === "SKIP") {
|
if (verdict === "FBA" || verdict === "FBM" || verdict === "SKIP") {
|
||||||
conditions.push("analysis.decision::text = ?");
|
conditions.push("analysis.decision::text = ?");
|
||||||
@@ -686,7 +1054,14 @@ async function getStalkerResults(filters: URLSearchParams) {
|
|||||||
},
|
},
|
||||||
"persisted_inventory_asin_count DESC, last_seen_at DESC, seller_id ASC",
|
"persisted_inventory_asin_count DESC, last_seen_at DESC, seller_id ASC",
|
||||||
);
|
);
|
||||||
const total = Number((await pgGet<{ total: string }>(`SELECT COUNT(*) AS total FROM (${base}) rows`, params))?.total ?? 0);
|
const total = Number(
|
||||||
|
(
|
||||||
|
await pgGet<{ total: string }>(
|
||||||
|
`SELECT COUNT(*) AS total FROM (${base}) rows`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
)?.total ?? 0,
|
||||||
|
);
|
||||||
const items = await pgAll(
|
const items = await pgAll(
|
||||||
`SELECT * FROM (${base}) rows ORDER BY ${order} LIMIT ? OFFSET ?`,
|
`SELECT * FROM (${base}) rows ORDER BY ${order} LIMIT ? OFFSET ?`,
|
||||||
[...params, pageSize, offset],
|
[...params, pageSize, offset],
|
||||||
@@ -704,14 +1079,18 @@ async function getStalkerResults(filters: URLSearchParams) {
|
|||||||
sellers: Number(summary?.sellers ?? 0),
|
sellers: Number(summary?.sellers ?? 0),
|
||||||
persistedInventoryAsins: Number(summary?.persistedInventoryAsins ?? 0),
|
persistedInventoryAsins: Number(summary?.persistedInventoryAsins ?? 0),
|
||||||
},
|
},
|
||||||
page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function stalkerProductSql(where: string) {
|
function stalkerProductSql(where: string) {
|
||||||
return `SELECT r.id AS "runId", r.started_at, seller.seller_id, seller.seller_name,
|
return `SELECT r.id AS "runId", r.started_at, seller.seller_id, seller.seller_name,
|
||||||
seller.rating, seller.rating_count, inventory.product_asin AS asin,
|
seller.rating, seller.rating_count, inventory.id AS inventory_item_id, inventory.product_asin AS asin,
|
||||||
observation.can_sell, observation.sellability_status, observation.sellability_reason,
|
observation.can_sell, observation.sellability_status, observation.sellability_reason,
|
||||||
|
analysis.run_item_id,
|
||||||
product.name AS product_title, product.brand,
|
product.name AS product_title, product.brand,
|
||||||
CASE WHEN product.category IS NULL THEN NULL ELSE json_build_array(product.category)::text END AS category_tree,
|
CASE WHEN product.category IS NULL THEN NULL ELSE json_build_array(product.category)::text END AS category_tree,
|
||||||
observation.current_price, observation.avg_price_90d, observation.sales_rank,
|
observation.current_price, observation.avg_price_90d, observation.sales_rank,
|
||||||
@@ -724,7 +1103,7 @@ function stalkerProductSql(where: string) {
|
|||||||
JOIN products product ON product.asin = inventory.product_asin
|
JOIN products product ON product.asin = inventory.product_asin
|
||||||
JOIN product_observations observation ON observation.id = inventory.observation_id
|
JOIN product_observations observation ON observation.id = inventory.observation_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT revision.decision, revision.confidence, revision.reasoning
|
SELECT item.id AS run_item_id, revision.decision, revision.confidence, revision.reasoning
|
||||||
FROM run_items item
|
FROM run_items item
|
||||||
JOIN analysis_revisions revision ON revision.run_item_id = item.id
|
JOIN analysis_revisions revision ON revision.run_item_id = item.id
|
||||||
WHERE item.source_inventory_item_id = inventory.id
|
WHERE item.source_inventory_item_id = inventory.id
|
||||||
@@ -761,8 +1140,16 @@ async function stalkerProducts(filters: URLSearchParams, exportOnly = false) {
|
|||||||
},
|
},
|
||||||
"monthly_sold DESC NULLS LAST, last_seen_at DESC, asin ASC",
|
"monthly_sold DESC NULLS LAST, last_seen_at DESC, asin ASC",
|
||||||
);
|
);
|
||||||
if (exportOnly) return pgAll(`SELECT * FROM (${base}) products ORDER BY ${order}`, params);
|
if (exportOnly)
|
||||||
const total = Number((await pgGet<{ total: string }>(`SELECT COUNT(*) AS total FROM (${base}) products`, params))?.total ?? 0);
|
return pgAll(`SELECT * FROM (${base}) products ORDER BY ${order}`, params);
|
||||||
|
const total = Number(
|
||||||
|
(
|
||||||
|
await pgGet<{ total: string }>(
|
||||||
|
`SELECT COUNT(*) AS total FROM (${base}) products`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
)?.total ?? 0,
|
||||||
|
);
|
||||||
const items = await pgAll(
|
const items = await pgAll(
|
||||||
`SELECT * FROM (${base}) products ORDER BY ${order} LIMIT ? OFFSET ?`,
|
`SELECT * FROM (${base}) products ORDER BY ${order} LIMIT ? OFFSET ?`,
|
||||||
[...params, pageSize, offset],
|
[...params, pageSize, offset],
|
||||||
@@ -779,12 +1166,19 @@ async function stalkerProducts(filters: URLSearchParams, exportOnly = false) {
|
|||||||
sellers: Number(summary?.sellers ?? 0),
|
sellers: Number(summary?.sellers ?? 0),
|
||||||
products: Number(summary?.products ?? 0),
|
products: Number(summary?.products ?? 0),
|
||||||
},
|
},
|
||||||
page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportStalkerProducts(filters: URLSearchParams): Promise<Response> {
|
async function exportStalkerProducts(
|
||||||
const rows = (await stalkerProducts(filters, true)) as Array<Record<string, any>>;
|
filters: URLSearchParams,
|
||||||
|
): Promise<Response> {
|
||||||
|
const rows = (await stalkerProducts(filters, true)) as Array<
|
||||||
|
Record<string, any>
|
||||||
|
>;
|
||||||
const data = rows.map((row) => ({
|
const data = rows.map((row) => ({
|
||||||
ASIN: row.asin,
|
ASIN: row.asin,
|
||||||
"Amazon URL": `https://amazon.com/dp/${row.asin}`,
|
"Amazon URL": `https://amazon.com/dp/${row.asin}`,
|
||||||
@@ -802,7 +1196,11 @@ async function exportStalkerProducts(filters: URLSearchParams): Promise<Response
|
|||||||
"Run ID": row.runId,
|
"Run ID": row.runId,
|
||||||
}));
|
}));
|
||||||
const workbook = XLSX.utils.book_new();
|
const workbook = XLSX.utils.book_new();
|
||||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(data), "Sellable Products");
|
XLSX.utils.book_append_sheet(
|
||||||
|
workbook,
|
||||||
|
XLSX.utils.json_to_sheet(data),
|
||||||
|
"Sellable Products",
|
||||||
|
);
|
||||||
return xlsx(
|
return xlsx(
|
||||||
XLSX.write(workbook, { type: "array", bookType: "xlsx" }) as ArrayBuffer,
|
XLSX.write(workbook, { type: "array", bookType: "xlsx" }) as ArrayBuffer,
|
||||||
"stalker-sellable-products.xlsx",
|
"stalker-sellable-products.xlsx",
|
||||||
@@ -831,13 +1229,17 @@ const server = Bun.serve({
|
|||||||
"/stalker": index,
|
"/stalker": index,
|
||||||
"/stalker/products": index,
|
"/stalker/products": index,
|
||||||
"/runs/:runId": index,
|
"/runs/:runId": index,
|
||||||
"/api/runs": async (req) => json(await getRuns(new URL(req.url).searchParams)),
|
"/api/runs": async (req) =>
|
||||||
|
json(await getRuns(new URL(req.url).searchParams)),
|
||||||
"/api/runs/:runId": async (req) => {
|
"/api/runs/:runId": async (req) => {
|
||||||
const runId = Number(req.params.runId);
|
const runId = Number(req.params.runId);
|
||||||
if (!Number.isInteger(runId)) return json({ error: "Invalid run identifier" }, 400);
|
if (!Number.isInteger(runId))
|
||||||
|
return json({ error: "Invalid run identifier" }, 400);
|
||||||
if (req.method === "DELETE") {
|
if (req.method === "DELETE") {
|
||||||
const deleted = await pgRun("DELETE FROM runs WHERE id = ?", [runId]);
|
const deleted = await pgRun("DELETE FROM runs WHERE id = ?", [runId]);
|
||||||
return deleted ? json({ deletedRun: true }) : json({ error: "Run not found" }, 404);
|
return deleted
|
||||||
|
? json({ deletedRun: true })
|
||||||
|
: json({ error: "Run not found" }, 404);
|
||||||
}
|
}
|
||||||
const run = await getRun(runId);
|
const run = await getRun(runId);
|
||||||
if (!run) return json({ error: "Run not found" }, 404);
|
if (!run) return json({ error: "Run not found" }, 404);
|
||||||
@@ -855,35 +1257,123 @@ const server = Bun.serve({
|
|||||||
},
|
},
|
||||||
"/api/runs/:runId/items": async (req) => {
|
"/api/runs/:runId/items": async (req) => {
|
||||||
const runId = Number(req.params.runId);
|
const runId = Number(req.params.runId);
|
||||||
if (!Number.isInteger(runId)) return json({ error: "Invalid run identifier" }, 400);
|
if (!Number.isInteger(runId))
|
||||||
|
return json({ error: "Invalid run identifier" }, 400);
|
||||||
return json(await getRunItems(runId, new URL(req.url).searchParams));
|
return json(await getRunItems(runId, new URL(req.url).searchParams));
|
||||||
},
|
},
|
||||||
"/api/runs/:runId/export.csv": async (req) => {
|
"/api/runs/:runId/export.csv": async (req) => {
|
||||||
const runId = Number(req.params.runId);
|
const runId = Number(req.params.runId);
|
||||||
if (!Number.isInteger(runId)) return json({ error: "Invalid run identifier" }, 400);
|
if (!Number.isInteger(runId))
|
||||||
return csv(await exportRunItems(runId, new URL(req.url).searchParams), `run-${runId}.csv`);
|
return json({ error: "Invalid run identifier" }, 400);
|
||||||
|
return csv(
|
||||||
|
await exportRunItems(runId, new URL(req.url).searchParams),
|
||||||
|
`run-${runId}.csv`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
"/api/run-items/:itemId/reanalyze": async (req) => {
|
"/api/run-items/:itemId/reanalyze": async (req) => {
|
||||||
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
if (req.method !== "POST")
|
||||||
|
return json({ error: "Method not allowed" }, 405);
|
||||||
const itemId = Number(req.params.itemId);
|
const itemId = Number(req.params.itemId);
|
||||||
if (!Number.isInteger(itemId)) return json({ error: "Invalid run item identifier" }, 400);
|
if (!Number.isInteger(itemId))
|
||||||
|
return json({ error: "Invalid run item identifier" }, 400);
|
||||||
try {
|
try {
|
||||||
return json(await reanalyzeRunItem(itemId));
|
return json(await reanalyzeRunItem(itemId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
return json({ error: message }, message === "Run item not found" ? 404 : 500);
|
return json(
|
||||||
|
{ error: message },
|
||||||
|
message === "Run item not found" ? 404 : 500,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/products": async (req) => json(await getProducts(new URL(req.url).searchParams)),
|
"/api/products": async (req) =>
|
||||||
|
json(await getProducts(new URL(req.url).searchParams)),
|
||||||
"/api/products/:asin": async (req) => {
|
"/api/products/:asin": async (req) => {
|
||||||
const asin = normalizeAsin(req.params.asin);
|
const asin = normalizeAsin(req.params.asin);
|
||||||
if (!asin) return json({ error: "Invalid ASIN" }, 400);
|
if (!asin) return json({ error: "Invalid ASIN" }, 400);
|
||||||
const result = await getProduct(asin);
|
const result = await getProduct(asin);
|
||||||
return result ? json(result) : json({ error: "Product not found" }, 404);
|
return result ? json(result) : json({ error: "Product not found" }, 404);
|
||||||
},
|
},
|
||||||
"/api/stalker/results": async (req) => json(await getStalkerResults(new URL(req.url).searchParams)),
|
"/api/stalker/results": async (req) =>
|
||||||
"/api/stalker/products": async (req) => json(await stalkerProducts(new URL(req.url).searchParams)),
|
json(await getStalkerResults(new URL(req.url).searchParams)),
|
||||||
"/api/stalker/products/export.xlsx": async (req) => exportStalkerProducts(new URL(req.url).searchParams),
|
"/api/stalker/products": async (req) =>
|
||||||
|
json(await stalkerProducts(new URL(req.url).searchParams)),
|
||||||
|
"/api/stalker/products/export.xlsx": async (req) =>
|
||||||
|
exportStalkerProducts(new URL(req.url).searchParams),
|
||||||
|
"/api/stalker/products/:runItemId/reanalyze": async (req) => {
|
||||||
|
if (req.method !== "POST")
|
||||||
|
return json({ error: "Method not allowed" }, 405);
|
||||||
|
const runItemId = Number(req.params.runItemId);
|
||||||
|
if (!Number.isInteger(runItemId))
|
||||||
|
return json({ error: "Invalid run item identifier" }, 400);
|
||||||
|
const provider = new URL(req.url).searchParams
|
||||||
|
.get("provider")
|
||||||
|
?.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const useClaude = provider === "claude";
|
||||||
|
try {
|
||||||
|
return json(await reanalyzeRunItem(runItemId, useClaude || USE_CLAUDE));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return json(
|
||||||
|
{ error: message },
|
||||||
|
message === "Run item not found" ? 404 : 500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/stalker/products/:runItemId/distributors": async (req) => {
|
||||||
|
if (req.method !== "POST")
|
||||||
|
return json({ error: "Method not allowed" }, 405);
|
||||||
|
const runItemId = Number(req.params.runItemId);
|
||||||
|
if (!Number.isInteger(runItemId))
|
||||||
|
return json({ error: "Invalid run item identifier" }, 400);
|
||||||
|
try {
|
||||||
|
return json(await findDistributorsForStalkerProduct(runItemId));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return json(
|
||||||
|
{ error: message },
|
||||||
|
message === "Stalker product item not found" ? 404 : 500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/stalker/products/by-asin/:asin/reanalyze": async (req) => {
|
||||||
|
if (req.method !== "POST")
|
||||||
|
return json({ error: "Method not allowed" }, 405);
|
||||||
|
const asin = normalizeAsin(req.params.asin);
|
||||||
|
if (!asin) return json({ error: "Invalid ASIN" }, 400);
|
||||||
|
const provider = new URL(req.url).searchParams
|
||||||
|
.get("provider")
|
||||||
|
?.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const useClaude = provider === "claude";
|
||||||
|
try {
|
||||||
|
return json(
|
||||||
|
await reanalyzeStalkerProductByAsin(asin, useClaude || USE_CLAUDE),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return json(
|
||||||
|
{ error: message },
|
||||||
|
message === "Stalker product item not found" ? 404 : 500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/stalker/products/by-asin/:asin/distributors": async (req) => {
|
||||||
|
if (req.method !== "POST")
|
||||||
|
return json({ error: "Method not allowed" }, 405);
|
||||||
|
const asin = normalizeAsin(req.params.asin);
|
||||||
|
if (!asin) return json({ error: "Invalid ASIN" }, 400);
|
||||||
|
try {
|
||||||
|
return json(await findDistributorsForStalkerProductByAsin(asin));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return json(
|
||||||
|
{ error: message },
|
||||||
|
message === "Stalker product item not found" ? 404 : 500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/stalker/purge": async (req) =>
|
"/api/stalker/purge": async (req) =>
|
||||||
req.method === "DELETE" || req.method === "POST"
|
req.method === "DELETE" || req.method === "POST"
|
||||||
? json(await purgeStalkerData())
|
? json(await purgeStalkerData())
|
||||||
@@ -893,10 +1383,15 @@ const server = Bun.serve({
|
|||||||
const upcs = await parseUpcsFromRequest(req);
|
const upcs = await parseUpcsFromRequest(req);
|
||||||
const error = validateUpcs(upcs);
|
const error = validateUpcs(upcs);
|
||||||
if (error) return json({ error }, 400);
|
if (error) return json({ error }, 400);
|
||||||
const items = [...(await mapUpcsToAsins(upcs)).entries()].map(([upc, asin]) => ({ upc, asin }));
|
const items = [...(await mapUpcsToAsins(upcs)).entries()].map(
|
||||||
|
([upc, asin]) => ({ upc, asin }),
|
||||||
|
);
|
||||||
return json({ requested: upcs.length, matched: items.length, items });
|
return json({ requested: upcs.length, matched: items.length, items });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
return json(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/upc/lookup": async (req) => {
|
"/api/upc/lookup": async (req) => {
|
||||||
@@ -905,16 +1400,31 @@ const server = Bun.serve({
|
|||||||
const error = validateUpcs(upcs);
|
const error = validateUpcs(upcs);
|
||||||
if (error) return json({ error }, 400);
|
if (error) return json({ error }, 400);
|
||||||
const items = [...(await lookupKeepaUpcs(upcs)).values()];
|
const items = [...(await lookupKeepaUpcs(upcs)).values()];
|
||||||
return json({ requested: upcs.length, statusCounts: summarizeLookupStatuses(items), items });
|
return json({
|
||||||
|
requested: upcs.length,
|
||||||
|
statusCounts: summarizeLookupStatuses(items),
|
||||||
|
items,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
return json(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/process/upc-file": async (req) => {
|
"/api/process/upc-file": async (req) => {
|
||||||
try {
|
try {
|
||||||
return json(await runUpcFileAnalysis({ ...(await parseUpcFileRequest(req)), manageResources: false }));
|
return json(
|
||||||
|
await runUpcFileAnalysis({
|
||||||
|
...(await parseUpcFileRequest(req)),
|
||||||
|
manageResources: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
return json(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { db } from "../db/index.ts";
|
import { client, db } from "../db/index.ts";
|
||||||
import { persistLlmResults, refreshRunStats } from "../db/persistence.ts";
|
import { persistLlmResults, refreshRunStats } from "../db/persistence.ts";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { normalizeAsin } from "../asin.ts";
|
import { normalizeAsin } from "../asin.ts";
|
||||||
@@ -143,7 +143,7 @@ async function loadInventoryRows(
|
|||||||
WHERE inventory.run_id = ${stalkerRunId}
|
WHERE inventory.run_id = ${stalkerRunId}
|
||||||
AND observation.can_sell = true
|
AND observation.can_sell = true
|
||||||
AND observation.sellability_status = 'available'
|
AND observation.sellability_status = 'available'
|
||||||
AND inventory.product_asin = ANY(${asins})
|
AND inventory.product_asin = ANY(ARRAY[${sql.join(asins.map((asin) => sql`${asin}`), sql`, `)}])
|
||||||
ORDER BY inventory.product_asin, observation.fetched_at DESC`,
|
ORDER BY inventory.product_asin, observation.fetched_at DESC`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -261,8 +261,15 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
main().catch((error) => {
|
main()
|
||||||
console.error(error instanceof Error ? error.message : String(error));
|
.catch((error) => {
|
||||||
process.exit(1);
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
});
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
try {
|
||||||
|
await client.end({ timeout: 5 });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,6 @@ test("sellability checks matched seller inventory, not the source ASIN", async (
|
|||||||
{
|
{
|
||||||
input: inputPath,
|
input: inputPath,
|
||||||
maxAsins: null,
|
maxAsins: null,
|
||||||
storefrontUpdateHours: 168,
|
|
||||||
offerLimit: 20,
|
offerLimit: 20,
|
||||||
sellerLimit: 30,
|
sellerLimit: 30,
|
||||||
inventoryLimit: 200,
|
inventoryLimit: 200,
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ test("runStalker fetches product offers, filters sellers, and tracks stats", asy
|
|||||||
if (url.pathname === "/seller") {
|
if (url.pathname === "/seller") {
|
||||||
const wantsStorefront = url.searchParams.get("storefront") === "1";
|
const wantsStorefront = url.searchParams.get("storefront") === "1";
|
||||||
if (wantsStorefront) {
|
if (wantsStorefront) {
|
||||||
expect(url.searchParams.get("update")).toBe("168");
|
expect(url.searchParams.has("update")).toBeFalse();
|
||||||
}
|
}
|
||||||
const sellerId = url.searchParams.get("seller");
|
const sellerId = url.searchParams.get("seller");
|
||||||
|
|
||||||
@@ -244,7 +244,6 @@ test("runStalker fetches product offers, filters sellers, and tracks stats", asy
|
|||||||
const stats = await runStalker({
|
const stats = await runStalker({
|
||||||
input: inputPath,
|
input: inputPath,
|
||||||
maxAsins: null,
|
maxAsins: null,
|
||||||
storefrontUpdateHours: 168,
|
|
||||||
offerLimit: 20,
|
offerLimit: 20,
|
||||||
sellerLimit: 30,
|
sellerLimit: 30,
|
||||||
inventoryLimit: 200,
|
inventoryLimit: 200,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import type { SellabilityInfo } from "../types.ts";
|
|||||||
const KEEPA_BASE = "https://api.keepa.com";
|
const KEEPA_BASE = "https://api.keepa.com";
|
||||||
const DOMAIN_US = "1";
|
const DOMAIN_US = "1";
|
||||||
const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER";
|
const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER";
|
||||||
const DEFAULT_STOREFRONT_UPDATE_HOURS = 168;
|
|
||||||
const DEFAULT_OFFER_LIMIT = 100;
|
const DEFAULT_OFFER_LIMIT = 100;
|
||||||
const DEFAULT_SELLER_LIMIT = 30;
|
const DEFAULT_SELLER_LIMIT = 30;
|
||||||
const DEFAULT_INVENTORY_LIMIT = 200;
|
const DEFAULT_INVENTORY_LIMIT = 200;
|
||||||
@@ -41,7 +40,6 @@ export type StalkerArgs = {
|
|||||||
input: string;
|
input: string;
|
||||||
dbPath?: string;
|
dbPath?: string;
|
||||||
maxAsins: number | null;
|
maxAsins: number | null;
|
||||||
storefrontUpdateHours: number;
|
|
||||||
offerLimit: number;
|
offerLimit: number;
|
||||||
sellerLimit: number;
|
sellerLimit: number;
|
||||||
inventoryLimit: number;
|
inventoryLimit: number;
|
||||||
@@ -142,7 +140,6 @@ export function parseArgs(argv = process.argv.slice(2)): StalkerArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxAsinsRaw = readFlagValue(argv, "--max-asins");
|
const maxAsinsRaw = readFlagValue(argv, "--max-asins");
|
||||||
const storefrontUpdateRaw = readFlagValue(argv, "--storefront-update-hours");
|
|
||||||
const offerLimitRaw = readFlagValue(argv, "--offer-limit");
|
const offerLimitRaw = readFlagValue(argv, "--offer-limit");
|
||||||
const sellerLimitRaw = readFlagValue(argv, "--seller-limit");
|
const sellerLimitRaw = readFlagValue(argv, "--seller-limit");
|
||||||
const inventoryLimitRaw = readFlagValue(argv, "--inventory-limit");
|
const inventoryLimitRaw = readFlagValue(argv, "--inventory-limit");
|
||||||
@@ -150,9 +147,6 @@ export function parseArgs(argv = process.argv.slice(2)): StalkerArgs {
|
|||||||
const maxSellerRequestsRaw = readFlagValue(argv, "--max-seller-requests");
|
const maxSellerRequestsRaw = readFlagValue(argv, "--max-seller-requests");
|
||||||
|
|
||||||
const maxAsins = maxAsinsRaw ? Number(maxAsinsRaw) : null;
|
const maxAsins = maxAsinsRaw ? Number(maxAsinsRaw) : null;
|
||||||
const storefrontUpdateHours = storefrontUpdateRaw
|
|
||||||
? Number(storefrontUpdateRaw)
|
|
||||||
: DEFAULT_STOREFRONT_UPDATE_HOURS;
|
|
||||||
const offerLimit = offerLimitRaw
|
const offerLimit = offerLimitRaw
|
||||||
? Number(offerLimitRaw)
|
? Number(offerLimitRaw)
|
||||||
: DEFAULT_OFFER_LIMIT;
|
: DEFAULT_OFFER_LIMIT;
|
||||||
@@ -183,12 +177,6 @@ export function parseArgs(argv = process.argv.slice(2)): StalkerArgs {
|
|||||||
printUsageAndExit("--max-asins must be a positive integer.");
|
printUsageAndExit("--max-asins must be a positive integer.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isInteger(storefrontUpdateHours) || storefrontUpdateHours < 0) {
|
|
||||||
printUsageAndExit(
|
|
||||||
"--storefront-update-hours must be a non-negative integer.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Number.isInteger(offerLimit) || offerLimit < 20 || offerLimit > 100) {
|
if (!Number.isInteger(offerLimit) || offerLimit < 20 || offerLimit > 100) {
|
||||||
printUsageAndExit("--offer-limit must be an integer from 20 to 100.");
|
printUsageAndExit("--offer-limit must be an integer from 20 to 100.");
|
||||||
}
|
}
|
||||||
@@ -215,7 +203,6 @@ export function parseArgs(argv = process.argv.slice(2)): StalkerArgs {
|
|||||||
return {
|
return {
|
||||||
input,
|
input,
|
||||||
maxAsins,
|
maxAsins,
|
||||||
storefrontUpdateHours,
|
|
||||||
offerLimit,
|
offerLimit,
|
||||||
sellerLimit,
|
sellerLimit,
|
||||||
inventoryLimit,
|
inventoryLimit,
|
||||||
@@ -662,7 +649,6 @@ async function fetchKeepaInventoryProductDetails(
|
|||||||
asin: chunk.join(","),
|
asin: chunk.join(","),
|
||||||
stats: "30",
|
stats: "30",
|
||||||
days: "30",
|
days: "30",
|
||||||
buybox: "1",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await fetchKeepaWithRetries(
|
const data = await fetchKeepaWithRetries(
|
||||||
@@ -746,13 +732,15 @@ async function fetchQualifiedSellerStorefronts(
|
|||||||
for (const sellerId of uniqueSellerIds) {
|
for (const sellerId of uniqueSellerIds) {
|
||||||
const cached =
|
const cached =
|
||||||
context.storefrontCache.get(sellerId) ??
|
context.storefrontCache.get(sellerId) ??
|
||||||
|
context.metadataCache.get(sellerId) ??
|
||||||
(await loadCachedSeller(
|
(await loadCachedSeller(
|
||||||
sellerId,
|
sellerId,
|
||||||
args.sellerCacheHours,
|
args.sellerCacheHours,
|
||||||
true,
|
true,
|
||||||
args.inventoryLimit,
|
args.inventoryLimit,
|
||||||
));
|
));
|
||||||
if (cached) {
|
if (cached && cached.storefrontAsinTotal > 0) {
|
||||||
|
context.metadataCache.set(sellerId, cached);
|
||||||
context.storefrontCache.set(sellerId, cached);
|
context.storefrontCache.set(sellerId, cached);
|
||||||
out.set(sellerId, cached);
|
out.set(sellerId, cached);
|
||||||
continue;
|
continue;
|
||||||
@@ -765,7 +753,6 @@ async function fetchQualifiedSellerStorefronts(
|
|||||||
domain: DOMAIN_US,
|
domain: DOMAIN_US,
|
||||||
seller: sellerId,
|
seller: sellerId,
|
||||||
storefront: "1",
|
storefront: "1",
|
||||||
update: String(args.storefrontUpdateHours),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
context.stats.sellerStorefrontRequests += 1;
|
context.stats.sellerStorefrontRequests += 1;
|
||||||
@@ -1489,7 +1476,7 @@ async function runSellableAnalysisChild(
|
|||||||
const cmd = [
|
const cmd = [
|
||||||
"bun",
|
"bun",
|
||||||
"run",
|
"run",
|
||||||
"src/stalker-analyze.ts",
|
"src/stalker/stalker-analyze.ts",
|
||||||
"--stalker-run-id",
|
"--stalker-run-id",
|
||||||
String(stalkerRunId),
|
String(stalkerRunId),
|
||||||
"--analysis-run-id",
|
"--analysis-run-id",
|
||||||
@@ -1566,7 +1553,7 @@ function hasFlag(args: string[], flag: string): boolean {
|
|||||||
function printUsageAndExit(message: string): never {
|
function printUsageAndExit(message: string): never {
|
||||||
console.error(message);
|
console.error(message);
|
||||||
console.error(
|
console.error(
|
||||||
"Usage: bun run stalker --input input/asins.xlsx [--db db/results.db] [--max-asins N] [--offer-limit 100] [--seller-limit 30] [--inventory-limit 200] [--storefront-update-hours 168] [--seller-cache-hours 168] [--max-seller-requests N] [--sellability] [--analyze-sellable] [--include-stock] [--dry-run] [--no-resume] [--claude]",
|
"Usage: bun run stalker --input input/asins.xlsx [--db db/results.db] [--max-asins N] [--offer-limit 100] [--seller-limit 30] [--inventory-limit 200] [--seller-cache-hours 168] [--max-seller-requests N] [--sellability] [--analyze-sellable] [--include-stock] [--dry-run] [--no-resume] [--claude]",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -142,6 +142,93 @@ td {
|
|||||||
min-width: 1320px;
|
min-width: 1320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stalker-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-research-entry {
|
||||||
|
padding: 16px 0;
|
||||||
|
border-top: 1px solid #eceef0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-research-entry:first-child {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-entry-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5f6b7a;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-entry-run {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8a95a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-candidates {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-candidate-card {
|
||||||
|
border: 1px solid #e7e8ea;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-candidate-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-field {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-field-block {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #445060;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-field-block .dist-label {
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-outreach {
|
||||||
|
background: #f7f8fa;
|
||||||
|
border: 1px solid #e7e8ea;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background: #fafafb;
|
background: #fafafb;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
Reference in New Issue
Block a user