Skip to content

Commit 5e65ec9

Browse files
committed
organize code
1 parent bc5bc1f commit 5e65ec9

File tree

6 files changed

+360
-301
lines changed

6 files changed

+360
-301
lines changed

client/api/divar.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { toEnglishDigits } from "@/utils/format";
2+
import { ofetch } from "ofetch";
3+
4+
const CITY = '1'; // Tehran city ID
5+
6+
const httpClient = ofetch.create({
7+
baseURL: 'https://api.divar.ir',
8+
retryDelay: 30000,
9+
retry: 5,
10+
retryStatusCodes: [429, 500],
11+
responseType: 'json',
12+
headers: {
13+
'Content-Type': 'application/json',
14+
},
15+
})
16+
17+
export type House = {
18+
location: { lat: number, lng: number } | null,
19+
size: number | null,
20+
beds: number | null,
21+
totalPrice: number | null,
22+
unitPrice: number | null,
23+
elevator: boolean | null,
24+
storage: boolean | null,
25+
parking: boolean | null,
26+
balcony: boolean | null,
27+
yearBuilt: number | null,
28+
}
29+
export const getHouse = (id: string): Promise<House> => {
30+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
31+
return httpClient(`/v8/posts-v2/web/${id}`).then((resp: any) => {
32+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
33+
let fields: Record<string, any> = {};
34+
35+
for (const section of (resp?.sections ?? [])) {
36+
for (const widget of (section?.widgets ?? [])) {
37+
const widgetType = widget?.widget_type;
38+
if (widgetType === 'GROUP_INFO_ROW' || widgetType === 'GROUP_FEATURE_ROW' || widgetType === 'UNEXPANDABLE_ROW') {
39+
for (const item of (widget?.data?.items ?? [widget?.data])) {
40+
if (item?.title) {
41+
fields = {
42+
...fields,
43+
[item.title]: item.available ?? toEnglishDigits(item.value).replace(/[^\d\s]/g, ''),
44+
};
45+
}
46+
}
47+
}
48+
if (widgetType === 'MAP_ROW' && (widget?.data?.location?.fuzzy_data?.point || widget?.data?.location?.exact_data?.point)) {
49+
fields = {
50+
...fields,
51+
location: widget.data.location.exact_data?.point ?? widget.data.location.fuzzy_data?.point,
52+
};
53+
}
54+
}
55+
}
56+
57+
return {
58+
location: typeof fields.location?.latitude === 'number' && typeof fields.location?.longitude === 'number' ? { lat: fields.location?.latitude, lng: fields.location.longitude } : null,
59+
size: typeof fields['متراژ'] === 'string' && fields['متراژ'].trim() !== '' ? +fields['متراژ'] : null,
60+
beds: typeof fields['اتاق'] === 'string' && fields['اتاق'].trim() !== '' ? +fields['اتاق'] : null,
61+
// floor: typeof fields['طبقه'] === 'string' && fields['طبقه'].trim() !== '' ? fields['طبقه'].split(' ').filter(x=>!!x).map(x => +x) as [number, number] | [number] : null,
62+
totalPrice: typeof fields['قیمت کل'] === 'string' && fields['قیمت کل'].trim() !== '' ? +fields['قیمت کل'] : null,
63+
unitPrice: typeof fields['قیمت هر متر'] === 'string' && fields['قیمت هر متر'].trim() !== '' ? +fields['قیمت هر متر'] : null,
64+
elevator: 'آسانسور ندارد' in fields || fields['آسانسور'] === false ? false : fields['آسانسور'] === true ? true : null,
65+
storage: 'انباری ندارد' in fields || fields['انباری'] === false ? false : fields['انباری'] === true ? true : null,
66+
parking: 'پارکینگ ندارد' in fields || fields['پارکینگ'] === false ? false : fields['پارکینگ'] === true ? true : null,
67+
balcony: 'بالکن ندارد' in fields || fields['بالکن'] === false ? false : fields['بالکن'] === true ? true : null,
68+
yearBuilt: typeof fields['ساخت'] === 'string' && fields['ساخت'] !== '' ? +fields['ساخت'] : null,
69+
};
70+
});
71+
}
72+
73+
74+
export type District = {
75+
title: string,
76+
value: string,
77+
hint: string,
78+
keywords: string[],
79+
}
80+
81+
export const getDistricts = (): Promise<District[]> => {
82+
return httpClient(
83+
"/v8/postlist/w/filters",
84+
{
85+
method: 'POST',
86+
body: {
87+
city_ids: [CITY],
88+
source_view: "FILTER",
89+
data: {},
90+
},
91+
}
92+
)
93+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
94+
.then((resp: any) => {
95+
let returnValue: District[] = [];
96+
for (const widget of (resp?.page?.widget_list ?? [])) {
97+
for (const subWidget of (widget?.data?.widget_list ?? [])) {
98+
for (const district of (subWidget?.data?.neighborhoods?.options ?? [])) {
99+
returnValue = [
100+
...returnValue,
101+
{
102+
title: district.title as string,
103+
value: district.value as string,
104+
hint: district.hint as string,
105+
keywords: district.search_keywords.split('،').map((x: string) => x.trim())
106+
},
107+
];
108+
}
109+
}
110+
}
111+
return returnValue;
112+
});
113+
}
114+
115+
export type SearchHousesFilters = {
116+
district?: string[];
117+
elevator?: boolean;
118+
parking?: boolean;
119+
balcony?: boolean;
120+
size?: [number, number];
121+
price?: [number, number];
122+
}
123+
124+
export const getHousesIds = (filters: SearchHousesFilters): Promise<string[]> => {
125+
return httpClient("/v8/postlist/w/search", {
126+
method: 'POST',
127+
body: {
128+
city_ids: [CITY],
129+
source_view: 'FILTER',
130+
disable_recommendation: false,
131+
search_data: {
132+
form_data: {
133+
data: {
134+
// bbox: {
135+
// repeated_float: {
136+
// value: [
137+
// { value: 51.4265289 },
138+
// { value: 35.7938423 },
139+
// { value: 51.4346771 },
140+
// { value: 35.8068733 },
141+
// ],
142+
// },
143+
// },
144+
deed_type: { repeated_string: { value: ['single_page'] } },
145+
category: { str: { value: "apartment-sell" } },
146+
...(typeof filters.district !== 'undefined' && {
147+
districts: { repeated_string: { value: filters.district } },
148+
}),
149+
...(typeof filters.elevator !== 'undefined' && {
150+
elevator: { boolean: { value: filters.elevator } },
151+
}),
152+
...(typeof filters.parking !== 'undefined' && {
153+
parking: { boolean: { value: filters.parking } },
154+
}),
155+
...(typeof filters.balcony !== 'undefined' && {
156+
balcony: { boolean: { value: filters.balcony } },
157+
}),
158+
...(typeof filters.size !== 'undefined' && {
159+
size: {number_range: { minimum: filters.size[0], maximum: filters.size[1] }},
160+
}),
161+
...(typeof filters.price !== 'undefined' && {
162+
price: {number_range: { minimum: filters.price[0], maximum: filters.price[1] }},
163+
}),
164+
},
165+
},
166+
},
167+
},
168+
})
169+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
170+
.then((resp: any) => {
171+
let returnValue: string[] = [];
172+
for (const widget of (resp?.list_widgets ?? [])) {
173+
if (widget?.widget_type !== 'POST_ROW' || !widget?.data?.middle_description_text?.includes('تومان') || !widget?.data?.token) continue;
174+
returnValue = [...returnValue, widget.data.token];
175+
}
176+
return returnValue;
177+
});
178+
}
179+
180+
181+
export const getHouses = async (filters: SearchHousesFilters, progressFn?: (value: number, lastValue: House[]) => void): Promise<House[]> => {
182+
let returnValue: House[] = [];
183+
let currentProgress = 0;
184+
const progress = (value: number) => {
185+
currentProgress = value;
186+
progressFn?.(currentProgress, returnValue);
187+
}
188+
progress(0);
189+
const districts = await getDistricts();
190+
191+
let passedDistricts = 0;
192+
for (const district of districts) {
193+
progress(passedDistricts / districts.length);
194+
const districtHouseIds = await getHousesIds({
195+
...filters,
196+
district: [district.value],
197+
});
198+
199+
200+
for (const houseId of districtHouseIds) {
201+
await new Promise((resolve) => setTimeout(resolve, 1000));
202+
returnValue = [...returnValue, await getHouse(houseId)];
203+
progress((passedDistricts + (districtHouseIds.indexOf(houseId) / districtHouseIds.length)) / districts.length);
204+
}
205+
206+
passedDistricts += 1;
207+
}
208+
progress(1);
209+
return returnValue;
210+
}

