# HajiBot — Ads Ingestion & Search: Postmortem + Final Implementation Spec (Bama & Divar)

**Version:** 1.0 (Finalization Draft)
**Scope:** ریشه‌یابی باگ‌ها، اصلاح معماری، و پیاده‌سازی قطعی برای «باما» و «دیوار» با UX کامل تلگرام، اسکِراپ پایدار، نرمال‌سازی/اسکورینگ، و مانیتورینگ.

---

## 0) Executive Summary
- **مشکل فعلی:**
  - دیوار: صفر نتیجه با اینکه در صفحه، کارت‌ها حاضرند؛ بخشی به‌دلیل انتخاب CSS اشتباه/وابستگی به کلاس‌های شکننده، بخشی به‌دلیل ایزولیشن/هدرهای نامعتبر/ری‌دایرکت و Lazy-loading.
  - باما: داده‌های برگشتی از MajidAPI در کُد به‌درستی parse نمی‌شود (ساختار/کلیدها) و فیلتر کردن نتایج با UX ربات ناهمخوان است (فیلترهای «۷ روز/شهر» فقط در مسیر جستجو اعمال، درحالی‌که «مشاهده آگهی‌های فعلی» داده دارد).
  - زیرساخت: کمبود ستون‌ها در DB (مثل `scheduled_at`)، سیاست upsert ناقص، pagination/limit نامشخص، و تفاوت مسیرهای «مشاهده vs جستجو» باعث پیام‌های تناقضی می‌شود.
- **راه‌حل قطعی:**
  1) **Divar**: گذار از selectorهای شکننده به سه لایه‌ی resilient parsing:
     - **Layer A (Result List):** انتخاب پایدار کارت‌ها از container ثابت لیست + استخراج href/title/price/mileage/location.
     - **Layer B (Fallback):** اگر لود تنبل/JS، از پیمایش incremental DOM یا HTML snapshot (headless) با هدرهای واقعی مرورگر + دِیلِی رندوم + rotate UA.
     - **Layer C (Details):** باز کردن صفحه‌ی آگهی برای فیلدهای ساخت‌یافته (کارکرد، سال، رنگ، گیربکس، برند/تیپ) و نگاشت آنها به اسکیمای داخلی.
  2) **Bama**: هم‌راستا با مستندات MajidAPI — end-pointهای `latest`, `search`, `details` را بدون فرض کلید اضافی parse کن؛ upsert بر اساس `detail.code`، سازگار با صفحه‌بندی.
  3) **Pipeline واحد:** Raw ➜ Normalize ➜ Score ➜ Opportunities(`reviewed`) ➜ Push Schedule. فیلترهای شهر/۷روز فقط هنگام «جستجو»، ولی «مشاهده آگهی‌های فعلی» آخرین فرصت‌های reviewed را بدون محدودیت شهر/زمان (یا با حد soft) نشان می‌دهد.
  4) **DB & Queue Fixes:** افزودن migrationهای کمبود (`scheduled_at`)، ایندکس‌های کلیدی، سیاست upsert بدون حذف تاریخچه، و صف‌ها/timeout/بازپخش ایمن.
  5) **تلگرام UX نهایی:** دو مسیر شفاف: «مشاهده آگهی‌های فعلی» vs «پیدا کردن آگهی جدید». جستجو scoped به شهر کاربر + ۷ روز اخیر + برند/مدل انتخابی. Session/Rate-limit per-user.

---

## 1) Postmortem — علل ریشه‌ای

### 1.1 دیوار: صفر نتیجه
- **Selectors شکننده:** استفاده از کلاس‌هایی مانند `kt-post-card__body` بدون درنظرگرفتن لایه‌ی container و تغییرات پویا.
- **Lazy Loading/SSR:** لیست‌ها در containerهای تکرارشونده‌ی `post-list__items-container-*` تجمیع می‌شوند؛ صرفاً گرفتن اولین `.kt-post-card__body` کافی نیست.
- **Anti-Scraping:** نیاز به هدرهای واقعی (User-Agent، Accept-Language، Referer)، وقفه‌های تصادفی، و چرخش IP/Proxy.
- **Partial HTML:** برخی CDNها نسخه‌ی خلاصه‌شده‌ی thumbnail را ارائه می‌دهند؛ برای متادیتای کامل باید وارد صفحه‌ی جزئیات شد.

