better ui

This commit is contained in:
Vitrixxl 2025-11-05 15:57:05 +01:00
parent ef417be648
commit c90da606a3
11 changed files with 1195 additions and 129 deletions

View File

@ -1,7 +1,7 @@
DB_NAME=immonator_test
DB_PORT=5433
DB_HOST=34.135.132.141
DB_USERNAME=django
DB_PASSWORD=T17HE59a7Xlu2lBgPUQ9rdCPTe4159UNedGhcOvrTu8=
DB_PORT=5432
DB_HOST=34.155.247.19
DB_USERNAME=postgres
DB_PASSWORD=3cd70e73f0b73a2411c595f3a2f8cf125ee3937498f82628906ed946e8499a00f7733c6ec588cdff4aec386de3c3cc51d0a166369c6a99e73b9c95d2a1bd115a
API_TOKEN=B0uKZQN+qIsLc0yNR/t9xCOkgP6Keg0oarLUiZkO2Mo=
FLUXIMMO_API_KEY=a

View File

@ -228,6 +228,26 @@ def _load_scraper_params(value: Any) -> Dict[str, Any]:
abort(400, description="Field 'params' must be a JSON object or string")
def _validate_property_types(value: str | None) -> str | None:
"""Valide que les types de propriétés sont dans la liste autorisée."""
if value is None or value.strip() == "":
return None
valid_types = {"immeuble", "appartement", "maison"}
types = [t.strip().lower() for t in value.split(",") if t.strip()]
invalid_types = [t for t in types if t not in valid_types]
if invalid_types:
abort(
400,
description=f"Invalid property types: {', '.join(invalid_types)}. "
f"Allowed values: {', '.join(sorted(valid_types))}"
)
return value.strip()
def build_scraper_params(
params: Dict[str, Any],
first_seen_days: int | None,
@ -441,6 +461,7 @@ SCRAPER_RESPONSE_FIELDS = (
"max_pages",
"enrich_llm",
"only_match",
"once",
)
SCRAPER_INT_FIELDS = (
@ -451,6 +472,7 @@ SCRAPER_INT_FIELDS = (
"enabled",
"enrich_llm",
"only_match",
"once",
)
@ -743,13 +765,19 @@ def create_scraper():
data: Dict[str, Any] = {"id": scraper_id}
for field in ("params", "frequency", "task_name", "property_types"):
for field in ("params", "frequency", "task_name"):
if field in payload:
value = payload[field]
data[field] = (
None if value is None else _parse_string(value, field, allow_empty=True)
)
# Validation spéciale pour property_types
if "property_types" in payload:
value = payload["property_types"]
parsed_value = None if value is None else _parse_string(value, "property_types", allow_empty=True)
data["property_types"] = _validate_property_types(parsed_value)
for field in SCRAPER_INT_FIELDS:
if field in payload:
value = payload[field]
@ -768,13 +796,19 @@ def update_scraper(scraper_id: str):
payload = _get_json_body()
updates: Dict[str, Any] = {}
for field in ("params", "frequency", "task_name", "property_types"):
for field in ("params", "frequency", "task_name"):
if field in payload:
value = payload[field]
updates[field] = (
None if value is None else _parse_string(value, field, allow_empty=True)
)
# Validation spéciale pour property_types
if "property_types" in payload:
value = payload["property_types"]
parsed_value = None if value is None else _parse_string(value, "property_types", allow_empty=True)
updates["property_types"] = _validate_property_types(parsed_value)
for field in SCRAPER_INT_FIELDS:
if field in payload:
value = payload[field]
@ -860,4 +894,4 @@ def count_scraper_properties():
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.getenv("PORT", "8000")), debug=False)
app.run(host="0.0.0.0", port=int(os.getenv("PORT", "3000")), debug=False)

2
frontend/.env Normal file
View File

@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:3000
VITE_API_TOKEN=B0uKZQN+qIsLc0yNR/t9xCOkgP6Keg0oarLUiZkO2Mo=

View File

