@mostajs/agora-sourcing 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @mostajs/agora-sourcing — connecteur AliExpress par API OFFICIELLE (Affiliate / Open Platform).
3
+ * Requête SIGNÉE (schéma TOP : params triés + HMAC-SHA256, hex MAJUSCULE). Clés via `.env`
4
+ * (`ALIEXPRESS_APP_KEY/SECRET`). HTTP injectable (DI) → signature & mapping testables hors réseau.
5
+ * Sans identifiants → `search`/`scrape` renvoient vide proprement (repli sur autres sources/cache).
6
+ * @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
7
+ */
8
+ import { createHmac, createHash } from "node:crypto";
9
+ /**
10
+ * Signe un jeu de paramètres (schéma TOP) : trie les clés, concatène `clé+valeur`, puis
11
+ * HMAC-SHA256 (clé = app_secret) en hex MAJUSCULE. (`md5` legacy : secret+base+secret.)
12
+ */
13
+ export function signTop(params, appSecret, method = "sha256") {
14
+ const base = Object.keys(params)
15
+ .filter((k) => k !== "sign" && params[k] != null && params[k] !== "")
16
+ .sort()
17
+ .map((k) => `${k}${params[k]}`)
18
+ .join("");
19
+ if (method === "md5")
20
+ return createHash("md5").update(appSecret + base + appSecret, "utf8").digest("hex").toUpperCase();
21
+ return createHmac("sha256", appSecret).update(base, "utf8").digest("hex").toUpperCase();
22
+ }
23
+ /** Construit l'URL signée d'un appel API TOP. */
24
+ export function buildSignedUrl(cfg, apiMethod, business) {
25
+ const endpoint = cfg.endpoint ?? "https://api-sg.aliexpress.com/sync";
26
+ const now = (cfg.now ?? Date.now)();
27
+ const params = {
28
+ app_key: cfg.appKey,
29
+ method: apiMethod,
30
+ timestamp: String(now),
31
+ format: "json",
32
+ v: "2.0",
33
+ sign_method: "sha256",
34
+ ...business,
35
+ };
36
+ params.sign = signTop(params, cfg.appSecret, "sha256");
37
+ const qs = Object.keys(params).map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`).join("&");
38
+ return `${endpoint}?${qs}`;
39
+ }
40
+ const num = (v) => {
41
+ if (typeof v === "number" && Number.isFinite(v))
42
+ return v;
43
+ const n = Number.parseFloat(String(v ?? "").replace(/[^\d.,-]/g, "").replace(",", "."));
44
+ return Number.isFinite(n) ? n : null;
45
+ };
46
+ /** Extrait les produits d'une réponse `aliexpress.affiliate.product.query` → Offer[]. */
47
+ export function mapProducts(json) {
48
+ const j = json;
49
+ const list = j?.resp_result?.result?.products?.product ??
50
+ j?.aliexpress_affiliate_product_query_response?.resp_result?.result?.products?.product ??
51
+ j?.products ?? [];
52
+ const out = [];
53
+ for (const p of Array.isArray(list) ? list : []) {
54
+ const price = num(p.target_sale_price ?? p.target_app_sale_price ?? p.sale_price);
55
+ const url = p.product_detail_url || p.promotion_link;
56
+ if (price == null || !url)
57
+ continue;
58
+ out.push({
59
+ supplier: p.shop_name || "AliExpress",
60
+ unitPrice: price,
61
+ currency: (p.target_sale_price_currency || "USD").toUpperCase().slice(0, 3),
62
+ moq: 1,
63
+ country: "CN",
64
+ url: String(url).startsWith("http") ? url : `https:${url}`,
65
+ raw: { title: p.product_title, rating: p.evaluate_rate, productId: p.product_id },
66
+ });
67
+ }
68
+ return out;
69
+ }
70
+ const defaultHttpJson = async (url) => {
71
+ const res = await globalThis.fetch(url);
72
+ return res.json();
73
+ };
74
+ /**
75
+ * Dialecte scraper AliExpress (clé `aliexpress`). Sans `appKey`/`appSecret` → renvoie vide
76
+ * (le sourcing se rabat sur les autres connecteurs / le cache).
77
+ */
78
+ export function aliexpressConnector(cfg = {}) {
79
+ const httpJson = cfg.httpJson ?? defaultHttpJson;
80
+ const ready = !!(cfg.appKey && cfg.appSecret);
81
+ return {
82
+ key: "aliexpress",
83
+ kind: "scraper",
84
+ hosts: ["aliexpress.com", "aliexpress.us"],
85
+ capabilities: { transport: "api", search: true, requiresAuth: true, ready, currency: cfg.targetCurrency ?? "USD", region: cfg.shipToCountry },
86
+ async search(query, opts) {
87
+ if (!ready)
88
+ return [];
89
+ const url = buildSignedUrl(cfg, "aliexpress.affiliate.product.query", {
90
+ keywords: query,
91
+ page_size: String(opts?.limit ?? 20),
92
+ page_no: "1",
93
+ target_currency: cfg.targetCurrency ?? "USD",
94
+ target_language: cfg.targetLanguage ?? "EN",
95
+ ...(cfg.shipToCountry ? { ship_to_country: cfg.shipToCountry } : {}),
96
+ });
97
+ try {
98
+ return mapProducts(await httpJson(url));
99
+ }
100
+ catch {
101
+ return [];
102
+ }
103
+ },
104
+ async scrape(url) {
105
+ if (!ready)
106
+ return null;
107
+ const id = url.match(/item\/(?:[^/]*?)?(\d{6,})\.html/)?.[1] || url.match(/(\d{8,})/)?.[1];
108
+ if (!id)
109
+ return null;
110
+ const api = buildSignedUrl(cfg, "aliexpress.affiliate.productdetail.get", {
111
+ product_ids: id,
112
+ target_currency: cfg.targetCurrency ?? "USD",
113
+ target_language: cfg.targetLanguage ?? "EN",
114
+ });
115
+ try {
116
+ return mapProducts(await httpJson(api))[0] ?? null;
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,8 @@
1
+ import type { Offer, ScraperDialect } from "../types.js";
2
+ import { type HttpOpts } from "../http.js";
3
+ /** Coercition « 1 250,50 € » / « $1,250.50 » → 1250.5 (null si introuvable). */
4
+ export declare function parsePrice(s: unknown): number | null;
5
+ /** Extrait une offre d'un HTML de page produit. Pur (cheerio), testable hors réseau. */
6
+ export declare function parseOffer(html: string, url: string): Offer | null;
7
+ /** Dialecte scraper générique (clé `generic`). Découverte non supportée (scrape direct). */
8
+ export declare function genericScraper(http?: HttpOpts): ScraperDialect;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @mostajs/agora-sourcing — scraper générique « page produit → Offer » (cheerio).
3
+ * Heuristiques par ordre de fiabilité : JSON-LD (schema.org Product/Offer) → meta
4
+ * (og/product) → microdata itemprop → sélecteurs courants. Extraction de FAITS, 0 invention.
5
+ * @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
6
+ */
7
+ import * as cheerio from "cheerio";
8
+ import { getText } from "../http.js";
9
+ /** Coercition « 1 250,50 € » / « $1,250.50 » → 1250.5 (null si introuvable). */
10
+ export function parsePrice(s) {
11
+ if (typeof s === "number" && Number.isFinite(s))
12
+ return s;
13
+ if (typeof s !== "string")
14
+ return null;
15
+ const m = s.replace(/\s/g, "").match(/-?\d[\d.,]*/);
16
+ if (!m)
17
+ return null;
18
+ let t = m[0];
19
+ // si virgule ET point : le dernier séparateur est le décimal
20
+ if (t.includes(",") && t.includes(".")) {
21
+ if (t.lastIndexOf(",") > t.lastIndexOf("."))
22
+ t = t.replace(/\./g, "").replace(",", ".");
23
+ else
24
+ t = t.replace(/,/g, "");
25
+ }
26
+ else if (t.includes(",")) {
27
+ // virgule seule : décimale si 1-2 chiffres après, sinon séparateur de milliers
28
+ t = /,\d{1,2}$/.test(t) ? t.replace(",", ".") : t.replace(/,/g, "");
29
+ }
30
+ const n = Number.parseFloat(t);
31
+ return Number.isFinite(n) ? n : null;
32
+ }
33
+ const CUR = { "€": "EUR", "$": "USD", "£": "GBP", "¥": "JPY" };
34
+ function detectCurrency(s) {
35
+ if (!s)
36
+ return undefined;
37
+ const code = s.match(/\b(EUR|USD|GBP|JPY|CNY|CHF|CAD|AUD)\b/i);
38
+ if (code)
39
+ return code[1].toUpperCase();
40
+ for (const sym of Object.keys(CUR))
41
+ if (s.includes(sym))
42
+ return CUR[sym];
43
+ return undefined;
44
+ }
45
+ /** Extrait une offre d'un HTML de page produit. Pur (cheerio), testable hors réseau. */
46
+ export function parseOffer(html, url) {
47
+ const $ = cheerio.load(html);
48
+ let price = null;
49
+ let currency;
50
+ let title;
51
+ // 1) JSON-LD schema.org
52
+ $('script[type="application/ld+json"]').each((_i, el) => {
53
+ if (price != null)
54
+ return;
55
+ try {
56
+ const data = JSON.parse($(el).contents().text());
57
+ const nodes = Array.isArray(data) ? data : [data, ...(data["@graph"] ?? [])];
58
+ for (const node of nodes) {
59
+ const offer = node?.offers && (Array.isArray(node.offers) ? node.offers[0] : node.offers);
60
+ if (offer?.price != null) {
61
+ price = parsePrice(offer.price);
62
+ currency = offer.priceCurrency || currency;
63
+ title = title || node.name;
64
+ break;
65
+ }
66
+ }
67
+ }
68
+ catch { /* JSON-LD invalide → ignore */ }
69
+ });
70
+ // 2) meta og/product
71
+ const meta = (sel) => $(`meta[property="${sel}"], meta[name="${sel}"]`).attr("content");
72
+ if (price == null) {
73
+ price = parsePrice(meta("product:price:amount") || meta("og:price:amount") || "");
74
+ currency = currency || meta("product:price:currency") || meta("og:price:currency") || undefined;
75
+ }
76
+ title = title || meta("og:title") || $("title").first().text().trim() || undefined;
77
+ const siteName = meta("og:site_name");
78
+ // 3) microdata itemprop
79
+ if (price == null) {
80
+ const ip = $('[itemprop="price"]').first();
81
+ price = parsePrice(ip.attr("content") || ip.text());
82
+ currency = currency || $('[itemprop="priceCurrency"]').first().attr("content") || undefined;
83
+ }
84
+ // 4) sélecteurs courants
85
+ if (price == null) {
86
+ const cand = $('[class*="price"], [data-price]').first();
87
+ price = parsePrice(cand.attr("data-price") || cand.text());
88
+ currency = currency || detectCurrency(cand.text());
89
+ }
90
+ if (price == null)
91
+ return null;
92
+ const supplier = (siteName || new URL(url).hostname.replace(/^www\./, "")).trim();
93
+ return {
94
+ supplier,
95
+ unitPrice: price,
96
+ currency: (currency || "USD").toUpperCase().slice(0, 3),
97
+ url,
98
+ raw: title ? { title } : undefined,
99
+ };
100
+ }
101
+ /** Dialecte scraper générique (clé `generic`). Découverte non supportée (scrape direct). */
102
+ export function genericScraper(http = {}) {
103
+ return {
104
+ key: "generic",
105
+ kind: "scraper",
106
+ capabilities: { transport: "html", search: false, requiresAuth: false, ready: true },
107
+ async scrape(url) {
108
+ const html = await getText(url, http);
109
+ return parseOffer(html, url);
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,24 @@
1
+ import type { Offer, ScraperDialect } from "../types.js";
2
+ import { type HttpOpts } from "../http.js";
3
+ /** Profil d'un site e-commerce : sélecteurs CSS + métadonnées de routage. */
4
+ export interface SiteProfile {
5
+ key: string;
6
+ hosts: string[];
7
+ /** Sélecteur du prix (texte ou attribut content/data-price). */
8
+ price: string;
9
+ /** Sélecteur de la devise, OU code fixe (ex. "EUR"). */
10
+ currency?: string;
11
+ title?: string;
12
+ /** Nom fournisseur fixe (sinon hostname). */
13
+ supplier?: string;
14
+ country?: string;
15
+ /** Gabarit d'URL de recherche (liste) + sélecteur des liens produits. */
16
+ searchUrl?: (q: string) => string;
17
+ productLinks?: string;
18
+ }
19
+ /** Extraction par profil (pure, testable hors réseau). Repli générique si prix introuvable. */
20
+ export declare function parseWithProfile(html: string, url: string, p: SiteProfile): Offer | null;
21
+ /** Construit un dialecte scraper à partir d'un profil de site. */
22
+ export declare function makeSiteScraper(p: SiteProfile, http?: HttpOpts): ScraperDialect;
23
+ /** Profils e-commerce fournis (exemples génériques, extensibles par données). */
24
+ export declare const BUILTIN_PROFILES: SiteProfile[];
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @mostajs/agora-sourcing — scraper e-commerce piloté par PROFIL (data-driven).
3
+ * Ajouter une marketplace = des SÉLECTEURS (données), pas du code → forme dialectes.
4
+ * Repli sur le scraper générique (JSON-LD/meta) si un sélecteur manque.
5
+ * @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
6
+ */
7
+ import * as cheerio from "cheerio";
8
+ import { getText } from "../http.js";
9
+ import { parseOffer, parsePrice } from "./generic.js";
10
+ const text = ($, sel) => {
11
+ if (!sel)
12
+ return undefined;
13
+ const el = $(sel).first();
14
+ return (el.attr("content") || el.attr("data-price") || el.text() || "").trim() || undefined;
15
+ };
16
+ /** Extraction par profil (pure, testable hors réseau). Repli générique si prix introuvable. */
17
+ export function parseWithProfile(html, url, p) {
18
+ const $ = cheerio.load(html);
19
+ const price = parsePrice(text($, p.price));
20
+ if (price == null)
21
+ return parseOffer(html, url); // repli JSON-LD/meta
22
+ const rawCur = p.currency && /^[A-Z]{3}$/.test(p.currency) ? p.currency : text($, p.currency);
23
+ const currency = (rawCur?.match(/[A-Z]{3}/)?.[0]) || (text($, p.price)?.includes("€") ? "EUR" : "USD");
24
+ const supplier = p.supplier || new URL(url).hostname.replace(/^www\./, "");
25
+ const title = text($, p.title);
26
+ return { supplier, unitPrice: price, currency, country: p.country, url, raw: title ? { title } : undefined };
27
+ }
28
+ /** Construit un dialecte scraper à partir d'un profil de site. */
29
+ export function makeSiteScraper(p, http = {}) {
30
+ return {
31
+ key: p.key,
32
+ kind: "scraper",
33
+ hosts: p.hosts,
34
+ capabilities: { transport: "html", search: !!(p.searchUrl && p.productLinks), requiresAuth: false, ready: true, currency: p.currency && /^[A-Z]{3}$/.test(p.currency) ? p.currency : undefined },
35
+ async scrape(url) {
36
+ const html = await getText(url, http);
37
+ return parseWithProfile(html, url, p);
38
+ },
39
+ ...(p.searchUrl && p.productLinks
40
+ ? {
41
+ async search(query, opts) {
42
+ const html = await getText(p.searchUrl(query), http);
43
+ const $ = cheerio.load(html);
44
+ const links = [];
45
+ const base = `https://${p.hosts[0] ?? ""}`;
46
+ $(p.productLinks).each((_i, el) => {
47
+ const href = $(el).attr("href");
48
+ if (href) {
49
+ try {
50
+ links.push(new URL(href, base).toString());
51
+ }
52
+ catch { /* href invalide */ }
53
+ }
54
+ return links.length < (opts?.limit ?? 10);
55
+ });
56
+ const offers = await Promise.all(links.map((u) => this.scrape(u).catch(() => null)));
57
+ return offers.filter((o) => o != null);
58
+ },
59
+ }
60
+ : {}),
61
+ };
62
+ }
63
+ /** Profils e-commerce fournis (exemples génériques, extensibles par données). */
64
+ export const BUILTIN_PROFILES = [
65
+ // La plupart des marketplaces exposent JSON-LD → le scraper « generic » suffit souvent.
66
+ // Profil d'exemple pour un site sans JSON-LD (sélecteurs explicites) :
67
+ { key: "shopx", hosts: ["shopx.example"], price: ".product-price", title: ".product-name", supplier: "ShopX" },
68
+ ];
package/dist/tool.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @mostajs/agora-sourcing — pont CHATBOT : outil `agora_sourcing` (forme ChatTool) que le copilote
3
+ * (`@mostajs/chatbot`) peut appeler comme CHEMIN DÉTERMINISTE (scraping + optimisation), alternative
4
+ * à la recherche web LLM. Renvoie des FAITS (prix vérifiables) + l'allocation optimale ; RFQ optionnelle.
5
+ * @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
6
+ */
7
+ import type { HttpOpts } from "./http.js";
8
+ import type { Offer, SolverPort, CachePort } from "./types.js";
9
+ import { type RfqDeps } from "./rfq.js";
10
+ export interface AgoraToolDeps {
11
+ http?: HttpOpts;
12
+ solver?: SolverPort;
13
+ cache?: CachePort;
14
+ cacheTtlMs?: number;
15
+ maxUrls?: number;
16
+ /** Clé de découverte du registre (ex. "llm" pour Claude, "ddg"). */
17
+ discoveryKey?: string;
18
+ /** Filtre de pertinence (défaut on) ; `false` ou seuil. */
19
+ relevance?: boolean | number;
20
+ /** Découverte/scrape injectables (tests) ; défaut = registre. */
21
+ discover?: (query: string) => Promise<string[]>;
22
+ scrape?: (url: string) => Promise<Offer | null>;
23
+ rfq?: RfqDeps;
24
+ }
25
+ /** Construit l'outil `agora_sourcing` (ChatTool) pour le copilote. */
26
+ export declare function makeAgoraSourcingTool(deps?: AgoraToolDeps): {
27
+ name: string;
28
+ description: string;
29
+ schema: {
30
+ type: string;
31
+ properties: {
32
+ product: {
33
+ type: string;
34
+ description: string;
35
+ };
36
+ qty: {
37
+ type: string;
38
+ description: string;
39
+ };
40
+ budget: {
41
+ type: string;
42
+ };
43
+ maxSuppliers: {
44
+ type: string;
45
+ };
46
+ rfq: {
47
+ type: string;
48
+ description: string;
49
+ };
50
+ };
51
+ required: string[];
52
+ };
53
+ run(input: Record<string, unknown>): Promise<unknown>;
54
+ };
package/dist/tool.js ADDED
@@ -0,0 +1,53 @@
1
+ import { agoraSourceFromQuery } from "./index.js";
2
+ import { sendAgoraRfqs } from "./rfq.js";
3
+ const slim = (o) => ({ supplier: o.supplier, unitPrice: o.unitPrice, currency: o.currency, moq: o.moq, country: o.country, url: o.url });
4
+ /** Construit l'outil `agora_sourcing` (ChatTool) pour le copilote. */
5
+ export function makeAgoraSourcingTool(deps = {}) {
6
+ return {
7
+ name: "agora_sourcing",
8
+ description: "Source un produit sur le web de façon DÉTERMINISTE (scraping + optimisation d'achat). " +
9
+ "Renvoie des offres réelles (prix vérifiables avec lien source), le Top-N par prix, et " +
10
+ "l'ALLOCATION D'ACHAT OPTIMALE (combien commander chez qui, paliers B2B pris en compte). " +
11
+ "Préciser la quantité pour l'optimisation. Mettre rfq=true pour préparer des demandes de devis (brouillon).",
12
+ schema: {
13
+ type: "object",
14
+ properties: {
15
+ product: { type: "string", description: "produit à sourcer" },
16
+ qty: { type: "number", description: "quantité souhaitée (pour l'optimisation d'achat)" },
17
+ budget: { type: "number" },
18
+ maxSuppliers: { type: "number" },
19
+ rfq: { type: "boolean", description: "préparer des demandes de devis (brouillon)" },
20
+ },
21
+ required: ["product"],
22
+ },
23
+ async run(input) {
24
+ const product = String(input?.product ?? "").trim();
25
+ if (!product)
26
+ return { error: "produit manquant" };
27
+ const qty = Number(input?.qty) > 0 ? Number(input.qty) : 1;
28
+ const constraints = {
29
+ ...(Number(input?.budget) > 0 ? { budget: Number(input.budget) } : {}),
30
+ ...(Number(input?.maxSuppliers) > 0 ? { maxSuppliers: Number(input.maxSuppliers) } : {}),
31
+ };
32
+ const res = await agoraSourceFromQuery(product, {
33
+ qty, constraints, topN: 5,
34
+ http: deps.http, solver: deps.solver, cache: deps.cache, cacheTtlMs: deps.cacheTtlMs,
35
+ maxUrls: deps.maxUrls, discover: deps.discover, scrape: deps.scrape,
36
+ discoveryKey: deps.discoveryKey, relevance: deps.relevance,
37
+ });
38
+ const out = {
39
+ product, qty,
40
+ count: res.offers.length,
41
+ fromCache: !!res.fromCache,
42
+ topN: res.topN.map(slim),
43
+ allocation: res.allocation,
44
+ note: res.offers.length === 0 ? "aucune offre (sources indisponibles / sans clé API) — voir cache." : undefined,
45
+ };
46
+ if (input?.rfq === true && res.allocation.bySupplier.length) {
47
+ const retained = res.allocation.bySupplier.map((b) => res.offers.find((o) => o.url === b.url) ?? { supplier: b.supplier, unitPrice: b.unitPrice, currency: b.currency, url: b.url });
48
+ out.rfq = await sendAgoraRfqs(retained, product, deps.rfq);
49
+ }
50
+ return out;
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @mostajs/agora-sourcing — types du sourcing maison (collecte → optimisation → RFQ).
3
+ * @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
4
+ */
5
+ /** Palier de prix dégressif B2B (remise sur volume, « all-units ») : prix unitaire si qté ≥ minQty. */
6
+ export interface PriceTier {
7
+ minQty: number;
8
+ unitPrice: number;
9
+ }
10
+ /** Une offre — un FAIT collecté (scrappé), pas une supposition LLM. */
11
+ export interface Offer {
12
+ supplier: string;
13
+ unitPrice: number;
14
+ currency: string;
15
+ /** Quantité minimale de commande. */
16
+ moq?: number;
17
+ /** Quantité disponible (= capacité pour l'optimisation). */
18
+ stock?: number;
19
+ /** Paliers de prix dégressifs (B2B : Alibaba/1688). Si présent, l'optimiseur choisit le bon palier. */
20
+ priceTiers?: PriceTier[];
21
+ /** Coût de livraison PAR UNITÉ (dans `currency`) — pour le coût rendu (landed cost). */
22
+ shippingCost?: number;
23
+ /** Délai d'approvisionnement estimé (jours) — pour le multi-objectif prix↔délai. */
24
+ leadTimeDays?: number;
25
+ country?: string;
26
+ url: string;
27
+ /** Données brutes d'origine (debug / traçabilité). */
28
+ raw?: unknown;
29
+ }
30
+ /** `fetch` injectable (DI) — par défaut le fetch global. */
31
+ export type FetchFn = (url: string, init?: unknown) => Promise<{
32
+ status: number;
33
+ text(): Promise<string>;
34
+ }>;
35
+ export interface SearchOpts {
36
+ limit?: number;
37
+ }
38
+ /** Capacités déclarées d'un scraper (façon `SolverDialect.capabilities` de ro-pla / matrice ORM). */
39
+ export interface ScraperCapabilities {
40
+ /** Mode d'accès : scraping HTML ou API officielle. */
41
+ transport: "html" | "api";
42
+ /** Sait découvrir par requête libre (`search`) ? */
43
+ search: boolean;
44
+ /** Nécessite des identifiants (clé .env) ? */
45
+ requiresAuth: boolean;
46
+ /** Identifiants présents / prêt à appeler ? */
47
+ ready: boolean;
48
+ /** Devise native des prix (ex. CNY) — pour la normalisation FX. */
49
+ currency?: string;
50
+ /** Région/pays par défaut. */
51
+ region?: string;
52
+ }
53
+ /** Un scraper = un dialecte (forme registre façon ORM). */
54
+ export interface ScraperDialect {
55
+ key: string;
56
+ kind: "scraper";
57
+ /** Hôtes pris en charge (routage URL → scraper) ; absent = scraper générique. */
58
+ hosts?: string[];
59
+ /** Capacités déclarées (diagnostic / auto-sélection / « ready »). */
60
+ capabilities?: ScraperCapabilities;
61
+ /** Recherche par requête libre (si le site la supporte). */
62
+ search?(query: string, opts?: SearchOpts): Promise<Offer[]>;
63
+ /** Scrape une URL produit précise → une offre (ou null). */
64
+ scrape(url: string): Promise<Offer | null>;
65
+ }
66
+ /** Un moteur de découverte = un dialecte : requête libre → URLs candidates. */
67
+ export interface DiscoveryDialect {
68
+ key: string;
69
+ kind: "discovery";
70
+ discover(query: string, opts?: SearchOpts): Promise<string[]>;
71
+ }
72
+ /** Contraintes d'achat (optimisation). */
73
+ export interface OptimizeConstraints {
74
+ budget?: number;
75
+ maxSuppliers?: number;
76
+ allowedCountries?: string[];
77
+ }
78
+ /** Part d'achat affectée à un fournisseur. */
79
+ export interface AllocationItem {
80
+ supplier: string;
81
+ qty: number;
82
+ unitPrice: number;
83
+ currency: string;
84
+ cost: number;
85
+ url: string;
86
+ }
87
+ /** Décision d'achat calculée (par ro-pla). */
88
+ export interface Allocation {
89
+ bySupplier: AllocationItem[];
90
+ totalCost: number;
91
+ /** Quantité réellement couverte. */
92
+ filled: number;
93
+ status: "optimal" | "partial" | "infeasible";
94
+ }
95
+ /**
96
+ * Port solveur (DI) — compatible `@mostajs/ro-pla` : `solve(problem)` route par `problem.kind`.
97
+ * On n'importe PAS ro-pla ici (peer) ; l'app/le test injecte l'implémentation.
98
+ */
99
+ export interface SolverPort {
100
+ solve(problem: {
101
+ kind: string;
102
+ } & Record<string, unknown>, opts?: unknown): unknown | Promise<unknown>;
103
+ }
104
+ /** Résultat d'un sourcing complet. */
105
+ export interface SourcingResult {
106
+ offers: Offer[];
107
+ topN: Offer[];
108
+ allocation: Allocation;
109
+ /** Vrai si servi depuis le cache ORM (repli « dernier connu » ou hit frais). */
110
+ fromCache?: boolean;
111
+ /** Horodatage (ms) du résultat caché servi. */
112
+ cachedAt?: number;
113
+ }
114
+ /** Persistance des résultats (cache) — repli « au moins du disponible » sur nouvelle recherche. */
115
+ export interface CachePort {
116
+ get(queryKey: string): Promise<{
117
+ result: SourcingResult;
118
+ at: number;
119
+ } | null>;
120
+ put(queryKey: string, result: SourcingResult): Promise<void>;
121
+ }
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @mostajs/agora-sourcing — types du sourcing maison (collecte → optimisation → RFQ).
3
+ * @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
4
+ */
5
+ export {};
package/llms.txt ADDED
@@ -0,0 +1,37 @@
1
+ # @mostajs/agora-sourcing — fiche LLM
2
+ > Sourcing maison déterministe : SCRAPE les faits (cheerio + registre de scrapers façon ORM) → OPTIMISE l'achat (@mostajs/ro-pla) → RFQ (@mostajs/sourcing) ; le LLM ne fait que synthétiser.
3
+
4
+ - Version: 0.0.1 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI <drmdh@msn.com>
5
+ - Chemin: mostajs/mosta-agora-sourcing · deps: cheerio, @mostajs/config ; peers: @mostajs/ro-pla (solveur), @mostajs/sourcing (RFQ), @mostajs/orm (cache, optionnel) ; puppeteer optionnel.
6
+
7
+ ## RÔLE
8
+ Orchestre la chaîne « collecte → décision → RFQ » sans demander au LLM de chercher ni d'optimiser : un moteur de scraping déterministe (ex-AgoraScope-Studio) collecte les offres (prix, devise, MOQ, pays, lien), `@mostajs/ro-pla` calcule l'allocation d'achat exacte (min-cost-flow / MILP / hungarian), `@mostajs/sourcing` envoie les RFQ. Tout est injecté (DI) : scrapers, découverte, solveur, dispatchers, cache, lecteur d'env — agnostique et testable par stub. Stade proposition (0.0.1) : périmètre posé, moteur amorcé. NE fait pas la synthèse LLM elle-même ni la résolution RO (peer `ro-pla`).
9
+
10
+ ## EXPORTS
11
+ - Orchestration: `registerBuiltins`, `registerApis`, `defaultSolver`, `agoraSourceFromUrls`, `agoraSourceFromQuery` ; types `AgoraSourceOpts`, `QuerySourceOpts`.
12
+ - Registre (façon ORM): `registerScraper`, `getScraper`, `listScrapers`, `clearScrapers`, `activeScrapers`, `pickScraper`, `registerDiscovery`, `getDiscovery`, `listDiscovery`, `clearDiscovery`.
13
+ - HTTP: `getText`, `isAllowedByRobots`, type `HttpOpts`.
14
+ - Scrapers: `genericScraper`, `parseOffer`, `parsePrice` ; `makeSiteScraper`, `parseWithProfile`, `BUILTIN_PROFILES`, type `SiteProfile` ; `aliexpressConnector`, `signTop`, `buildSignedUrl`, `mapProducts`, types `AliexpressConfig`, `JsonFetch`.
15
+ - Découverte: `ddgDiscovery`, `parseDdgResults` ; `makeLlmDiscovery`, `parseUrlList`, type `DiscoveryLlm`.
16
+ - Optimisation: `optimizeOffers`, `optimizeSplitBuy`, `optimizeConstrained`, `optimizeTiered`, `optimizeAssignment`, `assignLinesToSuppliers`, `optimizeContinuous`, `topNByPrice`, types `AssignLine`, `LineAssignment`.
17
+ - RFQ: `sendAgoraRfqs`, `toSourcingOffers`, types `RfqDeps`, `SourcingOffer`, `SendRfqs`.
18
+ - Normalisation/pertinence: `normalizeOffers`, `landedUnit`, `convert`, types `FxRates`, `LandedOptions` ; `filterRelevant`, `relevanceScore`, `tokens`.
19
+ - Config: `loadConfig`, `isEnabled`, `configuredApis`, `readerFromObject`, types `AgoraConfig`, `ApiCredential`, `EnvReader`.
20
+ - Cache: `makeOrmCache`, `openFileCache`, `MemoryCache`, `AGORA_SEARCH_SCHEMA`.
21
+ - Outil/types: `makeAgoraSourcingTool`, type `AgoraToolDeps` ; `export * from types` (`Offer`, `PriceTier`, `ScraperDialect`, `DiscoveryDialect`, `OptimizeConstraints`, `Allocation`, `SolverPort`, `CachePort`, `SourcingResult`, …).
22
+
23
+ ## API
24
+ - `registerBuiltins(http?: HttpOpts): void` — enregistre scraper générique + profils e-commerce + découverte DDG.
25
+ - `registerApis(cfg: AgoraConfig, deps?: { httpJson? }): string[]` — enregistre les connecteurs API dont les clés `.env` sont présentes ET actives ; renvoie les clés posées.
26
+ - `defaultSolver(): Promise<SolverPort>` — charge paresseusement `@mostajs/ro-pla` (peer) au runtime.
27
+ - `agoraSourceFromUrls(urls, opts: AgoraSourceOpts, scrape): Promise<SourcingResult>` — scrape chaque URL → filtre → `optimizeOffers` → `{ offers, topN, allocation }`. `AgoraSourceOpts = { qty, constraints?, topN?, solver?, filter? }`.
28
+ - `agoraSourceFromQuery(query, opts: QuerySourceOpts): Promise<SourcingResult>` — pipeline complet avec persistance : (1) hit cache frais < `cacheTtlMs` ; (2) live découverte→scrape routé→optimisation, persiste si succès ; (3) repli sur dernier connu. `QuerySourceOpts` ajoute `http?, maxUrls?, queryKey?, cache?, cacheTtlMs?, discover?, discoveryKey?, scrape?, relevance?`.
29
+ - `loadConfig(env?): AgoraConfig` — sans arg = cascade `@mostajs/config` ; objet plat ou `EnvReader` = tests.
30
+ - `makeOrmCache(orm/conn)` / `openFileCache(...)` / `MemoryCache` — `CachePort { get, put }` ; schéma `AGORA_SEARCH_SCHEMA` (queryKey unique, payload `text`, at `number`).
31
+
32
+ ## PIÈGES
33
+ - Cache ORM : le résultat est sérialisé dans `payload` de type **`text`** (JSON), pas un champ `'object'` (interdit en ORM → préférer `'text'`/`'json'`). Schéma `schemaStrategy: "update"`, upsert sur `queryKey`.
34
+ - Peers non installés au build : `@mostajs/ro-pla` est importé **paresseusement** par `defaultSolver()` via specifier indirect ; sans lui, injecter un `SolverPort` (`opts.solver`) sinon échec runtime. `@mostajs/orm` est optionnel (cache).
35
+ - `registerBuiltins()` (ou injection de `discover`/`scrape`) est requis avant `agoraSourceFromQuery` : sinon repli sur scraper générique / DDG seulement.
36
+ - Env via `@mostajs/config` (cascade `.env` + `MOSTA_ENV`) : `AGORA_CURRENCY` est tronquée/majusculée sur 3 lettres ; `AGORA_SCRAPERS` vide ⇒ tous actifs (`isEnabled`). Un connecteur API sans `*_APP_KEY`+`*_APP_SECRET` n'est pas enregistré.
37
+ - Conformité : respect `robots.txt`/CGU par défaut (`isAllowedByRobots`), rate-limit, UA identifié ; pas de contournement abusif.