### 1.2 باما: mismatch ساختار داده و فیلترهای UX
- **MajidAPI** داده را به‌صورت آرایه‌ی آیتم‌ها بازمی‌گرداند؛ نباید انتظار `result.data` داشت. Policy upsert/parse باید بر `detail.code` و `detail.url` سوار شود.
- **فیلترینگ ناهمخوان:** مسیر «جستجو» فیلتر شهر+۷روز را اعمال می‌کند؛ «مشاهده فعلی» نه. نتیجه: «عدم وجود آگهی» در جستجو، درحالی‌که «مشاهده فعلی» لیست دارد.

### 1.3 دیتابیس و صف
- **ستون‌های ناقص:** نبود `scheduled_at` برای زمان‌بندی push.
- **سیاست پاکسازی:** حذف آگهی‌های قبلی به‌جای upsert نسخه‌دار؛ ایجاد توهم «فقط 10 آگهی باقی است».
- **ایندکس‌ها/کلیدها:** کمبود ایندکس بر `source`, `hash`, `brand`, `model`, `city_id`, `fetched_at` باعث کندی/timeouts.

---

## 2) Divar — انتخاب مطمئن CSS Selectors و URL Strategy

### 2.1 الگوی URL مطمئن
- جستجوی دامنه‌ی خودرو: `/s/{city}/car`
- برند: `/s/{city}/car/{brand}` (مثال: `peugeot`)
- مدل: `/s/{city}/car/{brand}/{model}` (مثال: `peugeot/206`)
- تیپ: `/s/{city}/car/{brand}/{model}/{trim}` (مثال: `peugeot/206/5`)
- مدل‌های تک‌برند مستقل: `/s/{city}/car/{model}` (مثال: `tiba`, `tiba/hatchback`, `saina`, `quick`, `pride`)

**Policy ساخت URL:**
1) اگر `brand` و `model` موجود: استفاده از مسیر سلسله‌مراتبی.
2) اگر فقط `model` شناخته‌شده‌ی مستقل: مسیر کوتاه (مثل `tiba`).
3) برای جستجوی کلمه‌ای (fallback): `?q=...` روی `/s/{city}/car`.

### 2.2 کارت لیست (Result List)
- **Container پایدار:** `.post-list__items-container-` (prefix ثابت + suffix پویای hashing). داخل هر container، کارت‌ها با `.kt-post-card` قرار دارند.
- **Anchor:** `a.kt-post-card__action` ⇒ `href` (slug آگهی؛ به `https://divar.ir` پیشوند بزن).
- **Title:** `.kt-post-card__title`
- **Descriptions (ordered):** اولین `.kt-post-card__description` معمولاً کارکرد/کیلومتر، دومی قیمت.
- **Bottom meta:** `.kt-post-card__bottom` شامل badge/محله/نمایشگاه.
- **Thumbnail:** `.kt-post-card-thumbnail img[alt][src]` (الزاماً نیازی نیست، اما برای completeness ذخیره کن).

**Selector نهایی لیست:**
```css
.post-list__items-container- .kt-post-card a.kt-post-card__action
```
سپس از childهای آن (title/desc/bottom) استخراج فیلدها.

### 2.3 صفحه جزئیات (Details Page)
- **Table info:** جدول سه‌ستونه با headers «کارکرد / مدل (سال تولید) / رنگ» ⇒ یک ردیف داده.
- **Brand & Trim:** ردیف «برند و تیپ» → لینک داخل `.kt-unexpandable-row__action` با متن فارسی (مثل «پژو 207i دنده‌ای»).
- **گیربکس/سوخت/قیمت پایه:** ردیف‌های جداگانه با `.kt-base-row__title` و `.kt-unexpandable-row__value`.

**Strategy:**
1) **Step1 (List)**: فقط `href`, `title`, `mileage?`, `price?`, `location?` جمع‌آوری کن.
2) **Step2 (Detail)**: برای هر `href`، صفحه‌ی آگهی را واکشی و فیلدهای ساختاری را parse و normalize.

### 2.4 ضدبن و پایداری
- **Headers:** UA واقعی دسکتاپ، Accept-Language=fa-IR,fa;q=0.9، Referer از صفحه‌ی بالادستی.
- **Throttle:** delay تصادفی 0.8–2.2s بین درخواست‌ها؛ backoff تصاعدی روی خطاهای 403/429/5xx.
- **Retry budget:** حداکثر 3 بار با jitter؛ ثبت reason در log.
- **IP/Proxy:** امکان پلن چرخش IP در config (اختیاری).
- **Timeouts:** 12–15s per request، کل job با time budget.