client/api/index.ts

Lines changed: 1 addition & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -1,178 +1 @@
1-
import { toEnglishDigits } from "@/utils/format";
2-
import { ofetch } from "ofetch";
3-
4-
const CITY = '1'; // Tehran city ID
5-
6-
const httpClient = ofetch.create({
7-
baseURL: 'https://api.divar.ir',
8-
retryDelay: 30000,
9-
retry: 5,
10-
retryStatusCodes: [429, 500],
11-
responseType: 'json',
12-
headers: {
13-
'Content-Type': 'application/json',
14-
},
15-
})
16-
17-
export type House = {
18-
location: { lat: number, lng: number } | null,
19-
size: number | null,
20-
beds: number | null,
21-
totalPrice: number | null,
22-
unitPrice: number | null,
23-
elevator: boolean | null,
24-
storage: boolean | null,
25-
parking: boolean | null,
26-
balcony: boolean | null,
27-
yearBuilt: number | null,
28-
}
29-
export const getHouse = (id: string): Promise<House> => {
30-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
31-
return httpClient(`/v8/posts-v2/web/${id}`).then((resp: any) => {
32-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
33-
let fields: Record<string, any> = {};
34-
35-
for (const section of (resp?.sections ?? [])) {
36-
for (const widget of (section?.widgets ?? [])) {
37-
const widgetType = widget?.widget_type;
38-
if (widgetType === 'GROUP_INFO_ROW' || widgetType === 'GROUP_FEATURE_ROW' || widgetType === 'UNEXPANDABLE_ROW') {
39-
for (const item of (widget?.data?.items ?? [widget?.data])) {
40-
if (item?.title) {
41-
fields = {
42-
...fields,
43-
[item.title]: item.available ?? toEnglishDigits(item.value).replace(/[^\d\s]/g, ''),
44-
};
45-
}
46-
}
47-
}
48-
if (widgetType === 'MAP_ROW' && (widget?.data?.location?.fuzzy_data?.point || widget?.data?.location?.exact_data?.point)) {
49-
fields = {
50-
...fields,
51-
location: widget.data.location.exact_data?.point ?? widget.data.location.fuzzy_data?.point,
52-
};
53-
}
54-
}
55-
}
56-
57-
return {
58-
location: typeof fields.location?.latitude === 'number' && typeof fields.location?.longitude === 'number' ? { lat: fields.location?.latitude, lng: fields.location.longitude } : null,
59-
size: typeof fields['متراژ'] === 'string' && fields['متراژ'].trim() !== '' ? +fields['متراژ'] : null,
60-
beds: typeof fields['اتاق'] === 'string' && fields['اتاق'].trim() !== '' ? +fields['اتاق'] : null,
61-
// floor: typeof fields['طبقه'] === 'string' && fields['طبقه'].trim() !== '' ? fields['طبقه'].split(' ').filter(x=>!!x).map(x => +x) as [number, number] | [number] : null,
62-
totalPrice: typeof fields['قیمت کل'] === 'string' && fields['قیمت کل'].trim() !== '' ? +fields['قیمت کل'] : null,
63-
unitPrice: typeof fields['قیمت هر متر'] === 'string' && fields['قیمت هر متر'].trim() !== '' ? +fields['قیمت هر متر'] : null,
64-
elevator: 'آسانسور ندارد' in fields || fields['آسانسور'] === false ? false : fields['آسانسور'] === true ? true : null,
65-
storage: 'انباری ندارد' in fields || fields['انباری'] === false ? false : fields['انباری'] === true ? true : null,
66-
parking: 'پارکینگ ندارد' in fields || fields['پارکینگ'] === false ? false : fields['پارکینگ'] === true ? true : null,
67-
balcony: 'بالکن ندارد' in fields || fields['بالکن'] === false ? false : fields['بالکن'] === true ? true : null,
68-
yearBuilt: typeof fields['ساخت'] === 'string' && fields['ساخت'] !== '' ? +fields['ساخت'] : null,
69-
};
70-
});
71-
}
72-
73-
74-
export type District = {
75-
title: string,
76-
value: string,
77-
hint: string,
78-
keywords: string[],
79-
}
80-
81-
export const getDistricts = (): Promise<District[]> => {
82-
return httpClient(
83-
"/v8/postlist/w/filters",
84-
{
85-
method: 'POST',
86-
body: {
87-
city_ids: [CITY],
88-
source_view: "FILTER",
89-
data: {},
90-
},
91-
}
92-
)
93-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
94-
.then((resp: any) => {
95-
let returnValue: District[] = [];
96-
for (const widget of (resp?.page?.widget_list ?? [])) {
97-
for (const subWidget of (widget?.data?.widget_list ?? [])) {
98-
for (const district of (subWidget?.data?.neighborhoods?.options ?? [])) {
99-
returnValue = [
100-
...returnValue,
101-
{
102-
title: district.title as string,
103-
value: district.value as string,
104-
hint: district.hint as string,
105-
keywords: district.search_keywords.split('،').map((x: string) => x.trim())
106-
},
107-
];
108-
}
109-
}
110-
}
111-
return returnValue;
112-
});
113-
}
114-
115-
export type SearchHousesFilters = {
116-
district?: string[];
117-
elevator?: boolean;
118-
parking?: boolean;
119-
balcony?: boolean;
120-
size?: [number, number];
121-
price?: [number, number];
122-
}
123-
124-
export const searchHouses = (filters: SearchHousesFilters): Promise<string[]> => {
125-
return httpClient("/v8/postlist/w/search", {
126-
method: 'POST',
127-
body: {
128-
city_ids: [CITY],
129-
source_view: 'FILTER',
130-
disable_recommendation: false,
131-
search_data: {
132-
form_data: {
133-
data: {
134-
// bbox: {
135-
// repeated_float: {
136-
// value: [
137-
// { value: 51.4265289 },
138-
// { value: 35.7938423 },
139-
// { value: 51.4346771 },
140-
// { value: 35.8068733 },
141-
// ],
142-
// },
143-
// },
144-
deed_type: { repeated_string: { value: ['single_page'] } },
145-
category: { str: { value: "apartment-sell" } },
146-
...(typeof filters.district !== 'undefined' && {
147-
districts: { repeated_string: { value: filters.district } },
148-
}),
149-
...(typeof filters.elevator !== 'undefined' && {
150-
elevator: { boolean: { value: filters.elevator } },
151-
}),
152-
...(typeof filters.parking !== 'undefined' && {
153-
parking: { boolean: { value: filters.parking } },
154-
}),
155-
...(typeof filters.balcony !== 'undefined' && {
156-
balcony: { boolean: { value: filters.balcony } },
157-
}),
158-
...(typeof filters.size !== 'undefined' && {
159-
size: {number_range: { minimum: filters.size[0], maximum: filters.size[1] }},
160-
}),
161-
...(typeof filters.price !== 'undefined' && {
162-
price: {number_range: { minimum: filters.price[0], maximum: filters.price[1] }},
163-
}),
164-
},
165-
},
166-
},
167-
},
168-
})
169-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
170-
.then((resp: any) => {
171-
let returnValue: string[] = [];
172-
for (const widget of (resp?.list_widgets ?? [])) {
173-
if (widget?.widget_type !== 'POST_ROW' || !widget?.data?.middle_description_text?.includes('تومان') || !widget?.data?.token) continue;
174-
returnValue = [...returnValue, widget.data.token];
175-
}
176-
return returnValue;
177-
});
178-
}
1+
export * from './divar';

0 commit comments

Comments
 (0)