Compare commits

...

6 Commits

Author SHA1 Message Date
Victor Noguera
a355359427 feat: implement filter presets and view state persistence across dashboard, run details, product list, and stalker explorer
- Added functionality to save, update, and apply filter presets for various views.
- Introduced local storage management for persisting view states across sessions.
- Enhanced dashboard, run details, product list, and stalker explorer components to utilize saved filter presets.
- Updated UI to include controls for managing filter presets.
2026-05-25 16:59:06 -04:00
Victor Noguera
31cf992e77 refactor: rename findLatestStalkerRunItemIdByAsin to findLatestRunItemIdByAsin and update references 2026-05-25 16:02:07 -04:00
Victor Noguera
506e2344b7 feat: implement reanalyze and distributor discovery endpoints for Stalker products by ASIN 2026-05-25 15:57:24 -04:00
Victor Noguera
313677692b feat: add distributor research functionality with detailed candidate information and outreach options 2026-05-25 15:30:41 -04:00
Victor Noguera
9b45546476 feat: enhance distributor candidate research with additional fields and improved prompt for API request 2026-05-25 15:01:22 -04:00
Victor Noguera
35087a5b2f feat: add product distributor research table and integrate distributor analysis in Stalker product workflow 2026-05-25 14:51:57 -04:00
9 changed files with 1542 additions and 150 deletions

View File

@@ -16,6 +16,18 @@
"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 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)" "Bash(bun run build:web 2>&1 || true)"
] ]
}, },

7
.claude/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(grep -v \"^$\")"
]
}
}

View 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");

View File

@@ -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
} }
] ]
} }

View File

@@ -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),
],
);

View File

@@ -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 (35 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 (12 sentences)',
' "confidence" — integer 0100 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,
);
} }
}, },
}, },

View File

@@ -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";
@@ -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 {
}
});
} }

File diff suppressed because it is too large Load Diff

View File

@@ -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;