@ -112,6 +112,15 @@ function renderEnum(
) {
const error = getFirstError(errorMap, path);
// Helper function to get value and label from enum option
const getOptionValue = (option: string | { value: string; label: string }) => {
return typeof option === "string" ? option : option.value;
};
const getOptionLabel = (option: string | { value: string; label: string }) => {
return typeof option === "string" ? option : option.label;
};
if (schema.multiple) {
const current = Array.isArray(value) ? (value as string[]) : [];
@ -120,20 +129,22 @@ function renderEnum(
<SchemaFieldLabel description={schema.description}>{schema.label ?? getLabelFromPath(path)}</SchemaFieldLabel>
<div className="flex flex-wrap gap-3">
{schema.options.map((option) => {
const checked = current.includes(option);
const optionValue = getOptionValue(option);
const optionLabel = getOptionLabel(option);
const checked = current.includes(optionValue);
return (
<label key={option} className="flex items-center gap-2 text-sm">
<label key={optionValue} className="flex items-center gap-2 text-sm">
<Checkbox
checked={checked}
onCheckedChange={(next) => {
if (next) {
onChange([...current, option]);
onChange([...current, optionValue]);
} else {
onChange(current.filter((item) => item !== option));
onChange(current.filter((item) => item !== optionValue));
}
}}
/>
{option}
{optionLabel}
</label>
);
})}
@ -154,11 +165,15 @@ function renderEnum(
<SelectValue placeholder="Selectionner" />
</SelectTrigger>
<SelectContent>
{schema.options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
{schema.options.map((option) => {
const optionValue = getOptionValue(option);
const optionLabel = getOptionLabel(option);
return (
<SelectItem key={optionValue} value={optionValue}>
{optionLabel}
</SelectItem>
);
})}
</SelectContent>
</Select>
{error ? <FieldError message={error.message} /> : null}

View File

@ -1,6 +1,7 @@
import { useCallback } from "react";
import type {
EnumOption,
SchemaArrayNode,
SchemaEnumNode,
SchemaNode,
@ -98,6 +99,13 @@ function validateEnum(schema: SchemaEnumNode, value: unknown, path: string) {
return schema.required === false ? [] : [createError(path, "Valeur requise.")];
}
// Helper to extract values from options (handles both string and {value, label} formats)
const getOptionValue = (option: EnumOption): string => {
return typeof option === "string" ? option : option.value;
};
const validValues = schema.options.map(getOptionValue);
if (schema.multiple) {
if (!Array.isArray(value)) {
return [createError(path, "Selection multiple attendue.")];
@ -107,7 +115,7 @@ function validateEnum(schema: SchemaEnumNode, value: unknown, path: string) {
return schema.required === false ? [] : [createError(path, "Valeur requise.")];
}
const invalid = value.filter((item) => !schema.options.includes(item as string));
const invalid = value.filter((item) => !validValues.includes(item as string));
if (invalid.length > 0) {
return [createError(path, "Valeurs invalides selectionnees.")];
@ -122,7 +130,7 @@ function validateEnum(schema: SchemaEnumNode, value: unknown, path: string) {
: [createError(path, "Valeur requise.")];
}
return schema.options.includes(value as string)
return validValues.includes(value as string)
? []
: [createError(path, "Valeur non autorisee.")];
}

View File

@ -41,6 +41,7 @@ export interface Scraper {
enabled: number | null;
enrich_llm: number | null;
only_match: number | null;
once: number | null;
}
interface ScraperFormValues {
@ -55,6 +56,7 @@ interface ScraperFormValues {
enabled: boolean;
enrich_llm: boolean;
only_match: boolean;
once: boolean;
}
interface CreateScraperPayload {
@ -70,6 +72,7 @@ interface CreateScraperPayload {
enabled?: number | null;
enrich_llm?: number | null;
only_match?: number | null;
once?: number | null;
}
const scraperSchema = getSchema("scraper");
@ -160,6 +163,11 @@ function transformPayload(values: ScraperFormValues): CreateScraperPayload {
payload.enrich_llm = values.enrich_llm ? 1 : 0;
payload.only_match = values.only_match ? 1 : 0;
// Ne pas envoyer 'once' si non coché
if (values.once) {
payload.once = 1;
}
return payload;
}
@ -176,6 +184,7 @@ function createDefaultScraperValues(): ScraperFormValues {
enabled: true,
enrich_llm: false,
only_match: false,
once: false,
};
}
@ -210,7 +219,10 @@ export function ScrapersTab() {
const createScraperMutation = useMutation({
mutationFn: (values: ScraperFormValues) =>
api.post<CreateScraperPayload, Scraper>("/scrapers", transformPayload(values)),
api.post<CreateScraperPayload, Scraper>(
"/scrapers",
transformPayload(values),
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["scrapers"] });
setParamsValid(true);
@ -253,22 +265,27 @@ export function ScrapersTab() {
mutationFn: (values: ScraperFormValues) =>
api.post<CreateScraperPayload, ScraperCountResponse>(
"/scrapers/count",
transformPayload(values)
transformPayload(values),
),
});
const onSubmit = form.handleSubmit(async (values) => {
setStatusMessage(null);
if (editingScraperId) {
await updateScraperMutation.mutateAsync({ id: editingScraperId, data: values });
await updateScraperMutation.mutateAsync({
id: editingScraperId,
data: values,
});
} else {
await createScraperMutation.mutateAsync(values);
}
});
const formError =
(createScraperMutation.isError && getErrorMessage(createScraperMutation.error)) ||
(updateScraperMutation.isError && getErrorMessage(updateScraperMutation.error)) ||
(createScraperMutation.isError &&
getErrorMessage(createScraperMutation.error)) ||
(updateScraperMutation.isError &&
getErrorMessage(updateScraperMutation.error)) ||
null;
const handleOpenCountDialog = async () => {
@ -284,7 +301,10 @@ export function ScrapersTab() {
countScraperMutation.mutate(form.getValues());
};
const scrapers = useMemo(() => scrapersQuery.data ?? [], [scrapersQuery.data]);
const scrapers = useMemo(
() => scrapersQuery.data ?? [],
[scrapersQuery.data],
);
return (
<div className="space-y-6">
@ -292,7 +312,8 @@ export function ScrapersTab() {
<CardHeader>
<CardTitle>Creer un scraper</CardTitle>
<CardDescription>
Definissez la configuration et les parametres d'execution du scraper.
Definissez la configuration et les parametres d'execution du
scraper.
</CardDescription>
</CardHeader>
<CardContent>
@ -302,6 +323,9 @@ export function ScrapersTab() {
<label className="text-sm font-medium" htmlFor="task_name">
Nom du scraper
</label>
<p className="text-xs text-muted-foreground">
Identifiant unique pour reperer ce scraper
</p>
<Input
id="task_name"
placeholder="Nom interne"
@ -313,6 +337,9 @@ export function ScrapersTab() {
<label className="text-sm font-medium" htmlFor="frequency">
Frequence
</label>
<p className="text-xs text-muted-foreground">
Expression cron pour planifier l'execution
</p>
<Input
id="frequency"
placeholder="0 7 * * *"
@ -324,55 +351,130 @@ export function ScrapersTab() {
<label className="text-sm font-medium" htmlFor="property_types">
Types de biens
</label>
<p className="text-xs text-muted-foreground">
Types de proprietes a rechercher (separes par virgule)
</p>
<Input
id="property_types"
placeholder="maison,appartement"
{...form.register("property_types")}
placeholder="maison,appartement,immeuble"
aria-invalid={!!form.formState.errors.property_types}
{...form.register("property_types", {
validate: (value) => {
if (!value || value.trim() === "") {
return true; // Champ optionnel
}
const validTypes = ["immeuble", "appartement", "maison"];
const types = value
.split(",")
.map((t) => t.trim().toLowerCase())
.filter((t) => t !== "");
const invalidTypes = types.filter(
(t) => !validTypes.includes(t)
);
if (invalidTypes.length > 0) {
return `Types invalides: ${invalidTypes.join(", ")}. Valeurs autorisées: ${validTypes.join(", ")}`;
}
return true;
},
})}
/>
{form.formState.errors.property_types && (
<p className="text-destructive text-xs">
{form.formState.errors.property_types.message}
</p>
)}
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="last_seen_days">
<label
className="text-sm font-medium"
htmlFor="last_seen_days"
>
Derniere vue (jours)
</label>
<Input id="last_seen_days" type="number" min="0" {...form.register("last_seen_days")} />
<p className="text-xs text-muted-foreground">
Annonces vues il y a maximum N jours
</p>
<Input
id="last_seen_days"
type="number"
min="0"
{...form.register("last_seen_days")}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="first_seen_days">
<label
className="text-sm font-medium"
htmlFor="first_seen_days"
>
Premiere vue (jours)
</label>
<Input id="first_seen_days" type="number" min="0" {...form.register("first_seen_days")} />
<p className="text-xs text-muted-foreground">
Annonces apparues il y a minimum N jours
</p>
<Input
id="first_seen_days"
type="number"
min="0"
{...form.register("first_seen_days")}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="page_size">
Taille de page
</label>
<Input id="page_size" type="number" min="1" {...form.register("page_size")} />
<p className="text-xs text-muted-foreground">
Nombre de resultats par page
</p>
<Input
id="page_size"
type="number"
min="1"
{...form.register("page_size")}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="max_pages">
Pages maximum
</label>
<Input id="max_pages" type="number" min="1" {...form.register("max_pages")} />
<p className="text-xs text-muted-foreground">
Nombre maximum de pages a parcourir
</p>
<Input
id="max_pages"
type="number"
min="1"
{...form.register("max_pages")}
/>
</div>
</div>
<div className="grid gap-2 md:col-span-2 md:grid-cols-3">
<div className="grid gap-4 md:col-span-2 md:grid-cols-2">
<Controller
control={form.control}
name="enabled"
render={({ field }) => (
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
/>
Activer
</label>
<div className="space-y-1">
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox
checked={field.value}
onCheckedChange={(checked) =>
field.onChange(Boolean(checked))
}
/>
Activer
</label>
<p className="text-xs text-muted-foreground ml-6">
Active ou desactive l'execution automatique
</p>
</div>
)}
/>
@ -380,13 +482,20 @@ export function ScrapersTab() {
control={form.control}
name="enrich_llm"
render={({ field }) => (
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
/>
Enrichir via LLM
</label>
<div className="space-y-1">
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox
checked={field.value}
onCheckedChange={(checked) =>
field.onChange(Boolean(checked))
}
/>
Enrichir via LLM
</label>
<p className="text-xs text-muted-foreground ml-6">
Analyse les annonces avec un modele de langage
</p>
</div>
)}
/>
@ -394,13 +503,41 @@ export function ScrapersTab() {
control={form.control}
name="only_match"
render={({ field }) => (
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
/>
Mode strict
</label>
<div className="space-y-1">
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox
checked={field.value}
onCheckedChange={(checked) =>
field.onChange(Boolean(checked))
}
/>
Mode strict
</label>
<p className="text-xs text-muted-foreground ml-6">
Ne retourne que les correspondances exactes
</p>
</div>
)}
/>
<Controller
control={form.control}
name="once"
render={({ field }) => (
<div className="space-y-1">
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox
checked={field.value}
onCheckedChange={(checked) =>
field.onChange(Boolean(checked))
}
/>
Une fois
</label>
<p className="text-xs text-muted-foreground ml-6">
Execute le scraper une seule fois
</p>
</div>
)}
/>
</div>
@ -416,7 +553,9 @@ export function ScrapersTab() {
onChange={(next) => field.onChange(next)}
label="Parametres"
description="Structure schemaisee conformement au schema scraper."
onValidationChange={(payload) => setParamsValid(payload.valid)}
onValidationChange={(payload) =>
setParamsValid(payload.valid)
}
/>
)}
/>
@ -424,7 +563,11 @@ export function ScrapersTab() {
<div className="flex flex-wrap items-center gap-3">
<Button
type="submit"
disabled={(editingScraperId ? updateScraperMutation.isPending : createScraperMutation.isPending) || !paramsValid}
disabled={
(editingScraperId
? updateScraperMutation.isPending
: createScraperMutation.isPending) || !paramsValid
}
>
{editingScraperId
? updateScraperMutation.isPending
@ -444,7 +587,9 @@ export function ScrapersTab() {
) : null}
{!paramsValid ? (
<p className="text-destructive text-sm">Corrigez les erreurs du schema avant l'envoi.</p>
<p className="text-destructive text-sm">
Corrigez les erreurs du schema avant l'envoi.
</p>
) : null}
<Button
@ -453,7 +598,9 @@ export function ScrapersTab() {
disabled={countScraperMutation.isPending}
onClick={handleOpenCountDialog}
>
{countScraperMutation.isPending ? "Calcul..." : "Compter les resultats"}
{countScraperMutation.isPending
? "Calcul..."
: "Compter les resultats"}
</Button>
{editingScraperId ? (
@ -496,8 +643,12 @@ export function ScrapersTab() {
<thead>
<tr className="text-left">
<th className="border-b px-3 py-2 font-semibold">Nom</th>
<th className="border-b px-3 py-2 font-semibold">Frequence</th>
<th className="border-b px-3 py-2 font-semibold">Parametres</th>
<th className="border-b px-3 py-2 font-semibold">
Frequence
</th>
<th className="border-b px-3 py-2 font-semibold">
Parametres
</th>
<th className="border-b px-3 py-2 font-semibold">Etat</th>
<th className="border-b px-3 py-2 font-semibold">Limites</th>
<th className="border-b px-3 py-2 font-semibold">Actions</th>
@ -509,7 +660,9 @@ export function ScrapersTab() {
return (
<tr key={scraper.id} className="align-top">
<td className="border-b px-3 py-2 font-medium">{scraper.task_name ?? "-"}</td>
<td className="border-b px-3 py-2 font-medium">
{scraper.task_name ?? "-"}
</td>
<td className="border-b px-3 py-2 text-muted-foreground">
{scraper.frequency ?? "-"}
</td>
@ -529,7 +682,7 @@ export function ScrapersTab() {
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-medium",
scraper.enabled
? "bg-emerald-100 text-emerald-700"
: "bg-gray-200 text-gray-600"
: "bg-gray-200 text-gray-600",
)}
>
Actif: {scraper.enabled ? "Oui" : "Non"}
@ -540,12 +693,19 @@ export function ScrapersTab() {
<span className="block text-muted-foreground">
Strict: {scraper.only_match ? "Oui" : "Non"}
</span>
<span className="block text-muted-foreground">
Une fois: {scraper.once ? "Oui" : "Non"}
</span>
</div>
</td>
<td className="border-b px-3 py-2 text-xs text-muted-foreground">
<div className="space-y-1">
<span>Derniere vue: {scraper.last_seen_days ?? "-"}</span>
<span>Premiere vue: {scraper.first_seen_days ?? "-"}</span>
<span>
Derniere vue: {scraper.last_seen_days ?? "-"}
</span>
<span>
Premiere vue: {scraper.first_seen_days ?? "-"}
</span>
<span>Page size: {scraper.page_size ?? "-"}</span>
<span>Max pages: {scraper.max_pages ?? "-"}</span>
</div>
@ -560,13 +720,22 @@ export function ScrapersTab() {
setStatusMessage(null);
setEditingScraperId(scraper.id);
setParamsValid(true);
let parsedParams: JsonObject = buildDefaultValue(scraperSchema) as JsonObject;
let parsedParams: JsonObject = buildDefaultValue(
scraperSchema,
) as JsonObject;
if (scraper.params) {
try {
const raw = JSON.parse(scraper.params) as JsonObject;
parsedParams = raw && typeof raw === "object" ? raw : parsedParams;
const raw = JSON.parse(
scraper.params,
) as JsonObject;
parsedParams =
raw && typeof raw === "object"
? raw
: parsedParams;
} catch (error) {
parsedParams = buildDefaultValue(scraperSchema) as JsonObject;
parsedParams = buildDefaultValue(
scraperSchema,
) as JsonObject;
}
}
form.reset({
@ -574,13 +743,16 @@ export function ScrapersTab() {
frequency: scraper.frequency ?? "",
task_name: scraper.task_name ?? "",
property_types: scraper.property_types ?? "",
last_seen_days: scraper.last_seen_days?.toString() ?? "",
first_seen_days: scraper.first_seen_days?.toString() ?? "",
last_seen_days:
scraper.last_seen_days?.toString() ?? "",
first_seen_days:
scraper.first_seen_days?.toString() ?? "",
page_size: scraper.page_size?.toString() ?? "",
max_pages: scraper.max_pages?.toString() ?? "",
enabled: Boolean(scraper.enabled),
enrich_llm: Boolean(scraper.enrich_llm),
only_match: Boolean(scraper.only_match),
once: Boolean(scraper.once),
});
}}
>
@ -594,7 +766,7 @@ export function ScrapersTab() {
onClick={() => {
if (
window.confirm(
"Confirmez la suppression de ce scraper ?"
"Confirmez la suppression de ce scraper ?",
)
) {
deleteScraperMutation.mutate(scraper.id);
@ -632,7 +804,9 @@ export function ScrapersTab() {
</DialogHeader>
<div className="min-h-[80px]">
{countScraperMutation.isPending ? (
<p className="text-sm text-muted-foreground">Calcul en cours...</p>
<p className="text-sm text-muted-foreground">
Calcul en cours...
</p>
) : countScraperMutation.isError ? (
<p className="text-destructive text-sm">
{getErrorMessage(countScraperMutation.error)}
@ -640,7 +814,10 @@ export function ScrapersTab() {
) : countScraperMutation.data ? (
<p className="text-sm">
Ce scraper correspond a
<span className="font-semibold"> {countScraperMutation.data.count} </span>
<span className="font-semibold">
{" "}
{countScraperMutation.data.count}{" "}
</span>
annonce(s) potentielle(s).
</p>
) : (

View File

@ -1,5 +1,7 @@
const DEFAULT_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";
const API_TOKEN = import.meta.env.VITE_API_TOKEN ?? "";
const SESSION_TOKEN_STORAGE_KEY = "managinator-session-token";
@ -52,24 +54,30 @@ async function request<TResponse>(
options?: ApiRequestOptions,
): Promise<TResponse> {
const url = buildUrl(path, options?.query);
console.log(`🚀 [API] ${method} ${url}`);
console.log(`🔑 [API] Has API_TOKEN: ${!!API_TOKEN}`);
const headers: HeadersInit = {
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
...(API_TOKEN ? { Authorization: `Bearer ${API_TOKEN}` } : {}),
...(sessionToken
? {
"X-Session-Token": sessionToken,
Authorization: `Bearer ${sessionToken}`,
}
: {}),
...options?.headers,
};
console.log(`📤 [API] Headers:`, headers);
const response = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
console.log(`📥 [API] Response status: ${response.status}`);
if (!response.ok) {
let errorPayload: unknown;
const contentType = response.headers.get("content-type") ?? "";

View File

@ -2,6 +2,7 @@ import scraperSchemaSource from "../../scraper-schema.json";
import { normalizeRootSchema } from "./normalize";
import { profileSchema } from "./profile-schema";
import { enrichScraperSchema } from "./scraper-schema-labels";
import type { SchemaDefinition } from "./types";
type SchemaRegistry = {
@ -10,7 +11,7 @@ type SchemaRegistry = {
};
const registry: SchemaRegistry = {
scraper: normalizeRootSchema(scraperSchemaSource),
scraper: enrichScraperSchema(normalizeRootSchema(scraperSchemaSource)),
profile: profileSchema,
};

View File

@ -41,8 +41,8 @@ const characteristicsSchema: SchemaObjectNode = {
kind: "enum",
options: ["any", "all", "none"],
required: false,
label: "Evaluation mode",
description: "Defines how the boolean flags are evaluated.",
label: "Mode d'évaluation",
description: "Définit comment les caractéristiques sont évaluées (une, toutes, aucune)",
},
},
{
@ -53,6 +53,7 @@ const characteristicsSchema: SchemaObjectNode = {
type: "string",
required: false,
label: "Description",
description: "Description du groupe de caractéristiques",
},
},
{
@ -66,8 +67,8 @@ const characteristicsSchema: SchemaObjectNode = {
options: characteristicOptions,
required: false,
},
label: "Feature identifiers",
description: "List of boolean feature identifiers to evaluate.",
label: "Caractéristiques",
description: "Liste des caractéristiques à évaluer pour le bien",
},
},
],
@ -81,51 +82,111 @@ const poiConstraintSchema: SchemaObjectNode = {
key: "poi_category",
required: false,
schema: {
kind: "primitive",
type: "string",
kind: "enum",
options: [
{ value: "EDUCATION", label: "EDUCATION (écoles, universités, établissements éducatifs)" },
{ value: "PARKS", label: "PARKS (parcs, jardins, aires de jeux)" },
{ value: "HEALTH", label: "HEALTH (hôpitaux, médecins, pharmacies, dentistes, vétérinaires)" },
{ value: "SERVICES", label: "SERVICES (bureaux de poste, banques, blanchisseries, salons de coiffure)" },
{ value: "SPORT", label: "SPORT (salles de sport, stades, complexes sportifs, piscines)" },
{ value: "FOOD", label: "FOOD (restaurants, cafés, boulangeries)" },
{ value: "TRANSPORT", label: "TRANSPORT (gares, stations de métro, stations de bus)" },
{ value: "SHOPPING", label: "SHOPPING (supermarchés, centres commerciaux, épiceries)" },
{ value: "SKI_AREA", label: "SKI_AREA (domaines skiables)" },
{ value: "SKI_LIFT", label: "SKI_LIFT (remontées mécaniques)" },
],
required: false,
label: "POI category",
label: "Catégorie de POI",
description: "Type de point d'intérêt recherché à proximité",
},
},
{
key: "max_walk_time_minutes",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Temps de marche max (min)",
description: "Temps de marche maximum pour atteindre le POI",
},
},
{
key: "max_car_time_minutes",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Temps en voiture max (min)",
description: "Temps en voiture maximum pour atteindre le POI",
},
},
{
key: "max_transport_time_minutes",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Temps en transport max (min)",
description: "Temps en transport en commun maximum pour atteindre le POI",
},
},
{
key: "max_bike_time_minutes",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Temps à vélo max (min)",
description: "Temps à vélo maximum pour atteindre le POI",
},
},
{
key: "max_distance_meters",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Distance maximale (m)",
description: "Distance maximale en mètres jusqu'au POI",
},
},
{
key: "lift_type",
required: false,
schema: { kind: "primitive", type: "string", required: false },
schema: {
kind: "primitive",
type: "string",
required: false,
label: "Type de remontée",
description: "Type de remontée mécanique (télésiège, téléphérique, etc.)",
},
},
{
key: "domain_min_altitude",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Altitude minimale (m)",
description: "Altitude minimale du domaine skiable en mètres",
},
},
{
key: "min_rating",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Note minimale",
description: "Note minimale requise pour le POI (sur 5)",
},
},
],
};
@ -138,6 +199,8 @@ const visionWeightFields: SchemaObjectNode["fields"] = [
kind: "primitive",
type: "number",
required: false,
label: "Poids",
description: "Importance de ce critère dans l'évaluation",
},
},
{
@ -147,6 +210,8 @@ const visionWeightFields: SchemaObjectNode["fields"] = [
kind: "primitive",
type: "number",
required: false,
label: "Pénalité si absent",
description: "Pénalité appliquée si ce critère est manquant",
},
},
];
@ -162,44 +227,86 @@ export const profileSchema: SchemaDefinition = {
kind: "enum",
options: transactionOptions,
required: false,
label: "Transaction type",
description: "Desired transaction type.",
label: "Type de transaction",
description: "Type de transaction souhaité (vente, location, viager)",
},
},
{
key: "min_price",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Prix minimum (€)",
description: "Prix minimum du bien en euros",
},
},
{
key: "max_price",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Prix maximum (€)",
description: "Prix maximum du bien en euros",
},
},
{
key: "min_size",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Surface minimale (m²)",
description: "Surface minimale du bien en mètres carrés",
},
},
{
key: "max_size",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Surface maximale (m²)",
description: "Surface maximale du bien en mètres carrés",
},
},
{
key: "min_bedrooms",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Nombre de chambres min",
description: "Nombre minimum de chambres",
},
},
{
key: "max_bedrooms",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Nombre de chambres max",
description: "Nombre maximum de chambres",
},
},
{
key: "min_bathrooms",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Nombre de salles de bain min",
description: "Nombre minimum de salles de bain",
},
},
{
key: "property_type",
@ -208,7 +315,8 @@ export const profileSchema: SchemaDefinition = {
kind: "array",
required: false,
element: { kind: "primitive", type: "string", required: false },
description: "List of property types (apartment, house, etc.).",
label: "Types de biens",
description: "Types de biens recherchés (appartement, maison, etc.)",
},
},
{
@ -219,7 +327,8 @@ export const profileSchema: SchemaDefinition = {
options: dpeClassOptions,
multiple: true,
required: false,
description: "Accepted energy classes.",
label: "Classes DPE acceptées",
description: "Classes de diagnostic de performance énergétique acceptées",
},
},
{
@ -230,13 +339,20 @@ export const profileSchema: SchemaDefinition = {
options: dpeClassOptions,
multiple: true,
required: false,
description: "Accepted greenhouse emission classes.",
label: "Classes GES acceptées",
description: "Classes d'émissions de gaz à effet de serre acceptées",
},
},
{
key: "city",
required: false,
schema: { kind: "primitive", type: "string", required: false },
schema: {
kind: "primitive",
type: "string",
required: false,
label: "Ville",
description: "Nom de la ville recherchée",
},
},
{
key: "cities",
@ -245,27 +361,53 @@ export const profileSchema: SchemaDefinition = {
kind: "array",
required: false,
element: { kind: "primitive", type: "string", required: false },
label: "Villes",
description: "Liste des villes recherchées",
},
},
{
key: "postal_code",
required: false,
schema: { kind: "primitive", type: "string", required: false },
schema: {
kind: "primitive",
type: "string",
required: false,
label: "Code postal",
description: "Code postal de la zone recherchée",
},
},
{
key: "code_insee",
required: false,
schema: { kind: "primitive", type: "string", required: false },
schema: {
kind: "primitive",
type: "string",
required: false,
label: "Code INSEE",
description: "Code INSEE de la commune",
},
},
{
key: "target_yield",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Rendement cible (%)",
description: "Rendement locatif cible en pourcentage",
},
},
{
key: "require_address",
required: false,
schema: { kind: "primitive", type: "boolean", required: false },
schema: {
kind: "primitive",
type: "boolean",
required: false,
label: "Adresse requise",
description: "Exiger une adresse complète pour le bien",
},
},
{
key: "characteristics",
@ -274,7 +416,8 @@ export const profileSchema: SchemaDefinition = {
kind: "array",
required: false,
element: characteristicsSchema,
description: "Boolean feature groups to enforce.",
label: "Caractéristiques",
description: "Groupes de caractéristiques du bien à rechercher",
},
},
{
@ -284,7 +427,8 @@ export const profileSchema: SchemaDefinition = {
kind: "array",
required: false,
element: poiConstraintSchema,
description: "Proximity constraints relative to points of interest.",
label: "Proximité POI",
description: "Contraintes de proximité par rapport aux points d'intérêt",
},
},
{
@ -293,11 +437,19 @@ export const profileSchema: SchemaDefinition = {
schema: {
kind: "object",
required: false,
label: "Exigences visuelles",
description: "Critères d'évaluation basés sur l'analyse visuelle du bien",
fields: [
{
key: "description",
required: false,
schema: { kind: "primitive", type: "string", required: false },
schema: {
kind: "primitive",
type: "string",
required: false,
label: "Description",
description: "Description générale des exigences visuelles",
},
},
{
key: "kitchen",
@ -305,26 +457,52 @@ export const profileSchema: SchemaDefinition = {
schema: {
kind: "object",
required: false,
label: "Cuisine",
description: "Exigences pour la cuisine",
fields: [
{
key: "description",
required: false,
schema: { kind: "primitive", type: "string", required: false },
schema: {
kind: "primitive",
type: "string",
required: false,
label: "Description",
description: "Description des attentes pour la cuisine",
},
},
{
key: "size",
required: false,
schema: { kind: "enum", options: visionSizeOptions, required: false },
schema: {
kind: "enum",
options: visionSizeOptions,
required: false,
label: "Taille",
description: "Taille souhaitée de la cuisine (Small, Medium, Large)",
},
},
{
key: "open",
required: false,
schema: { kind: "primitive", type: "boolean", required: false },
schema: {
kind: "primitive",
type: "boolean",
required: false,
label: "Cuisine ouverte",
description: "La cuisine doit être ouverte sur le salon",
},
},
{
key: "appliances",
required: false,
schema: { kind: "primitive", type: "string", required: false },
schema: {
kind: "primitive",
type: "string",
required: false,
label: "Équipements",
description: "Liste des équipements souhaités dans la cuisine",
},
},
...visionWeightFields,
],
@ -336,21 +514,41 @@ export const profileSchema: SchemaDefinition = {
schema: {
kind: "object",
required: false,
label: "Salle de bain",
description: "Exigences pour la salle de bain",
fields: [
{
key: "description",
required: false,
schema: { kind: "primitive", type: "string", required: false },
schema: {
kind: "primitive",
type: "string",
required: false,
label: "Description",
description: "Description des attentes pour la salle de bain",
},
},
{
key: "size",
required: false,
schema: { kind: "enum", options: visionSizeOptions, required: false },
schema: {
kind: "enum",
options: visionSizeOptions,
required: false,
label: "Taille",
description: "Taille souhaitée de la salle de bain",
},
},
{
key: "appliances",
required: false,
schema: { kind: "primitive", type: "string", required: false },
schema: {
kind: "primitive",
type: "string",
required: false,
label: "Équipements",
description: "Liste des équipements souhaités (baignoire, douche, etc.)",
},
},
...visionWeightFields,
],
@ -362,16 +560,30 @@ export const profileSchema: SchemaDefinition = {
schema: {
kind: "object",
required: false,
label: "Salon",
description: "Exigences pour le salon",
fields: [
{
key: "description",
required: false,
schema: { kind: "primitive", type: "string", required: false },
schema: {
kind: "primitive",
type: "string",
required: false,
label: "Description",
description: "Description des attentes pour le salon",
},
},
{
key: "size",
required: false,
schema: { kind: "enum", options: visionSizeOptions, required: false },
schema: {
kind: "enum",
options: visionSizeOptions,
required: false,
label: "Taille",
description: "Taille souhaitée du salon",
},
},
...visionWeightFields,
],
@ -383,16 +595,30 @@ export const profileSchema: SchemaDefinition = {
schema: {
kind: "object",
required: false,
label: "Chambres",
description: "Exigences pour les chambres",
fields: [
{
key: "description",
required: false,
schema: { kind: "primitive", type: "string", required: false },
schema: {
kind: "primitive",
type: "string",
required: false,
label: "Description",
description: "Description des attentes pour les chambres",
},
},
{
key: "min_count",
required: false,
schema: { kind: "primitive", type: "number", required: false },
schema: {
kind: "primitive",
type: "number",
required: false,
label: "Nombre minimum",
description: "Nombre minimum de chambres requis",
},
},
...visionWeightFields,
],
@ -404,16 +630,30 @@ export const profileSchema: SchemaDefinition = {
schema: {
kind: "object",
required: false,
label: "Espaces extérieurs",
description: "Exigences pour les espaces extérieurs",
fields: [
{
key: "description",
required: false,
schema: { kind: "primitive", type: "string", required: false },
schema: {
kind: "primitive",
type: "string",
required: false,
label: "Description",
description: "Description des attentes pour les espaces extérieurs",
},
},
{
key: "required",
required: false,
schema: { kind: "primitive", type: "boolean", required: false },
schema: {
kind: "primitive",
type: "boolean",
required: false,
label: "Requis",
description: "Un espace extérieur est-il obligatoire",
},
},
{
key: "types",
@ -422,6 +662,8 @@ export const profileSchema: SchemaDefinition = {
kind: "array",
required: false,
element: { kind: "primitive", type: "string", required: false },
label: "Types d'espaces",
description: "Types d'espaces extérieurs souhaités (terrasse, jardin, balcon, etc.)",
},
},
...visionWeightFields,
@ -431,7 +673,13 @@ export const profileSchema: SchemaDefinition = {
{
key: "note",
required: false,
schema: { kind: "primitive", type: "string", required: false },
schema: {
kind: "primitive",
type: "string",
required: false,
label: "Note",
description: "Remarques ou notes supplémentaires sur les exigences visuelles",
},
},
],
},

View File

@ -0,0 +1,571 @@
/**
* Métadonnées (labels et descriptions) pour le schéma scraper.
* Ces informations sont injectées dans le schéma après normalisation.
*/
import type { SchemaDefinition, SchemaNode, SchemaObjectNode } from "./types";
export interface FieldMetadata {
label?: string;
description?: string;
}
export const scraperFieldLabels: Record<string, FieldMetadata> = {
// Métadonnées globales
"meta": {
label: "Métadonnées",
description: "Informations temporelles sur les annonces",
},
"meta.firstSeenAt": {
label: "Première apparition",
description: "Période de première apparition de l'annonce",
},
"meta.firstSeenAt.min": {
label: "Date min",
description: "Date minimale de première apparition",
},
"meta.firstSeenAt.max": {
label: "Date max",
description: "Date maximale de première apparition",
},
"meta.lastSeenAt": {
label: "Dernière vue",
description: "Période de dernière visibilité de l'annonce",
},
"meta.lastSeenAt.min": {
label: "Date min",
description: "Date minimale de dernière vue",
},
"meta.lastSeenAt.max": {
label: "Date max",
description: "Date maximale de dernière vue",
},
"meta.lastPublishedAt": {
label: "Dernière publication",
description: "Période de dernière publication",
},
"meta.lastUpdatedAt": {
label: "Dernière mise à jour",
description: "Période de dernière mise à jour",
},
"meta.isTotallyOffline": {
label: "Totalement hors ligne",
description: "Annonce complètement retirée de toutes les plateformes",
},
// Prix
"price": {
label: "Prix",
description: "Critères de prix du bien",
},
"price.currency": {
label: "Devise",
description: "Devise du prix",
},
"price.isAuction": {
label: "Est aux enchères",
description: "Le bien est vendu aux enchères",
},
"price.scope": {
label: "Périodicité",
description: "Paiement unique ou mensuel (pour locations)",
},
"price.latest": {
label: "Prix actuel",
description: "Prix le plus récent",
},
"price.latest.value": {
label: "Valeur",
description: "Fourchette de prix",
},
"price.latest.value.min": {
label: "Prix min (€)",
description: "Prix minimum",
},
"price.latest.value.max": {
label: "Prix max (€)",
description: "Prix maximum",
},
"price.latest.valuePerArea": {
label: "Prix au m²",
description: "Prix par mètre carré",
},
"price.latest.valuePerArea.min": {
label: "Prix/m² min (€)",
description: "Prix minimum par m²",
},
"price.latest.valuePerArea.max": {
label: "Prix/m² max (€)",
description: "Prix maximum par m²",
},
"price.initial": {
label: "Prix initial",
description: "Prix de départ",
},
"price.initial.value": {
label: "Valeur initiale",
description: "Fourchette de prix initial",
},
"price.warrantyDeposit": {
label: "Dépôt de garantie",
description: "Montant du dépôt de garantie (pour locations)",
},
"price.warrantyDeposit.min": {
label: "Min (€)",
description: "Dépôt minimum",
},
"price.warrantyDeposit.max": {
label: "Max (€)",
description: "Dépôt maximum",
},
// Localisation
"location": {
label: "Localisation",
description: "Critères de localisation géographique",
},
"location.city": {
label: "Ville",
description: "Nom de la ville",
},
"location.postalCode": {
label: "Code postal",
description: "Code postal de la zone",
},
"location.department": {
label: "Département",
description: "Nom du département",
},
"location.inseeCode": {
label: "Code INSEE",
description: "Code INSEE de la commune",
},
"location.irisCode": {
label: "Code IRIS",
description: "Code IRIS (quartier)",
},
"location.locationCoordinate": {
label: "Coordonnées GPS",
description: "Position géographique exacte",
},
"location.locationCoordinate.location.lat": {
label: "Latitude",
description: "Latitude GPS",
},
"location.locationCoordinate.location.lon": {
label: "Longitude",
description: "Longitude GPS",
},
// Habitation
"habitation": {
label: "Habitation",
description: "Caractéristiques de l'habitation",
},
"habitation.type": {
label: "Type de bien",
description: "Type de propriété (appartement, maison, etc.)",
},
"habitation.roomCount": {
label: "Nombre de pièces",
description: "Nombre total de pièces",
},
"habitation.roomCount.min": {
label: "Min",
description: "Nombre minimum de pièces",
},
"habitation.roomCount.max": {
label: "Max",
description: "Nombre maximum de pièces",
},
"habitation.bedroomCount": {
label: "Nombre de chambres",
description: "Nombre de chambres",
},
"habitation.bedroomCount.min": {
label: "Min",
description: "Nombre minimum de chambres",
},
"habitation.bedroomCount.max": {
label: "Max",
description: "Nombre maximum de chambres",
},
"habitation.bathroomCount": {
label: "Salles de bain",
description: "Nombre de salles de bain",
},
"habitation.bathroomCount.min": {
label: "Min",
description: "Minimum de salles de bain",
},
"habitation.bathroomCount.max": {
label: "Max",
description: "Maximum de salles de bain",
},
"habitation.wcCount": {
label: "Toilettes",
description: "Nombre de WC",
},
// Surface
"habitation.surface": {
label: "Surfaces",
description: "Surfaces du bien",
},
"habitation.surface.total": {
label: "Surface totale",
description: "Surface totale du bien",
},
"habitation.surface.total.min": {
label: "Min (m²)",
description: "Surface minimale",
},
"habitation.surface.total.max": {
label: "Max (m²)",
description: "Surface maximale",
},
"habitation.surface.livingSpace": {
label: "Surface habitable",
description: "Surface habitable",
},
"habitation.surface.livingSpace.min": {
label: "Min (m²)",
description: "Surface habitable minimale",
},
"habitation.surface.livingSpace.max": {
label: "Max (m²)",
description: "Surface habitable maximale",
},
"habitation.surface.livingroom": {
label: "Surface salon",
description: "Surface du salon",
},
"habitation.surface.kitchen": {
label: "Surface cuisine",
description: "Surface de la cuisine",
},
"habitation.surface.terraces": {
label: "Surface terrasses",
description: "Surface des terrasses",
},
"habitation.surface.balconies": {
label: "Surface balcons",
description: "Surface des balcons",
},
"habitation.surface.gardens": {
label: "Surface jardins",
description: "Surface des jardins",
},
// Caractéristiques
"habitation.characteristics": {
label: "Caractéristiques",
description: "Caractéristiques et équipements",
},
"habitation.characteristics.hasBalcony": {
label: "Balcon",
description: "Présence d'un balcon",
},
"habitation.characteristics.hasTerrace": {
label: "Terrasse",
description: "Présence d'une terrasse",
},
"habitation.characteristics.hasGarden": {
label: "Jardin",
description: "Présence d'un jardin",
},
"habitation.characteristics.hasParking": {
label: "Parking",
description: "Place de parking disponible",
},
"habitation.characteristics.hasGarage": {
label: "Garage",
description: "Garage disponible",
},
"habitation.characteristics.hasPool": {
label: "Piscine",
description: "Présence d'une piscine",
},
"habitation.characteristics.hasLift": {
label: "Ascenseur",
description: "Immeuble avec ascenseur",
},
"habitation.characteristics.hasCellar": {
label: "Cave",
description: "Cave disponible",
},
"habitation.characteristics.hasFireplace": {
label: "Cheminée",
description: "Présence d'une cheminée",
},
"habitation.characteristics.hasAlarm": {
label: "Alarme",
description: "Système d'alarme installé",
},
// DPE / Climat
"habitation.climate": {
label: "Performance énergétique",
description: "Diagnostic de performance énergétique",
},
"habitation.climate.epcEnergy": {
label: "Classe DPE",
description: "Classe de diagnostic de performance énergétique",
},
"habitation.climate.epcClimate": {
label: "Classe GES",
description: "Classe d'émissions de gaz à effet de serre",
},
"habitation.climate.epcEnergyScore": {
label: "Score DPE",
description: "Score numérique DPE",
},
"habitation.climate.epcClimateScore": {
label: "Score GES",
description: "Score numérique GES",
},
// État du bien
"habitation.propertyCondition": {
label: "État du bien",
description: "Condition et année de construction",
},
"habitation.propertyCondition.constructionYear": {
label: "Année de construction",
description: "Année de construction du bien",
},
"habitation.propertyCondition.renovationYear": {
label: "Année de rénovation",
description: "Année de dernière rénovation",
},
"habitation.propertyCondition.interiorCondition": {
label: "État intérieur",
description: "État de l'intérieur du bien",
},
// Étage
"habitation.features": {
label: "Caractéristiques",
description: "Caractéristiques détaillées",
},
"habitation.features.propertyFloor": {
label: "Étage",
description: "Étage du bien",
},
"habitation.features.propertyFloor.min": {
label: "Étage min",
description: "Étage minimum",
},
"habitation.features.propertyFloor.max": {
label: "Étage max",
description: "Étage maximum",
},
"habitation.features.propertyTotalFloor": {
label: "Étages total",
description: "Nombre total d'étages de l'immeuble",
},
"habitation.features.exposure": {
label: "Exposition",
description: "Exposition du bien (nord, sud, etc.)",
},
"habitation.features.furniture": {
label: "Ameublement",
description: "État d'ameublement du bien",
},
// Chauffage
"habitation.heatings": {
label: "Type de chauffage",
description: "Source d'énergie pour le chauffage",
},
"habitation.heatTypes": {
label: "Système de chauffage",
description: "Type de système de chauffage",
},
"habitation.heatTypeDetails": {
label: "Détails chauffage",
description: "Détails du système de chauffage",
},
// Terrain
"land": {
label: "Terrain",
description: "Caractéristiques du terrain",
},
"land.surface": {
label: "Surface terrain",
description: "Surface du terrain",
},
"land.surface.min": {
label: "Min (m²)",
description: "Surface minimale du terrain",
},
"land.surface.max": {
label: "Max (m²)",
description: "Surface maximale du terrain",
},
"land.type": {
label: "Type de terrain",
description: "Type de terrain",
},
"land.canConstruct": {
label: "Constructible",
description: "Terrain constructible",
},
"land.isServiced": {
label: "Viabilisé",
description: "Terrain viabilisé",
},
"land.haveBuildingPermit": {
label: "Permis de construire",
description: "Permis de construire obtenu",
},
// Type de transaction
"type": {
label: "Type de bien",
description: "Catégorie de bien immobilier",
},
"offer": {
label: "Offre",
description: "Type d'offre immobilière",
},
"offer.type": {
label: "Type de transaction",
description: "Achat, location, viager, etc.",
},
"offer.isCurrentlyOccupied": {
label: "Actuellement occupé",
description: "Le bien est actuellement occupé",
},
"offer.renting": {
label: "Location",
description: "Détails de la location",
},
"offer.renting.isColocation": {
label: "Colocation",
description: "Location en colocation",
},
"offer.renting.isLongTerm": {
label: "Longue durée",
description: "Location longue durée",
},
"offer.renting.isShortTerm": {
label: "Courte durée",
description: "Location courte durée / saisonnière",
},
// Parking
"parking": {
label: "Parking",
description: "Informations sur le parking",
},
"parking.count": {
label: "Nombre de places",
description: "Nombre de places de parking",
},
"parking.count.min": {
label: "Min",
description: "Nombre minimum de places",
},
"parking.count.max": {
label: "Max",
description: "Nombre maximum de places",
},
"parking.type": {
label: "Type",
description: "Type de parking (garage, extérieur)",
},
"parking.surface": {
label: "Surface",
description: "Surface du parking",
},
// Annonces
"adverts": {
label: "Annonces",
description: "Critères sur les annonces elles-mêmes",
},
"adverts.isPro": {
label: "Annonce professionnelle",
description: "Annonce d'un professionnel",
},
"adverts.isOnline": {
label: "En ligne",
description: "Annonce actuellement visible",
},
"adverts.hasAnomaly": {
label: "A des anomalies",
description: "Présence d'anomalies détectées",
},
"adverts.isExclusive": {
label: "Exclusivité",
description: "Annonce en exclusivité",
},
// Global
"process": {
label: "Statut",
description: "Statut de l'annonce dans le processus de vente",
},
"isUrgent": {
label: "Urgent",
description: "Annonce marquée comme urgente",
},
"hasAnomaly": {
label: "A des anomalies",
description: "Des anomalies ont été détectées",
},
"tags": {
label: "Tags",
description: "Tags personnalisés",
},
};
/**
* Enrichit récursivement un schéma avec les labels et descriptions.
*/
function enrichSchemaNode(node: SchemaNode, path: string): SchemaNode {
const metadata = scraperFieldLabels[path];
const enrichedNode = { ...node };
if (metadata) {
if (metadata.label) {
enrichedNode.label = metadata.label;
}
if (metadata.description) {
enrichedNode.description = metadata.description;
}
}
if (enrichedNode.kind === "object") {
const objectNode = enrichedNode as SchemaObjectNode;
objectNode.fields = objectNode.fields.map((field) => {
const fieldPath = path ? `${path}.${field.key}` : field.key;
return {
...field,
schema: enrichSchemaNode(field.schema, fieldPath),
};
});
} else if (enrichedNode.kind === "array") {
// Pour les arrays, on enrichit l'élément mais on garde le même path
// car les éléments du array partagent le même schéma
enrichedNode.element = enrichSchemaNode(enrichedNode.element, path);
} else if (enrichedNode.kind === "range") {
// Pour les ranges, on enrichit aussi les min/max si ils existent
const minPath = `${path}.min`;
const maxPath = `${path}.max`;
if (scraperFieldLabels[minPath] || scraperFieldLabels[maxPath]) {
// Les ranges n'ont pas de sous-champs mais on peut enrichir le range lui-même
}
}
return enrichedNode;
}
/**
* Enrichit un schéma racine avec les labels et descriptions.
*/
export function enrichScraperSchema(schema: SchemaDefinition): SchemaDefinition {
return enrichSchemaNode(schema, "") as SchemaDefinition;
}

View File

@ -12,9 +12,11 @@ export interface SchemaPrimitiveNode extends SchemaBaseNode {
type: PrimitiveType;
}
export type EnumOption = string | { value: string; label: string };
export interface SchemaEnumNode extends SchemaBaseNode {
kind: "enum";
options: string[];
options: EnumOption[];
multiple?: boolean;
}