---

## 3) Bama — هم‌راستاسازی با MajidAPI

### 3.1 Endpoints
- `.../bama?action=latest&page={n}` → لیست آگهی‌ها (آرایه‌ی آیتم‌ها)
- `.../bama?action=search&s={term}&page={n}` → جستجو (آرایه‌ی آیتم‌ها)
- `.../bama?action=details&code={code}` → جزئیات آگهی

**نکات Parse:**
- سطح ریشه **آرایه** است، نه `{"data": ...}`. هر آیتم دارای `type`, `detail`, `price`, `images`, ...
- کلید یکتا: `detail.code`. URL کامل: base + `detail.url`.

### 3.2 Upsert Policy
- کلید طبیعی: (`source='bama'`, `external_code=detail.code`).
- برای هر آیتم:
  - hash محتوا (`sha1(source|code|price|modified_date)`) برای تشخیص تغییر.
  - اگر hash تغییر کرد → نسخه‌ی جدید در `ads_raw` + بروزرسانی آخرین نسخه‌ی normalized.
  - **عدم حذف نسخه‌های قبلی** (history حفظ شود).

### 3.3 Pagination & Backfill
- صفحه 1..N تا خالی شدن یا ceiling (مثلاً 5 صفحه/اجرا).
- توقف زودهنگام اگر همه‌ی کدهای دیده‌شده تکراری شد (early break).

---

## 4) Pipeline یکپارچه

1) **Collect (Raw)**
   - Sources: `bama`, `divar`.
   - ذخیره‌ی خام با `source`, `payload_json`, `fetched_at`.
2) **Normalize**
   - نگاشت فیلدهای مشترک: `brand`, `model`, `trim`, `year`, `mileage_km`, `price_irt`, `city`, `gearbox`, `fuel`, `color`, `source_url`, `external_code`.
   - نرمال‌ساز فارسی→استاندارد (ارقام فارسی، «میلیون/هزار»، حذف کاما و فاصله مجازی).
3) **Score**
   - مدل امتیازدهی ≥ 0.6 واجد شرایط.
4) **Opportunities**
   - ساخت/به‌روزرسانی فرصت با `status=reviewed` پس از گذر از آستانه.
5) **Push Schedule**
   - نیازمند ستون `scheduled_at` و ایندکس‌ها؛ ترایگر براساس `score`, `user prefs`.

---

## 5) Telegram UX — نسخه نهایی

- **منوی اصلی:**
  - «🔍 آگهی به‌قیمت»
- **درون آگهی به‌قیمت:**
  - «👀 مشاهده آگهی‌های فعلی» → نمایش آخرین *N* فرصت `reviewed` (مرتب بر `updated_at DESC`, N=10–20). بدون فیلتر شهر/۷روز (یا Soft limit تنظیم‌پذیر).
  - «🧭 پیدا کردن آگهی جدید» →
    1) **انتخاب خودرو (منوی شیشه‌ای)**
    2) اعمال فیلتر **شهر کاربر** (الزامی) + **۷ روز اخیر** + **برند/مدل**
    3) اجرای scrape + normalize + score + build opportunities tagged with `search_session_id`, `search_user_id`.
  - **تفکیک session per-user:** نتایج جستجوی هر کاربر فقط برای خود او برچسب می‌خورند.

**نکات عملی:**
- اگر شهر کاربر ست نیست → onboarding سریع.
- اگر پس از اسکرپ نتیجه 0 بود → پیام راهنما با دلایل (آستانه امتیاز/فیلدهای ناقص/کمبود آگهی در ۷ روز اخیر) + پیشنهاد کلید «مشاهده آگهی‌های فعلی».

---

## 6) Data Model & Migrations

### 6.1 `ads_raw`
- `id` PK, `source` (index), `external_code` (nullable برای دیوار اگر نداشت)، `payload_json` LONGTEXT, `hash`, `fetched_at` (index), timestamps.
- ایندکس ترکیبی (`source`, `external_code`).

### 6.2 `ads_normalized`
- فیلدهای استاندارد شده + `raw_id` FK + `source_url` + `city_id` + `brand_key/model_key/trim_key` (اختیاری) + `price_norm`, `year_num`, `mileage_num` (INT/DECIMAL).

