@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.
- package/README.md +54 -0
- package/dist/cache.d.ts +30 -0
- package/dist/cache.js +52 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.js +54 -0
- package/dist/discover-llm.d.ts +15 -0
- package/dist/discover-llm.js +23 -0
- package/dist/discover.d.ts +6 -0
- package/dist/discover.js +51 -0
- package/dist/http.d.ts +25 -0
- package/dist/http.js +101 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +121 -0
- package/dist/landed.d.ts +28 -0
- package/dist/landed.js +32 -0
- package/dist/optimize.d.ts +65 -0
- package/dist/optimize.js +332 -0
- package/dist/registry.d.ts +25 -0
- package/dist/registry.js +47 -0
- package/dist/relevance.d.ts +13 -0
- package/dist/relevance.js +25 -0
- package/dist/rfq.d.ts +37 -0
- package/dist/rfq.js +28 -0
- package/dist/scrapers/aliexpress.d.ts +31 -0
- package/dist/scrapers/aliexpress.js +123 -0
- package/dist/scrapers/generic.d.ts +8 -0
- package/dist/scrapers/generic.js +112 -0
- package/dist/scrapers/site-profile.d.ts +24 -0
- package/dist/scrapers/site-profile.js +68 -0
- package/dist/tool.d.ts +54 -0
- package/dist/tool.js +53 -0
- package/dist/types.d.ts +121 -0
- package/dist/types.js +5 -0
- package/llms.txt +37 -0
- package/package.json +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @mostajs/agora-sourcing
|
|
2
|
+
|
|
3
|
+
**Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later
|
|
4
|
+
**Statut** : ⚠️ **proposition / scaffold (0.0.1)** — non implémenté, périmètre à valider (cas C).
|
|
5
|
+
|
|
6
|
+
**Le sourcing maison — « moins d'IA, plus d'outils ».** Au lieu de demander à un LLM de *chercher* les
|
|
7
|
+
offres (où il hallucine et ne sait pas optimiser), `agora-sourcing` **scrape les faits**, **calcule la
|
|
8
|
+
meilleure décision d'achat**, puis ne laisse au LLM que la **synthèse**.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
SCRAPE (faits) OPTIMISE (décision) RFQ / PRÉSENTE
|
|
12
|
+
moteur agora (cheerio) → @mostajs/ro-pla (solve) → @mostajs/sourcing (sendRfqs)
|
|
13
|
+
registre de scrapers min-cost-flow / MILP / hungarian + Top-N
|
|
14
|
+
prix, devise, lien allocation optimale
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Trois briques composées
|
|
18
|
+
|
|
19
|
+
| Brique | Module | Rôle |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| **Collecte** | moteur **agora** (extrait d'`AgoraScope-Studio`) | scraping `cheerio` (+ puppeteer optionnel), **registre de scrapers façon ORM**, anti-bot (rate-limit/retries/CloudFlare), `search`/`scrape` |
|
|
22
|
+
| **Optimisation** | **`@mostajs/ro-pla`** (composé) | transforme les offres en problème exact : achat fractionné (`min-cost-flow`), sélection sous contraintes (`MILP`), affectation (`hungarian`) |
|
|
23
|
+
| **RFQ** | **`@mostajs/sourcing`** (composé) | `sendRfqs`, modèle `Offer`, dispatchers — **réutilisé, pas réécrit** |
|
|
24
|
+
|
|
25
|
+
## API (esquisse)
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
agoraSource(query | order, { qty, constraints }) → {
|
|
29
|
+
offers, // offres scrappées (faits : fournisseur, prix+devise, MOQ, pays, lien)
|
|
30
|
+
allocation, // décision d'achat optimale (ro-pla) : combien acheter chez qui
|
|
31
|
+
topN // Top-N par prix (référence)
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Tout injecté (DI)** : scrapers, solveur `ro-pla`, dispatchers RFQ — le module est agnostique et testable
|
|
36
|
+
par stub (règle d'or).
|
|
37
|
+
|
|
38
|
+
## Périmètre
|
|
39
|
+
|
|
40
|
+
- **Numérique** : réutilise les 21 scrapers d'`AgoraScope-Studio` (templates/code/apps).
|
|
41
|
+
- **Physique (négoce)** : ajoute **connecteurs e-commerce** (Amazon/AliExpress/Alibaba…) + **découverte
|
|
42
|
+
moteur de recherche** (query → URLs candidates → scrape). *(Hypothèse de travail : négoce = physique ;
|
|
43
|
+
ajustable.)*
|
|
44
|
+
|
|
45
|
+
## Conformité
|
|
46
|
+
Respect **robots.txt / CGU** par défaut, rate-limit, identification UA ; pas de contournement abusif.
|
|
47
|
+
Voir livrable #15 (sécurité/abus) avant prod.
|
|
48
|
+
|
|
49
|
+
## Livrables (DEVRULES §14)
|
|
50
|
+
- #1 Étude : `docs/01-ETUDE-AGORA-SOURCING.md`
|
|
51
|
+
- #2 Audit : `docs/02-AUDIT-AGORA-SOURCING.md`
|
|
52
|
+
- #3 Plan dev/test : `docs/03-PLAN-DEV-TEST-AGORA-SOURCING.md`
|
|
53
|
+
|
|
54
|
+
> ⚠️ Non implémenté : ce README décrit l'**intention**. Voir le plan pour le séquencement.
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/agora-sourcing — persistance des résultats (cache) via **@mostajs/orm**.
|
|
3
|
+
* But : sur une nouvelle recherche, si le live échoue/ne renvoie rien, servir le DERNIER résultat
|
|
4
|
+
* connu (« au moins du disponible »). Persistance SUR FICHIER (sqljs/sqlite…), pas en mémoire.
|
|
5
|
+
* Composition stricte de @mostajs/orm (jamais de SQL direct). DI : la connexion est injectée.
|
|
6
|
+
* @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
7
|
+
*/
|
|
8
|
+
import type { EntitySchema, IDialect } from "@mostajs/orm";
|
|
9
|
+
import type { CachePort, SourcingResult } from "./types.js";
|
|
10
|
+
/** Schéma ORM du cache (une ligne par requête). `payload` = JSON sérialisé du SourcingResult. */
|
|
11
|
+
export declare const AGORA_SEARCH_SCHEMA: EntitySchema;
|
|
12
|
+
/** Cache adossé à une connexion ORM déjà ouverte (DI). Aucune dépendance directe à un SGBD. */
|
|
13
|
+
export declare function makeOrmCache(conn: IDialect): CachePort;
|
|
14
|
+
/**
|
|
15
|
+
* Ouvre un cache persistant SUR FICHIER (compose @mostajs/orm, dialecte au choix, défaut sqljs).
|
|
16
|
+
* `uri` = chemin du fichier (ex. './sourcing-cache.db'). Renvoie le cache + `close()`.
|
|
17
|
+
* @mostajs/orm (et son driver, ex. sql.js) sont des PEERS — chargés au runtime.
|
|
18
|
+
*/
|
|
19
|
+
export declare function openFileCache(uri: string, dialect?: string): Promise<CachePort & {
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
}>;
|
|
22
|
+
/** Cache mémoire (défaut/tests rapides ; NON persistant — préférer openFileCache en prod). */
|
|
23
|
+
export declare class MemoryCache implements CachePort {
|
|
24
|
+
private m;
|
|
25
|
+
get(queryKey: string): Promise<{
|
|
26
|
+
result: SourcingResult;
|
|
27
|
+
at: number;
|
|
28
|
+
} | null>;
|
|
29
|
+
put(queryKey: string, result: SourcingResult): Promise<void>;
|
|
30
|
+
}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Schéma ORM du cache (une ligne par requête). `payload` = JSON sérialisé du SourcingResult. */
|
|
2
|
+
export const AGORA_SEARCH_SCHEMA = {
|
|
3
|
+
name: "AgoraSearch",
|
|
4
|
+
collection: "agora_searches",
|
|
5
|
+
fields: {
|
|
6
|
+
queryKey: { type: "string", required: true, unique: true },
|
|
7
|
+
payload: { type: "text", required: true },
|
|
8
|
+
at: { type: "number", required: true },
|
|
9
|
+
},
|
|
10
|
+
relations: {},
|
|
11
|
+
indexes: [],
|
|
12
|
+
timestamps: false,
|
|
13
|
+
};
|
|
14
|
+
/** Cache adossé à une connexion ORM déjà ouverte (DI). Aucune dépendance directe à un SGBD. */
|
|
15
|
+
export function makeOrmCache(conn) {
|
|
16
|
+
return {
|
|
17
|
+
async get(queryKey) {
|
|
18
|
+
const row = await conn.findOne(AGORA_SEARCH_SCHEMA, { queryKey });
|
|
19
|
+
if (!row)
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
return { result: JSON.parse(row.payload), at: Number(row.at) };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
async put(queryKey, result) {
|
|
29
|
+
const payload = JSON.stringify({ ...result, fromCache: undefined });
|
|
30
|
+
await conn.upsert(AGORA_SEARCH_SCHEMA, { queryKey }, { queryKey, payload, at: Date.now() });
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Ouvre un cache persistant SUR FICHIER (compose @mostajs/orm, dialecte au choix, défaut sqljs).
|
|
36
|
+
* `uri` = chemin du fichier (ex. './sourcing-cache.db'). Renvoie le cache + `close()`.
|
|
37
|
+
* @mostajs/orm (et son driver, ex. sql.js) sont des PEERS — chargés au runtime.
|
|
38
|
+
*/
|
|
39
|
+
export async function openFileCache(uri, dialect = "sqljs") {
|
|
40
|
+
const orm = (await import("@mostajs/orm"));
|
|
41
|
+
// createIsolatedDialect : connexion INDÉPENDANTE (pas le singleton global) → réouvertures sûres ;
|
|
42
|
+
// sqljs recharge le fichier au connect et le flush après chaque write/disconnect (persistance fichier).
|
|
43
|
+
const conn = await orm.createIsolatedDialect({ dialect: dialect, uri, schemaStrategy: "update" }, [AGORA_SEARCH_SCHEMA]);
|
|
44
|
+
const cache = makeOrmCache(conn);
|
|
45
|
+
return { ...cache, async close() { await conn.disconnect(); } };
|
|
46
|
+
}
|
|
47
|
+
/** Cache mémoire (défaut/tests rapides ; NON persistant — préférer openFileCache en prod). */
|
|
48
|
+
export class MemoryCache {
|
|
49
|
+
m = new Map();
|
|
50
|
+
async get(queryKey) { return this.m.get(queryKey) ?? null; }
|
|
51
|
+
async put(queryKey, result) { this.m.set(queryKey, { result: { ...result, fromCache: undefined }, at: Date.now() }); }
|
|
52
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Couple clé/secret d'une API marketplace officielle. */
|
|
2
|
+
export interface ApiCredential {
|
|
3
|
+
appKey: string;
|
|
4
|
+
appSecret: string;
|
|
5
|
+
}
|
|
6
|
+
export interface AgoraConfig {
|
|
7
|
+
scrapers: string[];
|
|
8
|
+
discovery: string;
|
|
9
|
+
currency: string;
|
|
10
|
+
locale: string;
|
|
11
|
+
maxPages: number;
|
|
12
|
+
maxUrls: number;
|
|
13
|
+
cacheTtlMs: number;
|
|
14
|
+
proxyUrl?: string;
|
|
15
|
+
credentials: Partial<Record<"aliexpress" | "alibaba" | "dhgate" | "i1688", ApiCredential>>;
|
|
16
|
+
}
|
|
17
|
+
/** Lecteur d'env (str/num). Défaut = cascade @mostajs/config ; injectable pour les tests. */
|
|
18
|
+
export interface EnvReader {
|
|
19
|
+
str(key: string, fallback?: string): string | undefined;
|
|
20
|
+
num(key: string, fallback: number): number;
|
|
21
|
+
}
|
|
22
|
+
/** Construit un lecteur depuis un objet plat (tests). */
|
|
23
|
+
export declare function readerFromObject(env: Record<string, string | undefined>): EnvReader;
|
|
24
|
+
/**
|
|
25
|
+
* Charge la config. Sans argument → cascade `@mostajs/config` (prod). Avec un objet env → lecture
|
|
26
|
+
* directe (tests). On peut aussi passer un `EnvReader` custom.
|
|
27
|
+
*/
|
|
28
|
+
export declare function loadConfig(env?: Record<string, string | undefined> | EnvReader): AgoraConfig;
|
|
29
|
+
/** Un connecteur est-il actif ? (liste vide ⇒ tous). */
|
|
30
|
+
export declare function isEnabled(cfg: AgoraConfig, key: string): boolean;
|
|
31
|
+
/** Connecteurs API dont les identifiants sont présents ET actifs. */
|
|
32
|
+
export declare function configuredApis(cfg: AgoraConfig): string[];
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/agora-sourcing — configuration TYPÉE, composée sur **@mostajs/config** (cascade de profils).
|
|
3
|
+
* On ne réimplémente PAS la lecture d'env : `@mostajs/config` (getEnv/getEnvNumber) gère la cascade
|
|
4
|
+
* `.env` + profils (MOSTA_ENV). Ce fichier ne fait que MAPPER ces clés vers un objet typé.
|
|
5
|
+
* Reste injectable (objet env) pour les tests. Les SECRETS vivent dans le `.env` (jamais committés).
|
|
6
|
+
* @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
7
|
+
*/
|
|
8
|
+
import { getEnv, getEnvNumber } from "@mostajs/config";
|
|
9
|
+
const cascadeReader = { str: (k, d) => getEnv(k, d), num: (k, d) => getEnvNumber(k, d) };
|
|
10
|
+
/** Construit un lecteur depuis un objet plat (tests). */
|
|
11
|
+
export function readerFromObject(env) {
|
|
12
|
+
return {
|
|
13
|
+
str: (k, d) => (env[k] ?? d),
|
|
14
|
+
num: (k, d) => { const n = Number.parseInt(env[k] ?? "", 10); return Number.isFinite(n) ? n : d; },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const csv = (v) => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
18
|
+
function cred(r, prefix) {
|
|
19
|
+
const appKey = r.str(`${prefix}_APP_KEY`);
|
|
20
|
+
const appSecret = r.str(`${prefix}_APP_SECRET`);
|
|
21
|
+
return appKey && appSecret ? { appKey, appSecret } : undefined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Charge la config. Sans argument → cascade `@mostajs/config` (prod). Avec un objet env → lecture
|
|
25
|
+
* directe (tests). On peut aussi passer un `EnvReader` custom.
|
|
26
|
+
*/
|
|
27
|
+
export function loadConfig(env) {
|
|
28
|
+
const r = !env ? cascadeReader : ("str" in env && typeof env.str === "function" ? env : readerFromObject(env));
|
|
29
|
+
return {
|
|
30
|
+
scrapers: csv(r.str("AGORA_SCRAPERS")),
|
|
31
|
+
discovery: r.str("AGORA_DISCOVERY", "ddg"),
|
|
32
|
+
currency: r.str("AGORA_CURRENCY", "EUR").toUpperCase().slice(0, 3),
|
|
33
|
+
locale: r.str("AGORA_LOCALE", "fr"),
|
|
34
|
+
maxPages: r.num("AGORA_MAX_PAGES", 3),
|
|
35
|
+
maxUrls: r.num("AGORA_MAX_URLS", 8),
|
|
36
|
+
cacheTtlMs: r.num("AGORA_CACHE_TTL_MS", 86_400_000),
|
|
37
|
+
proxyUrl: r.str("AGORA_PROXY_URL") || undefined,
|
|
38
|
+
credentials: {
|
|
39
|
+
aliexpress: cred(r, "ALIEXPRESS"),
|
|
40
|
+
alibaba: cred(r, "ALIBABA"),
|
|
41
|
+
dhgate: cred(r, "DHGATE"),
|
|
42
|
+
i1688: cred(r, "ALI1688"),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Un connecteur est-il actif ? (liste vide ⇒ tous). */
|
|
47
|
+
export function isEnabled(cfg, key) {
|
|
48
|
+
return cfg.scrapers.length === 0 || cfg.scrapers.includes(key);
|
|
49
|
+
}
|
|
50
|
+
/** Connecteurs API dont les identifiants sont présents ET actifs. */
|
|
51
|
+
export function configuredApis(cfg) {
|
|
52
|
+
return Object.keys(cfg.credentials)
|
|
53
|
+
.filter((k) => cfg.credentials[k] && isEnabled(cfg, k));
|
|
54
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/agora-sourcing — découverte par LLM (ex. Claude + web_search) : la force du LLM est de
|
|
3
|
+
* COMPRENDRE le produit et de CIBLER des pages pertinentes. Le LLM ne renvoie que des URLs ; c'est
|
|
4
|
+
* agora qui scrape ensuite les FAITS (prix vérifiables) → hybride. LLM injecté (DI), aucun SDK en dur.
|
|
5
|
+
* @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
6
|
+
*/
|
|
7
|
+
import type { DiscoveryDialect } from "./types.js";
|
|
8
|
+
/** Port LLM minimal (mêmes contours que `SourcingLlm` de @mostajs/sourcing). */
|
|
9
|
+
export interface DiscoveryLlm {
|
|
10
|
+
complete(prompt: string): Promise<string>;
|
|
11
|
+
}
|
|
12
|
+
/** Extrait des URLs http(s) d'un texte libre (réponse LLM). Dé-doublonne. */
|
|
13
|
+
export declare function parseUrlList(text: string): string[];
|
|
14
|
+
/** Dialecte de découverte adossé à un LLM (Claude/…) : requête → URLs candidates pertinentes. */
|
|
15
|
+
export declare function makeLlmDiscovery(llm: DiscoveryLlm, key?: string): DiscoveryDialect;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Extrait des URLs http(s) d'un texte libre (réponse LLM). Dé-doublonne. */
|
|
2
|
+
export function parseUrlList(text) {
|
|
3
|
+
const urls = (text.match(/https?:\/\/[^\s"'<>)\]]+/g) ?? []).map((u) => u.replace(/[.,);]+$/, ""));
|
|
4
|
+
return [...new Set(urls)];
|
|
5
|
+
}
|
|
6
|
+
/** Dialecte de découverte adossé à un LLM (Claude/…) : requête → URLs candidates pertinentes. */
|
|
7
|
+
export function makeLlmDiscovery(llm, key = "llm") {
|
|
8
|
+
return {
|
|
9
|
+
key,
|
|
10
|
+
kind: "discovery",
|
|
11
|
+
async discover(query, opts) {
|
|
12
|
+
const n = opts?.limit ?? 8;
|
|
13
|
+
const prompt = `Trouve ${n} URLs de PAGES PRODUIT réelles (fournisseurs / marketplaces, dont B2B chinois si pertinent) ` +
|
|
14
|
+
`pour acheter : « ${query} ». Donne UNIQUEMENT les URLs, une par ligne, sans autre texte.`;
|
|
15
|
+
try {
|
|
16
|
+
return parseUrlList(await llm.complete(prompt)).slice(0, n);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { DiscoveryDialect } from "./types.js";
|
|
2
|
+
import { type HttpOpts } from "./http.js";
|
|
3
|
+
/** Extrait les URLs de résultats d'une page HTML DuckDuckGo. Pur (cheerio). */
|
|
4
|
+
export declare function parseDdgResults(html: string, limit?: number): string[];
|
|
5
|
+
/** Dialecte de découverte DuckDuckGo HTML. */
|
|
6
|
+
export declare function ddgDiscovery(http?: HttpOpts): DiscoveryDialect;
|
package/dist/discover.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/agora-sourcing — découverte par moteur de recherche : requête → URLs candidates.
|
|
3
|
+
* DuckDuckGo HTML (sans clé API). Pur `parseDdgResults` (testable hors réseau) + dialecte `ddg`.
|
|
4
|
+
* @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import * as cheerio from "cheerio";
|
|
7
|
+
import { getText } from "./http.js";
|
|
8
|
+
/** Décode un lien de redirection DDG (`/l/?uddg=<encoded>`) ou renvoie l'URL http(s) directe. */
|
|
9
|
+
function resolveDdgHref(href) {
|
|
10
|
+
if (!href)
|
|
11
|
+
return null;
|
|
12
|
+
const m = href.match(/[?&]uddg=([^&]+)/);
|
|
13
|
+
if (m) {
|
|
14
|
+
try {
|
|
15
|
+
const u = decodeURIComponent(m[1]);
|
|
16
|
+
return /^https?:\/\//.test(u) ? u : null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return /^https?:\/\//.test(href) ? href : null;
|
|
23
|
+
}
|
|
24
|
+
/** Extrait les URLs de résultats d'une page HTML DuckDuckGo. Pur (cheerio). */
|
|
25
|
+
export function parseDdgResults(html, limit = 10) {
|
|
26
|
+
const $ = cheerio.load(html);
|
|
27
|
+
const out = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
$('a.result__a, a.result__url, a[href*="uddg="]').each((_i, el) => {
|
|
30
|
+
if (out.length >= limit)
|
|
31
|
+
return;
|
|
32
|
+
const url = resolveDdgHref($(el).attr("href") ?? "");
|
|
33
|
+
if (url && !seen.has(url)) {
|
|
34
|
+
seen.add(url);
|
|
35
|
+
out.push(url);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/** Dialecte de découverte DuckDuckGo HTML. */
|
|
41
|
+
export function ddgDiscovery(http = {}) {
|
|
42
|
+
return {
|
|
43
|
+
key: "ddg",
|
|
44
|
+
kind: "discovery",
|
|
45
|
+
async discover(query, opts) {
|
|
46
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
47
|
+
const html = await getText(url, { ...http, checkRobots: http.checkRobots ?? false });
|
|
48
|
+
return parseDdgResults(html, opts?.limit ?? 10);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/agora-sourcing — accès HTTP responsable : robots.txt + rate-limit + retries.
|
|
3
|
+
* Repris/condensé du moteur AgoraScope-Studio, sans état global ni couplage framework. DI fetch.
|
|
4
|
+
* @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import type { FetchFn } from "./types.js";
|
|
7
|
+
/**
|
|
8
|
+
* Vérifie un chemin contre un robots.txt (groupe `User-agent: *`), de façon volontairement
|
|
9
|
+
* simple : on refuse si un `Disallow:` non vide est préfixe du chemin (et qu'aucun `Allow:`
|
|
10
|
+
* plus spécifique ne l'autorise). Pure → testable hors réseau.
|
|
11
|
+
*/
|
|
12
|
+
export declare function isAllowedByRobots(robotsTxt: string, path: string, ua?: string): boolean;
|
|
13
|
+
export interface HttpOpts {
|
|
14
|
+
fetch?: FetchFn;
|
|
15
|
+
retries?: number;
|
|
16
|
+
minIntervalMs?: number;
|
|
17
|
+
checkRobots?: boolean;
|
|
18
|
+
/** User-Agent à présenter (défaut : navigateur). */
|
|
19
|
+
userAgent?: string;
|
|
20
|
+
/** horloge injectable (tests). */
|
|
21
|
+
now?: () => number;
|
|
22
|
+
sleep?: (ms: number) => Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
/** GET texte avec robots.txt + rate-limit + retries (backoff). Retourne le HTML. */
|
|
25
|
+
export declare function getText(url: string, opts?: HttpOpts): Promise<string>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// UA navigateur par défaut : de nombreux sites (et moteurs) renvoient une page vide/challenge à un UA
|
|
2
|
+
// « bot » (constaté : DuckDuckGo HTML → 202 sans résultats). On reste correct via robots.txt (vérifié
|
|
3
|
+
// séparément) + rate-limit. Surchargable par `opts.userAgent`.
|
|
4
|
+
const UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
|
5
|
+
/** fetch global par défaut (Node ≥ 18). */
|
|
6
|
+
const defaultFetch = (url, init) => globalThis.fetch(url, init);
|
|
7
|
+
/**
|
|
8
|
+
* Vérifie un chemin contre un robots.txt (groupe `User-agent: *`), de façon volontairement
|
|
9
|
+
* simple : on refuse si un `Disallow:` non vide est préfixe du chemin (et qu'aucun `Allow:`
|
|
10
|
+
* plus spécifique ne l'autorise). Pure → testable hors réseau.
|
|
11
|
+
*/
|
|
12
|
+
export function isAllowedByRobots(robotsTxt, path, ua = "*") {
|
|
13
|
+
const lines = robotsTxt.split(/\r?\n/).map((l) => l.replace(/#.*$/, "").trim());
|
|
14
|
+
let inGroup = false;
|
|
15
|
+
let applies = false;
|
|
16
|
+
const rules = [];
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
const m = /^([A-Za-z-]+)\s*:\s*(.*)$/.exec(line);
|
|
19
|
+
if (!m)
|
|
20
|
+
continue;
|
|
21
|
+
const field = m[1].toLowerCase();
|
|
22
|
+
const val = m[2].trim();
|
|
23
|
+
if (field === "user-agent") {
|
|
24
|
+
if (inGroup && applies)
|
|
25
|
+
break; // fin du groupe pertinent
|
|
26
|
+
inGroup = true;
|
|
27
|
+
if (val === "*" || val.toLowerCase() === ua.toLowerCase())
|
|
28
|
+
applies = true;
|
|
29
|
+
else if (!applies)
|
|
30
|
+
applies = false;
|
|
31
|
+
}
|
|
32
|
+
else if (applies && (field === "allow" || field === "disallow")) {
|
|
33
|
+
rules.push({ allow: field === "allow", path: val });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// règle la plus spécifique (plus long préfixe) l'emporte ; Disallow vide = tout autorisé.
|
|
37
|
+
let decision = true;
|
|
38
|
+
let best = -1;
|
|
39
|
+
for (const r of rules) {
|
|
40
|
+
if (r.path === "") {
|
|
41
|
+
if (!r.allow)
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (path.startsWith(r.path) && r.path.length > best) {
|
|
45
|
+
best = r.path.length;
|
|
46
|
+
decision = r.allow;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return decision;
|
|
50
|
+
}
|
|
51
|
+
const lastHit = new Map();
|
|
52
|
+
/** Petite limitation de débit par hôte (intervalle minimal). */
|
|
53
|
+
async function rateLimit(host, minIntervalMs, now, sleep) {
|
|
54
|
+
const prev = lastHit.get(host) ?? 0;
|
|
55
|
+
const wait = prev + minIntervalMs - now();
|
|
56
|
+
if (wait > 0)
|
|
57
|
+
await sleep(wait);
|
|
58
|
+
lastHit.set(host, now());
|
|
59
|
+
}
|
|
60
|
+
/** GET texte avec robots.txt + rate-limit + retries (backoff). Retourne le HTML. */
|
|
61
|
+
export async function getText(url, opts = {}) {
|
|
62
|
+
const f = opts.fetch ?? defaultFetch;
|
|
63
|
+
const retries = opts.retries ?? 2;
|
|
64
|
+
const minInterval = opts.minIntervalMs ?? 1000;
|
|
65
|
+
const now = opts.now ?? (() => Date.now());
|
|
66
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
67
|
+
const ua = opts.userAgent ?? UA;
|
|
68
|
+
const u = new URL(url);
|
|
69
|
+
if (opts.checkRobots !== false) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await f(`${u.origin}/robots.txt`, { headers: { "user-agent": ua } });
|
|
72
|
+
if (res.status === 200) {
|
|
73
|
+
const txt = await res.text();
|
|
74
|
+
if (!isAllowedByRobots(txt, u.pathname, UA)) {
|
|
75
|
+
throw new Error(`robots.txt interdit l'accès à ${u.pathname}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
if (e instanceof Error && e.message.startsWith("robots.txt"))
|
|
81
|
+
throw e;
|
|
82
|
+
/* robots inaccessible → on continue prudemment */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
await rateLimit(u.host, minInterval, now, sleep);
|
|
86
|
+
let lastErr = "?";
|
|
87
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
88
|
+
try {
|
|
89
|
+
const res = await f(url, { headers: { "user-agent": ua, accept: "text/html,application/xhtml+xml" } });
|
|
90
|
+
if (res.status >= 200 && res.status < 300)
|
|
91
|
+
return res.text();
|
|
92
|
+
lastErr = `HTTP ${res.status}`;
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
lastErr = e;
|
|
96
|
+
}
|
|
97
|
+
if (attempt < retries)
|
|
98
|
+
await sleep(250 * Math.pow(2, attempt));
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`getText échec après ${retries + 1} tentatives : ${String(lastErr)}`);
|
|
101
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/agora-sourcing — sourcing maison : SCRAPE (faits) → OPTIMISE (ro-pla) → RFQ (sourcing).
|
|
3
|
+
* « Moins d'IA, plus d'outils » : l'algorithme collecte et décide ; le LLM ne fait que synthétiser.
|
|
4
|
+
* @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export * from "./types.js";
|
|
7
|
+
export { registerScraper, getScraper, listScrapers, clearScrapers, activeScrapers, pickScraper, registerDiscovery, getDiscovery, listDiscovery, clearDiscovery, } from "./registry.js";
|
|
8
|
+
export { getText, isAllowedByRobots, type HttpOpts } from "./http.js";
|
|
9
|
+
export { genericScraper, parseOffer, parsePrice } from "./scrapers/generic.js";
|
|
10
|
+
export { makeSiteScraper, parseWithProfile, BUILTIN_PROFILES, type SiteProfile } from "./scrapers/site-profile.js";
|
|
11
|
+
export { ddgDiscovery, parseDdgResults } from "./discover.js";
|
|
12
|
+
export { optimizeOffers, optimizeSplitBuy, optimizeConstrained, optimizeTiered, optimizeAssignment, assignLinesToSuppliers, optimizeContinuous, topNByPrice, type AssignLine, type LineAssignment } from "./optimize.js";
|
|
13
|
+
export { sendAgoraRfqs, toSourcingOffers, type RfqDeps, type SourcingOffer, type SendRfqs } from "./rfq.js";
|
|
14
|
+
export { makeAgoraSourcingTool, type AgoraToolDeps } from "./tool.js";
|
|
15
|
+
export { normalizeOffers, landedUnit, convert, type FxRates, type LandedOptions } from "./landed.js";
|
|
16
|
+
export { filterRelevant, relevanceScore, tokens } from "./relevance.js";
|
|
17
|
+
export { makeLlmDiscovery, parseUrlList, type DiscoveryLlm } from "./discover-llm.js";
|
|
18
|
+
export { loadConfig, isEnabled, configuredApis, readerFromObject, type AgoraConfig, type ApiCredential, type EnvReader } from "./config.js";
|
|
19
|
+
export { makeOrmCache, openFileCache, MemoryCache, AGORA_SEARCH_SCHEMA } from "./cache.js";
|
|
20
|
+
export { aliexpressConnector, signTop, buildSignedUrl, mapProducts, type AliexpressConfig, type JsonFetch } from "./scrapers/aliexpress.js";
|
|
21
|
+
import { type JsonFetch as AxJsonFetch } from "./scrapers/aliexpress.js";
|
|
22
|
+
import { type AgoraConfig } from "./config.js";
|
|
23
|
+
import type { HttpOpts } from "./http.js";
|
|
24
|
+
import type { Offer, OptimizeConstraints, SolverPort, SourcingResult, CachePort } from "./types.js";
|
|
25
|
+
/** Enregistre les built-ins : scraper générique + profils e-commerce + découverte DDG. */
|
|
26
|
+
export declare function registerBuiltins(http?: HttpOpts): void;
|
|
27
|
+
/**
|
|
28
|
+
* Enregistre les connecteurs par API officielle dont les identifiants `.env` sont présents ET actifs
|
|
29
|
+
* (`AGORA_SCRAPERS`). Sans clé, le connecteur n'est pas enregistré (repli sur autres sources/cache).
|
|
30
|
+
* Renvoie les clés enregistrées.
|
|
31
|
+
*/
|
|
32
|
+
export declare function registerApis(cfg: AgoraConfig, deps?: {
|
|
33
|
+
httpJson?: AxJsonFetch;
|
|
34
|
+
}): string[];
|
|
35
|
+
/**
|
|
36
|
+
* Solveur par défaut = `@mostajs/ro-pla` (peer), chargé paresseusement pour ne pas le rendre
|
|
37
|
+
* obligatoire au build. L'app/test peut injecter son propre `SolverPort` (DI).
|
|
38
|
+
*/
|
|
39
|
+
export declare function defaultSolver(): Promise<SolverPort>;
|
|
40
|
+
export interface AgoraSourceOpts {
|
|
41
|
+
qty: number;
|
|
42
|
+
constraints?: OptimizeConstraints;
|
|
43
|
+
topN?: number;
|
|
44
|
+
solver?: SolverPort;
|
|
45
|
+
/** Filtre appliqué aux offres scrappées AVANT optimisation (ex. pertinence). */
|
|
46
|
+
filter?: (offers: Offer[]) => Offer[];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Sourcing de bout en bout à partir d'URLs déjà découvertes (la découverte moteur de recherche
|
|
50
|
+
* arrive en Phase 3) : scrape chaque URL → offres → optimise (ro-pla) → { offers, topN, allocation }.
|
|
51
|
+
*/
|
|
52
|
+
export declare function agoraSourceFromUrls(urls: string[], opts: AgoraSourceOpts, scrape: (url: string) => Promise<Offer | null>): Promise<SourcingResult>;
|
|
53
|
+
/** Options de la recherche par requête (découverte/scrape injectables + cache de persistance). */
|
|
54
|
+
export interface QuerySourceOpts extends AgoraSourceOpts {
|
|
55
|
+
http?: HttpOpts;
|
|
56
|
+
maxUrls?: number;
|
|
57
|
+
/** Clé de cache (défaut : requête normalisée). */
|
|
58
|
+
queryKey?: string;
|
|
59
|
+
/** Cache ORM (persistance fichier) — repli « au moins du disponible ». */
|
|
60
|
+
cache?: CachePort;
|
|
61
|
+
/** Fraîcheur : sous ce TTL, on sert le cache sans re-scraper. 0/absent ⇒ pas de hit frais. */
|
|
62
|
+
cacheTtlMs?: number;
|
|
63
|
+
/** Découverte injectable (défaut : registre `ddg`). */
|
|
64
|
+
discover?: (query: string) => Promise<string[]>;
|
|
65
|
+
/** Clé de découverte à utiliser dans le registre (ex. "ddg", "llm"). Défaut "ddg". */
|
|
66
|
+
discoveryKey?: string;
|
|
67
|
+
/** Scrape injectable (défaut : routage par hôte). */
|
|
68
|
+
scrape?: (url: string) => Promise<Offer | null>;
|
|
69
|
+
/** Filtre de pertinence titre↔requête : `true`/seuil (défaut 0.34) ou `false` pour désactiver. */
|
|
70
|
+
relevance?: boolean | number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Sourcing complet à partir d'une REQUÊTE produit, avec **persistance** :
|
|
74
|
+
* 1. hit cache FRAIS (< TTL) → servi sans re-scraper ;
|
|
75
|
+
* 2. sinon live : découverte → scrape routé → optimisation ; succès → on PERSISTE ;
|
|
76
|
+
* 3. si le live échoue / ne renvoie rien → **repli sur le dernier résultat connu** (cache).
|
|
77
|
+
* `registerBuiltins()` doit avoir été appelé (sinon découverte/scraper sont injectés en repli).
|
|
78
|
+
*/
|
|
79
|
+
export declare function agoraSourceFromQuery(query: string, opts: QuerySourceOpts): Promise<SourcingResult>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/agora-sourcing — sourcing maison : SCRAPE (faits) → OPTIMISE (ro-pla) → RFQ (sourcing).
|
|
3
|
+
* « Moins d'IA, plus d'outils » : l'algorithme collecte et décide ; le LLM ne fait que synthétiser.
|
|
4
|
+
* @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export * from "./types.js";
|
|
7
|
+
export { registerScraper, getScraper, listScrapers, clearScrapers, activeScrapers, pickScraper, registerDiscovery, getDiscovery, listDiscovery, clearDiscovery, } from "./registry.js";
|
|
8
|
+
export { getText, isAllowedByRobots } from "./http.js";
|
|
9
|
+
export { genericScraper, parseOffer, parsePrice } from "./scrapers/generic.js";
|
|
10
|
+
export { makeSiteScraper, parseWithProfile, BUILTIN_PROFILES } from "./scrapers/site-profile.js";
|
|
11
|
+
export { ddgDiscovery, parseDdgResults } from "./discover.js";
|
|
12
|
+
export { optimizeOffers, optimizeSplitBuy, optimizeConstrained, optimizeTiered, optimizeAssignment, assignLinesToSuppliers, optimizeContinuous, topNByPrice } from "./optimize.js";
|
|
13
|
+
export { sendAgoraRfqs, toSourcingOffers } from "./rfq.js";
|
|
14
|
+
export { makeAgoraSourcingTool } from "./tool.js";
|
|
15
|
+
export { normalizeOffers, landedUnit, convert } from "./landed.js";
|
|
16
|
+
export { filterRelevant, relevanceScore, tokens } from "./relevance.js";
|
|
17
|
+
export { makeLlmDiscovery, parseUrlList } from "./discover-llm.js";
|
|
18
|
+
export { loadConfig, isEnabled, configuredApis, readerFromObject } from "./config.js";
|
|
19
|
+
export { makeOrmCache, openFileCache, MemoryCache, AGORA_SEARCH_SCHEMA } from "./cache.js";
|
|
20
|
+
export { aliexpressConnector, signTop, buildSignedUrl, mapProducts } from "./scrapers/aliexpress.js";
|
|
21
|
+
import { registerScraper, registerDiscovery, getDiscovery, pickScraper } from "./registry.js";
|
|
22
|
+
import { genericScraper } from "./scrapers/generic.js";
|
|
23
|
+
import { makeSiteScraper, BUILTIN_PROFILES } from "./scrapers/site-profile.js";
|
|
24
|
+
import { ddgDiscovery } from "./discover.js";
|
|
25
|
+
import { aliexpressConnector } from "./scrapers/aliexpress.js";
|
|
26
|
+
import { isEnabled as cfgIsEnabled } from "./config.js";
|
|
27
|
+
import { optimizeOffers, topNByPrice } from "./optimize.js";
|
|
28
|
+
import { filterRelevant } from "./relevance.js";
|
|
29
|
+
const EMPTY = { offers: [], topN: [], allocation: { bySupplier: [], totalCost: 0, filled: 0, status: "infeasible" } };
|
|
30
|
+
/** Enregistre les built-ins : scraper générique + profils e-commerce + découverte DDG. */
|
|
31
|
+
export function registerBuiltins(http = {}) {
|
|
32
|
+
registerScraper(genericScraper(http));
|
|
33
|
+
for (const p of BUILTIN_PROFILES)
|
|
34
|
+
registerScraper(makeSiteScraper(p, http));
|
|
35
|
+
registerDiscovery(ddgDiscovery(http));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Enregistre les connecteurs par API officielle dont les identifiants `.env` sont présents ET actifs
|
|
39
|
+
* (`AGORA_SCRAPERS`). Sans clé, le connecteur n'est pas enregistré (repli sur autres sources/cache).
|
|
40
|
+
* Renvoie les clés enregistrées.
|
|
41
|
+
*/
|
|
42
|
+
export function registerApis(cfg, deps = {}) {
|
|
43
|
+
const done = [];
|
|
44
|
+
const ax = cfg.credentials.aliexpress;
|
|
45
|
+
if (ax && cfgIsEnabled(cfg, "aliexpress")) {
|
|
46
|
+
registerScraper(aliexpressConnector({ appKey: ax.appKey, appSecret: ax.appSecret, httpJson: deps.httpJson, targetCurrency: cfg.currency }));
|
|
47
|
+
done.push("aliexpress");
|
|
48
|
+
}
|
|
49
|
+
return done;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Solveur par défaut = `@mostajs/ro-pla` (peer), chargé paresseusement pour ne pas le rendre
|
|
53
|
+
* obligatoire au build. L'app/test peut injecter son propre `SolverPort` (DI).
|
|
54
|
+
*/
|
|
55
|
+
export async function defaultSolver() {
|
|
56
|
+
// peer optionnel chargé au runtime — specifier indirect pour ne pas le résoudre au build
|
|
57
|
+
const spec = "@mostajs/ro-pla";
|
|
58
|
+
const rp = (await import(spec));
|
|
59
|
+
rp.registerBuiltinSolvers();
|
|
60
|
+
return { solve: (problem, opts) => rp.solve(problem, opts) };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Sourcing de bout en bout à partir d'URLs déjà découvertes (la découverte moteur de recherche
|
|
64
|
+
* arrive en Phase 3) : scrape chaque URL → offres → optimise (ro-pla) → { offers, topN, allocation }.
|
|
65
|
+
*/
|
|
66
|
+
export async function agoraSourceFromUrls(urls, opts, scrape) {
|
|
67
|
+
const settled = await Promise.all(urls.map((u) => scrape(u).catch(() => null)));
|
|
68
|
+
const collected = settled.filter((o) => o != null);
|
|
69
|
+
const offers = opts.filter ? opts.filter(collected) : collected;
|
|
70
|
+
if (!offers.length)
|
|
71
|
+
return { ...EMPTY };
|
|
72
|
+
const solver = opts.solver ?? (await defaultSolver());
|
|
73
|
+
const allocation = await optimizeOffers(offers, opts.qty, solver, opts.constraints);
|
|
74
|
+
return { offers, topN: topNByPrice(offers, opts.topN ?? 5), allocation };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Sourcing complet à partir d'une REQUÊTE produit, avec **persistance** :
|
|
78
|
+
* 1. hit cache FRAIS (< TTL) → servi sans re-scraper ;
|
|
79
|
+
* 2. sinon live : découverte → scrape routé → optimisation ; succès → on PERSISTE ;
|
|
80
|
+
* 3. si le live échoue / ne renvoie rien → **repli sur le dernier résultat connu** (cache).
|
|
81
|
+
* `registerBuiltins()` doit avoir été appelé (sinon découverte/scraper sont injectés en repli).
|
|
82
|
+
*/
|
|
83
|
+
export async function agoraSourceFromQuery(query, opts) {
|
|
84
|
+
const http = opts.http ?? {};
|
|
85
|
+
const key = opts.queryKey ?? query.trim().toLowerCase();
|
|
86
|
+
// 1) hit cache frais
|
|
87
|
+
if (opts.cache && opts.cacheTtlMs && opts.cacheTtlMs > 0) {
|
|
88
|
+
const c = await opts.cache.get(key);
|
|
89
|
+
if (c && Date.now() - c.at <= opts.cacheTtlMs)
|
|
90
|
+
return { ...c.result, fromCache: true, cachedAt: c.at };
|
|
91
|
+
}
|
|
92
|
+
// 2) live
|
|
93
|
+
let result = null;
|
|
94
|
+
try {
|
|
95
|
+
const discover = opts.discover ?? (async (q) => {
|
|
96
|
+
const d = getDiscovery(opts.discoveryKey ?? "ddg") ?? ddgDiscovery(http);
|
|
97
|
+
return d.discover(q, { limit: opts.maxUrls ?? 8 });
|
|
98
|
+
});
|
|
99
|
+
const fb = genericScraper(http);
|
|
100
|
+
const scrape = opts.scrape ?? ((url) => (pickScraper(url) ?? fb).scrape(url));
|
|
101
|
+
const rel = opts.relevance === false ? undefined
|
|
102
|
+
: (offers) => filterRelevant(offers, query, typeof opts.relevance === "number" ? opts.relevance : 0.34);
|
|
103
|
+
const urls = await discover(query);
|
|
104
|
+
result = await agoraSourceFromUrls(urls, { ...opts, filter: opts.filter ?? rel }, scrape);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
result = null;
|
|
108
|
+
}
|
|
109
|
+
// 3) succès → persiste ; échec/vide → repli dernier connu
|
|
110
|
+
if (result && result.offers.length) {
|
|
111
|
+
if (opts.cache)
|
|
112
|
+
await opts.cache.put(key, result);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
if (opts.cache) {
|
|
116
|
+
const c = await opts.cache.get(key);
|
|
117
|
+
if (c)
|
|
118
|
+
return { ...c.result, fromCache: true, cachedAt: c.at };
|
|
119
|
+
}
|
|
120
|
+
return result ?? { ...EMPTY };
|
|
121
|
+
}
|