### 6.3 `opportunities`
- `normalized_id`, `score`, `status` ENUM(`draft`,`reviewed`,`scheduled`,`sent`,`archived`), `search_user_id`, `search_session_id`, `search_created_at`, `scheduled_at` (NEW), ایندکس‌ها روی (`status`, `score`, `scheduled_at`).

### 6.4 `user_search_sessions`
- `id`, `user_id`, `car_key`, `city_id`, `filters_json`, `created_at`.

**Migrations Needed:**
- Add `scheduled_at` to `opportunities`.
- Add `external_code`, `hash` to `ads_raw` + ایندکس‌ها.
- Create `user_search_sessions`.

---

## 7) Implementation Notes — Jobs & Commands

### 7.1 DivarScrapeJob
- ورودی: `{url, city_id?, brand?, model?, trim?, session_id?, user_id?}`
- مراحل: (A) Fetch list → parse anchors → (B) For each href: fetch details → normalize → upsert raw+normalized.
- خط‌مشی شکست: `tries=3`, `backoff=300s`, لاگ structured.

### 7.2 BamaPullJobs (via MajidAPI)
- `BamaLatestJob(page)`، `BamaSearchJob(term,page)`، `BamaDetailsJob(code)`
- احترام به صفحه‌بندی و توقف هوشمند.

### 7.3 ScoringJob
- محاسبه score و به‌روزرسانی/ایجاد opportunity (`status=reviewed` اگر ≥0.6).

### 7.4 PushSchedulerJob
- انتخاب `status=scheduled` با `scheduled_at <= now()` و `score >= threshold`.
- **پیش‌نیاز:** ستون `scheduled_at` + ایندکس.

### 7.5 Console Commands
- `ads:scrape-cars {carKey} [--city-id=] [--sources=bama,divar] [--session-id=] [--user-id=] [--force]`
- `ads:run-full` → Collect→Normalize→Score→Schedule
- `ads:status` → آمار/سلامت سیستم

---

## 8) Parsing & Normalization Details

### 8.1 قیمت
- ورودی‌ها: «۸۰۰,۰۰۰,۰۰۰ تومان»، «۱,۹۸۵ میلیون تومان»، «توافقی»، «ندارد».
- تبدیل: حذف ارقام فارسی/کاما → تشخیص «میلیون/هزار» → تبدیل به ریال/تومان واحدی (تصمیم: **تومان** به عنوان INT).

### 8.2 کارکرد (کیلومتر)
- «۰ کیلومتر»، «۵۰,۰۰۰ کیلومتر» → INT.

### 8.3 سال/مدل
- ارقام فارسی به INT (۱۳۸۵–۱۴۰۴). برای باما `year` حاضر است، دیوار از جدول جزئیات گرفته شود.

### 8.4 برند/مدل/تیپ
- باما: `detail.brand`, `detail.title/subtitle/trim`.
- دیوار: از URL سلسله‌مراتبی + متن «برند و تیپ» در صفحه جزئیات.

### 8.5 شهر/محله
- دیوار: متن «... در {محله}»؛ نگاشت به city_id با جدول محله‌ها (optional). حداقل `city='تهران'` براساس مسیر URL.
- باما: `location` مانند «تهران / پونک».

---

## 9) Anti Fragility — Monitoring & Alerts
- **Structured Logs:** برای هر منبع: تعداد کارت در لیست، تعداد جزئیات موفق، تعداد خطاهای 403/429/5xx، میانگین زمان.
- **Metrics**: `ads_raw_collected`, `ads_normalized_new`, `opportunities_reviewed`, `zero_result_searches`.
- **Alerts:** اگر 3 بار پیاپی کارت=0 ولی HTML200 → هشدار «selector drift».

---

## 10) Test Plan (Deterministic)
1) **Bama Latest/Search** (صفحه 1..2): >0 آیتم، `detail.code` غیر تکراری، upsert بدون حذف.
2) **Divar**
   - URLهای: brand-only, brand/model, brand/model/trim, model-only (tiba/quick/saina/pride).
   - باید حداقل 5 href استخراج، و ≥80% با جزئیات موفق parse شوند.
3) **Normalization**: قیمت/کیلومتر/سال/brand/model/trim پر شوند (coverage ≥90%).
4) **Scoring**: آستانه 0.6؛ 1–3 مورد reviewed در دیتاست نمونه.
5) **Telegram UX**: «مشاهده فعلی» vs «جستجو» رفتار متمایز؛ session tagging per-user.
6) **DB migrations**: ستون `scheduled_at` وجود دارد؛ `ads_raw` تاریخچه را حفظ می‌کند.

---

## 11) Common Failure Modes & Fixes
- **0 کارت دیوار:** UA/Headers/Selector را بررسی کن؛ در fallback Headless snapshot بگیر.
- **باما count=0 در کُد ولی curl>0:** فرض نکن آرایه زیر کلید `data` است؛ مستقیماً آرایه را parse کن؛ null-safety و try/catch JSON.
- **فقط 10 رکورد:** limit/pagination را در Jobها افزایش بده و **پاکسازی destructive** را غیرفعال کن.
- **PushScheduler خطا:** migration `scheduled_at` + ایندکس.

---

## 12) Env & Config Checklist
- `ADS_MAJIDAPI_URL=https://api.majidapi.ir`
- `ADS_MAJIDAPI_KEY=bbd1j9otcgiyduy:QBNCNtMweeKhsuSUEaBm`
- `ADS_SOURCES=bama,divar`
- `ADS_CITY_DEFAULT=tehran`
- **Scraper**:
  - `SCRAPER_USER_AGENT=Mozilla/5.0 ...`
  - `SCRAPER_ACCEPT_LANGUAGE=fa-IR,fa;q=0.9`
  - اختیاری: `SCRAPER_PROXY=...`

---

## 13) «Cursor» — یادداشت‌های همکاری برای چت جدید
- **صراحت ساختار MajidAPI:** آرایه‌ی آیتم‌ها؛ انتظار `data` یا `result` نداشته باشد.
- **تفکیک دو مسیر تلگرام:** «مشاهده فعلی» (بدون فیلتر سخت) vs «جستجو» (شهر + ۷روز + carKey).
- **Divar selectors:** به جای کلاس‌های داخلی شکننده، از container `.post-list__items-container-` + `.kt-post-card` استفاده کند؛ سپس صفحه‌ی جزئیات برای فیلدهای محوری.
- **DB migrations:** ایجاد/تکمیل ستون‌های الزامی مثل `scheduled_at`؛ upsert بدون delete.
- **صف و ایزولیشن:** tries/backoff/timeout و هدرهای واقعی؛ لاگ متریک‌محور.
- **Pagination:** در باما و دیوار.
- **Session per-user:** نتایج جستجو با `search_session_id` و `search_user_id` تگ شوند.

---

## 14) شبه‌کدهای کلیدی (خلاصه)

**Parse List (Divar):**
```php
$crawler->filter('[class^="post-list__items-container-"] .kt-post-card a.kt-post-card__action')->each(function($a){
  $href = $a->attr('href');
  $title = trim($a->filter('.kt-post-card__title')->text(''));
  $descs = $a->filter('.kt-post-card__description')->each(fn($n)=>trim($n->text('')));
  $mileage = $descs[0] ?? null; $price = $descs[1] ?? null;
  // queue details fetch
});
```

**Normalize Price (fa → int تومان):**
```php
function faDigits($s){return strtr($s,'۰۱۲۳۴۵۶۷۸۹','0123456789');}
function parsePrice($s){
  $s = faDigits($s);
  if(str_contains($s,'میلیون')) return (int)round((float)preg_replace('/[^0-9.]/','',$s)*1_000_000);
  $n = preg_replace('/[^0-9]/','',$s);
  return $n? (int)$n : null; // توافقی → null
}
```

**Upsert Raw:**
```php
$hash = sha1($source.'|'.$code.'|'.$price.'|'.$modified);
AdsRaw::updateOrCreate([
  'source'=>$source,'external_code'=>$code
],[
  'payload_json'=>json_encode($payload,JSON_UNESCAPED_UNICODE),
  'hash'=>$hash,'fetched_at'=>now()
]);
```

---

## 15) Roadmap کوتاه اجرای اصلاحات
1) Migrations (scheduled_at, ایندکس‌ها, history-safe upsert).
2) اصلاح MajidAPI client (آرایه سطح ریشه + pagination + upsert).
3) بازنویسی DivarScrapeJob بر اساس selectors/flow فوق + anti-ban.
4) هم‌راستاسازی UX تلگرام و فیلترها + session tagging.
5) مانیتورینگ/متریک + Alert روی selector drift.
6) تست یکپارچه (گام‌های بند 10).

---

**پایان سند** ✅

