refactor: clean up frontend dependencies and remove unused files
- Removed several unused dependencies from frontend package.json including @tabler/icons-react, @tanstack/react-query, cobe, dotted-map, and jalaali-js. - Deleted the hero-bg.original.mp4 video file as it was no longer needed. - Removed live_dashboard.html and live_prices.js files, which were part of the old dashboard implementation. - Eliminated the scraper.py script and related price fetching logic from the server, streamlining the backend. - Removed the rtk.exe binary file as it was not required.
This commit is contained in:
parent
6a3b0db2ec
commit
406717be13
|
|
@ -42,5 +42,5 @@ brand_pages/
|
|||
*.old
|
||||
*.docx
|
||||
|
||||
# State file written by scripts/fetch-competitor-news.mjs
|
||||
scripts/news-history.json
|
||||
# State file written by frontend/scripts/fetch-competitor-news.mjs
|
||||
frontend/scripts/news-history.json
|
||||
|
|
|
|||
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -6,15 +6,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
Two independent apps in one repo:
|
||||
|
||||
1. **Frontend** (repo root) — a Vite + React 19 + TypeScript marketing/content site for "اندیشکده فولاد آینده" (a steel-industry think tank). Persian-first, RTL, bilingual (fa/en). Deploys as a static build via Docker to Hugging Face Spaces on port 7860.
|
||||
2. **Admin panel** (`panel/`) — a **standalone** Node/Express + SQLite app with its own `package.json` and `node_modules`. Manages content the site is meant to publish. **The frontend does NOT consume the panel yet** — every section renders hardcoded mock data. Wiring them is a deliberate future step.
|
||||
1. **Frontend** (`frontend/`) — a Vite + React 19 + TypeScript marketing/content site for "اندیشکده فولاد آینده" (a steel-industry think tank). Persian-first, RTL, bilingual (fa/en). Deploys as a static build via Docker to Hugging Face Spaces on port 7860.
|
||||
2. **Admin panel** (`panel/`) — a **standalone** Node/Express + Postgres app with its own `package.json` and `node_modules`, that is BOTH the backend API and the CMS admin UI (served from `panel/public/`). Manages content the site publishes.
|
||||
|
||||
These two have **no shared build or dependency tree**. Treat `panel/` as a separate project.
|
||||
These two have **no shared build or dependency tree**. `frontend/` and `panel/` are sibling folders at the repo root — neither references the other at build time (the frontend reads the panel only at runtime via `VITE_PANEL_API`).
|
||||
|
||||
## Commands
|
||||
|
||||
Frontend (run from repo root):
|
||||
Frontend (run from `frontend/`):
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev # Vite dev server (HMR)
|
||||
npm run build # tsc -b && vite build (type-checks then bundles)
|
||||
npm run lint # eslint .
|
||||
|
|
@ -30,12 +31,8 @@ npm install # first time only
|
|||
npm run seed # create the admin user from .env (ADMIN_USERNAME/PASSWORD)
|
||||
npm start # node server.js → http://localhost:3001
|
||||
npm run dev # node --watch server.js
|
||||
npm rebuild better-sqlite3 # REQUIRED if Node's ABI changed — native addon, fails to load otherwise
|
||||
```
|
||||
|
||||
## Critical build gotcha
|
||||
|
||||
`panel/` lives inside the frontend's project root, so **Vite's dependency scanner reads `panel/package.json`**. If that file (or anything Vite scans) has unresolved git merge-conflict markers (`<<<<<<<`), `npm run dev`/`build` fails with a cryptic `JSONError ... expected value at line 1`. Keep `panel/package.json` valid JSON.
|
||||
The panel uses Postgres (`pg` Pool over `DATABASE_URL`) behind a better-sqlite3-style `db.prepare().get/all/run()` facade — every call is async and must be awaited.
|
||||
|
||||
## Frontend architecture
|
||||
|
||||
|
|
|
|||
373
README_FA.md
373
README_FA.md
|
|
@ -1,249 +1,210 @@
|
|||
# راهنمای جامع پروژه اندیشکده فولاد آینده (Andishkade Foolad)
|
||||
|
||||
این سند راهنمای فنی و معماری پروژه **اندیشکده فولاد آینده** است که شامل جزئیات ساختار فرانتاند، پنل مدیریت بکاند، پایگاه داده، پشته فناوریها و نحوه راهاندازی و توسعه پروژه میباشد.
|
||||
این سند، راهنمای فنی و معماریِ پروژه **اندیشکده فولاد آینده** است — یک پلتفرم تحلیلیِ صنعت فولاد، شاملِ یک وبسایتِ محتوایی و یک پنلِ مدیریت/بکاند. در این سند ساختار پوشهها، پشتهی فناوری، پایگاه داده، احراز هویت، قابلیتها و نحوهی استقرار توضیح داده شده است.
|
||||
|
||||
---
|
||||
|
||||
## فهرست مطالب
|
||||
- [۱. ساختار کلی پروژه](#۱-ساختار-کلی-پروژه)
|
||||
- [۲. فرانتاند (Frontend)](#۲-فرانتاند-frontend)
|
||||
- [پشته فناوری فرانتاند](#پشته-فناوری-فرانتاند)
|
||||
- [ویژگیها و قابلیتهای بصری فرانتاند](#ویژگیها-و-قابلیتهای-بصری-فرانتاند)
|
||||
- [دستورات بخش فرانتاند](#دستورات-بخش-فرانتاند)
|
||||
- [۳. پنل مدیریت (Backend / Admin Panel)](#۳-پنل-مدیریت-backend--admin-panel)
|
||||
- [پشته فناوری بکاند](#پشته-فناوری-بکاند)
|
||||
- [ساختار و معماری پایگاه داده](#ساختار-و-معماری-پایگاه-داده)
|
||||
- [سرویسهای ویژه پنل مدیریت](#سرویسهای-ویژه-پنل-مدیریت)
|
||||
- [دستورات بخش پنل مدیریت](#دستورات-بخش-پنل-مدیریت)
|
||||
- [لیست APIهای پنل مدیریت](#لیست-apiهای-پنل-مدیریت)
|
||||
- [۴. نحوه اتصال فرانتاند به پنل مدیریت (Wiring)](#۴-نحوه-اتصال-فرانتاند-به-پنل-مدیریت-wiring)
|
||||
- [۵. استقرار و دگرگونیها (Deployment)](#۵-استقرار-و-دگرگونیها-deployment)
|
||||
- [۲. فرانتاند (`frontend/`)](#۲-فرانتاند-frontend)
|
||||
- [۳. پنل مدیریت و بکاند (`panel/`)](#۳-پنل-مدیریت-و-بکاند-panel)
|
||||
- [۴. پایگاه داده](#۴-پایگاه-داده)
|
||||
- [۵. احراز هویت و اعضا](#۵-احراز-هویت-و-اعضا)
|
||||
- [۶. اتصال فرانتاند به پنل (Wiring)](#۶-اتصال-فرانتاند-به-پنل-wiring)
|
||||
- [۷. فهرست APIها](#۷-فهرست-apiها)
|
||||
- [۸. استقرار (Deployment)](#۸-استقرار-deployment)
|
||||
|
||||
---
|
||||
|
||||
## ۱. ساختار کلی پروژه
|
||||
|
||||
پروژه به صورت یک ساختار تکمخزنی (Monorepo) طراحی شده است که شامل دو اپلیکیشن مستقل و بدون وابستگی اشتراکی در زمان ساخت (Build) است:
|
||||
|
||||
1. **فرانتاند (Frontend - ریشه پروژه)**: یک وبسایت بازاریابی و محتوایی تعاملی، مدرن و با اولویت زبان فارسی (Persian-first) و دو زبانه (فارسی / انگلیسی) که با React 19 و Vite توسعه یافته است.
|
||||
2. **پنل مدیریت (Admin Panel - پوشه `panel/`)**: یک وباپلیکیشن مستقل SPA با معماری Node.js/Express و پایگاه داده PostgreSQL که محتوای پویا وبسایت را مدیریت میکند.
|
||||
پروژه یک **مونوریپو** با دو اپلیکیشنِ مستقل و کنارهم (sibling) است که **هیچ build یا وابستگیِ مشترکی** ندارند؛ فرانتاند فقط در زمانِ اجرا (runtime) و از طریق متغیر `VITE_PANEL_API` با پنل حرف میزند.
|
||||
|
||||
```
|
||||
andishkade-foolad/
|
||||
├── panel/ # پروژه بکاند و پنل مدیریت
|
||||
│ ├── public/ # فایلهای استاتیک و رابط کاربری پنل (Vanilla JS)
|
||||
│ ├── uploads/ # فایلهای آپلود شده محلی (در صورت عدم استفاده از S3)
|
||||
│ ├── server.js # سرور اصلی Express
|
||||
│ ├── db.js # پیکربندی پایگاه داده PostgreSQL و کوئریها
|
||||
│ └── package.json # وابستگیهای بکاند
|
||||
├── src/ # کدهای منبع فرانتاند (React 19)
|
||||
│ ├── app/ # مسیریابی و قالب اصلی (Layout)
|
||||
│ ├── components/ # کامپوننتهای اشتراکی و UI
|
||||
│ ├── context/ # کانتکستهای ریاکت (مانند زبان سیستم)
|
||||
│ ├── data/ # دادههای اولیه و ماک (Mock Data)
|
||||
│ └── pages/ # صفحات مختلف وبسایت
|
||||
├── package.json # وابستگیهای فرانتاند
|
||||
├── Dockerfile # داکرفایل اجرای فرانتاند به عنوان سرور استاتیک Nginx
|
||||
└── docker-compose.yml # تنظیمات داکر کامپوز برای اجرای لوکال فرانتاند
|
||||
├── frontend/ # وبسایت عمومی — Vite + React 19 + TypeScript
|
||||
│ ├── src/
|
||||
│ │ ├── app/ # مسیریابی (router) و قالب اصلی (RootLayout)
|
||||
│ │ ├── components/ # کامپوننتهای اشتراکی و UI
|
||||
│ │ ├── context/ # کانتکستها: زبان (LangContext) و احراز هویت (AuthContext)
|
||||
│ │ ├── content/ data/ # دادههای اولیه/ماک + ماژولهای bootstrap که از پنل واکشی میکنند
|
||||
│ │ └── pages/ # صفحات سایت
|
||||
│ ├── public/ # داراییهای استاتیک، فونتها، manifest و service worker (PWA)
|
||||
│ ├── index.html
|
||||
│ ├── vite.config.ts tsconfig*.json eslint.config.js
|
||||
│ ├── Dockerfile nginx.conf liara.json # استقرار بهصورت سرور استاتیک Nginx روی پورت ۷۸۶۰
|
||||
│ └── package.json
|
||||
├── panel/ # بکاند (Express API) + پنلِ مدیریت (CMS) — یک اپ واحد
|
||||
│ ├── server.js # سرور و همهی Routeهای API
|
||||
│ ├── db.js # اتصال PostgreSQL + facade شبیه better-sqlite3 + مپ ردیفها
|
||||
│ ├── public/ # رابط کاربری پنل (SPA با Vanilla JS، بدون build)
|
||||
│ ├── uploads/ # آپلودِ محلی (در صورت نبودِ S3)
|
||||
│ ├── liara.json
|
||||
│ └── package.json
|
||||
├── docker-compose.yml # اجرای لوکالِ فرانت (context: ./frontend)
|
||||
├── CLAUDE.md README.md README_FA.md SECURITY.md design-system.md
|
||||
```
|
||||
|
||||
> **نکته:** پنل **هم بکاندِ API است و هم CMS** (رابطِ مدیریت را از `panel/public/` سرو میکند). این دو بههم گره خوردهاند و یک اپلیکیشنِ واحدند.
|
||||
|
||||
---
|
||||
|
||||
## ۲. فرانتاند (`frontend/`)
|
||||
|
||||
پورتالِ محتواییِ فارسیمحور (Persian-first)، دوزبانه (fa/en) و RTL با انیمیشنهای غنی.
|
||||
|
||||
### پشتهی فناوری
|
||||
- **هسته:** React 19 + TypeScript.
|
||||
- **بیلد:** Vite (HMR در توسعه، خروجی بهینه در پروداکشن). اعتبارسنجیِ سریع: `npx tsc -b --noEmit`.
|
||||
- **استایل:** عمدتاً `style` اینلاین + کلاسهای Tailwind CSS v4 برای واکنشگرایی (`max-md:` …) + متغیرهای CSS در `src/index.css`. رنگِ سازمانی: طلایی `#CD9E53` و سورمهای `#032340` (در بسیاری فایلها بهصورت `const GOLD`/`const NAVY` هاردکد شدهاند).
|
||||
- **مسیریابی:** React Router DOM v7 (`createBrowserRouter`). `RootLayout` همهی صفحات را با Header/Footer و موتورِ اسکرول میپیچد.
|
||||
- **انیمیشن و اسکرول:**
|
||||
- **GSAP + ScrollTrigger:** انیمیشنهای مبتنی بر اسکرول.
|
||||
- **Lenis:** اسکرولِ نرم هماهنگ با تیکرِ GSAP — **فقط دسکتاپ** (روی لمسی به مومنتومِ بومیِ سیستمعامل واگذار میشود تا لگ نشود).
|
||||
- **Framer Motion:** میکروانیمیشنها، پاپآپها و ترنزیشنها.
|
||||
- **سهبعدی و نقشه:** Three.js + React Three Fiber، `three-globe` و `react-globe.gl` برای کرهی زمینِ تعاملی.
|
||||
- **نمودار:** ApexCharts (صفحهی Market).
|
||||
- **مدیریت وضعیت:** صرفاً **React Context** (زبان و احراز هویت). بدون کتابخانهی state خارجی.
|
||||
|
||||
### قابلیتهای کلیدی
|
||||
1. **دوزبانه/RTL:** هوک `useLang()` (`src/context/LangContext`) مقدار `{ lang, toggle }` میدهد؛ کامپوننتها دیکشنریِ درونخطیِ `{ fa, en }` دارند و جهتِ صفحه با `dir` تنظیم میشود.
|
||||
2. **اعدادِ فارسی:** یک «بومیسازِ ارقام» در `RootLayout` همهی ارقامِ لاتینِ نمایشدادهشده را در حالتِ فارسی به فارسی تبدیل میکند (با MutationObserver برای محتوای دیرلودشده).
|
||||
3. **تاریخ شمسی:** بدون کتابخانهی تاریخ — مستقیماً با `Intl.DateTimeFormat('fa-IR-u-ca-persian', …)`.
|
||||
4. **PWA و موبایل:** manifest + service worker (نصبپذیری + کشِ آفلاین)؛ صفحه روی موبایل **غیرقابلِ زوم** است (viewport + بلوکِ pinch/double-tap برای iOS).
|
||||
5. **جستجوی سراسری:** آیکونِ سرچِ هدر → صفحهی `/search` که بهصورت کلاینتساید روی همهی محتوا (گزارشها، مطالب، «در یک نگاه»، رادار آینده، ویدیوکست/پادکست، رویدادها) میگردد و به صفحهی جزئیاتِ هر مورد لینک میدهد.
|
||||
|
||||
### دستورات (در پوشهی `frontend/`)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # سرورِ توسعهی Vite (HMR)
|
||||
npx tsc -b --noEmit # فقط type-check (سریعترین حلقهی اعتبارسنجی)
|
||||
npm run lint # ESLint
|
||||
npm run build # tsc -b && vite build → خروجی در dist/
|
||||
npm run preview # پیشنمایشِ بیلدِ پروداکشن
|
||||
```
|
||||
برای دیدنِ دادهٔ واقعیِ پنل در حالتِ توسعه:
|
||||
```bash
|
||||
VITE_PANEL_API=https://cms-steelforesight.liara.run npm run dev
|
||||
```
|
||||
> هیچ فریمورکِ تستی پیکربندی نشده؛ «تأیید» یعنی `tsc` تمیز + بازدیدِ سرورِ در حالِ اجرا.
|
||||
|
||||
---
|
||||
|
||||
## ۳. پنل مدیریت و بکاند (`panel/`)
|
||||
|
||||
سرورِ Express که هم API میدهد و هم رابطِ مدیریت (CMS) را سرو میکند.
|
||||
|
||||
### پشتهی فناوری
|
||||
- **سرور:** Express 4 روی Node.js.
|
||||
- **پایگاه داده:** PostgreSQL (لیارا) با ماژول `pg` (Pool روی `DATABASE_URL`). در `db.js` یک facadeِ شبیهِ `better-sqlite3` ساخته شده: `db.prepare(sql).get/all/run(...)` — **همه async و باید await شوند**. جداول و ایندکسها در زمانِ استارتآپ بهصورت idempotent ساخته/مهاجرت میشوند.
|
||||
- **امنیت/احراز هویت:** `bcryptjs` (هشِ گذرواژه)، `jsonwebtoken` (کوکیِ HttpOnly با `Secure`/`SameSite` پشتِ TLS)، `helmet` (هدر/CSP)، `express-rate-limit` (محدودسازیِ ورود و فرمِ تماس). CORS با `ALLOWED_ORIGINS` کنترل میشود (و در توسعه هر `localhost`).
|
||||
- **آپلود و تصویر:** `multer` (بافرِ حافظه) + `sharp` (چرخشِ EXIF، ریسایز تا ۲۰۰۰px و خروجیِ **WebP**). `pdf-parse` برای استخراجِ متنِ PDF.
|
||||
- **ذخیرهسازی:** **S3-compatible** (object storage لیارا) در صورتِ تنظیمِ متغیرها؛ وگرنه fallback به دیسکِ محلی `panel/uploads/`.
|
||||
- **هوش مصنوعی:** SDKهای **OpenAI** و **Anthropic (Claude)** برای خلاصهسازی و استخراجِ دادهٔ کلیدی از اسناد.
|
||||
- **رابطِ مدیریت:** SPA با **Vanilla JS** در `panel/public/` (بدون build). تبها در دو گروه: **محتوا** (در یک نگاه، رادار آینده، ویدیوکست/پادکست، رویدادها، بنرها) و **کاربران** (کاربران سایت، خبرنامه).
|
||||
|
||||
### دستورات (در پوشهی `panel/`)
|
||||
```bash
|
||||
cd panel
|
||||
npm install
|
||||
npm run seed # ساختِ ادمینِ اولیه از روی .env (ADMIN_USERNAME/PASSWORD)
|
||||
npm start # node server.js → http://localhost:3001
|
||||
npm run dev # node --watch server.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ۲. فرانتاند (Frontend)
|
||||
## ۴. پایگاه داده
|
||||
|
||||
بخش کاربری وبسایت اندیشکده فولاد آینده یک پورتال محتوایی بسیار پیشرفته و پویا با انیمیشنهای غنی است.
|
||||
جداولِ اصلی (PostgreSQL):
|
||||
|
||||
### پشته فناوری فرانتاند
|
||||
| جدول | کاربرد |
|
||||
| :--- | :--- |
|
||||
| `users` | ادمینهای پنل با نقشِ `admin` یا `owner` |
|
||||
| `articles` | مقالات/گزارشها (مدلِ منعطف؛ با `category`/`type` تفکیک میشود) |
|
||||
| `radar_items` | آیتمهای «در یک نگاه» در ۵ دسته: market/tech/commodity/geo/energy |
|
||||
| `radar_pages` | صفحاتِ «رادار آینده» (مقالهی بلوکیِ هر زیرمنو) |
|
||||
| `risk_signals` | سیگنالهای ریسک با سطوحِ critical…opportunity |
|
||||
| `events` | رویدادهای تقویم |
|
||||
| `team_members` | اعضای تیم/خبرگان |
|
||||
| `market_prices`, `market_chart_points` | قیمتها و نمودارهای صفحهی Market (**دستی، ادیتشونده در پنل**) |
|
||||
| `banners`, `integrations`, `institute_stats`, `vision_items`, `advisory_board`, `plans`, `factory_reports` | بخشهای مختلفِ محتواییِ سایت |
|
||||
| `members` | اعضای ثبتنامیِ سایت (موبایل/OTP) + `last_login`, `is_active` |
|
||||
| `purchases` | خریدِ گزارشهای غیررایگانِ اعضا |
|
||||
| `member_activity` | لاگِ فعالیتِ اعضا (بازدیدِ صفحه + کلیک) |
|
||||
| `subscribers` | ایمیلهای عضویت در خبرنامه (فرمِ فوتر) |
|
||||
|
||||
* **هسته اصلی:** React 19 همراه با TypeScript برای ایمنی کدها و مدلسازی دادهها.
|
||||
* **سیستم بیلد:** Vite جهت بارگذاری سریع در زمان توسعه (HMR) و خروجی بهینه در زمان پروداکشن.
|
||||
* **استایلدهی:** Tailwind CSS v4 (با پیکربندی جدید مبتنی بر پلاگین Vite) به همراه متغیرهای سفارشی CSS در `src/index.css` برای رنگبندی سازمانی طلایی (`#CD9E53`) و سورمهای (`#032340`).
|
||||
* **مسیریابی:** React Router DOM v7 با استفاده از ساختار شیءگرا (`createBrowserRouter`).
|
||||
* **انیمیشنها و اسکرول:**
|
||||
* **GSAP (GreenSock) & ScrollTrigger:** برای پیادهسازی انیمیشنهای پیچیده مبتنی بر اسکرول (مانند بخش Hero تعاملی).
|
||||
* **Lenis Smooth Scroll:** موتور اسکرول بسیار نرم هماهنگ شده با تیکر GSAP (مخصوص دسکتاپ).
|
||||
* **Framer Motion:** برای انیمیشنهای خرد (Micro-animations)، پاپآپها و ترنزیشن کامپوننتها.
|
||||
* **کامپوننتهای سهبعدی:**
|
||||
* **Three.js & React Three Fiber (R3F):** برای رندر کردن اشیاء سهبعدی در وب.
|
||||
* **Three Globe & Cobe:** جهت نمایش کره زمین سهبعدی تعاملی و زیبا در بخش ژئوپلیتیک و ارتباطات بینالملل.
|
||||
* **نمودارها:** React ApexCharts جهت نمایش نوسانات بازار فولاد و قیمتها.
|
||||
* **مدیریت وضعیت:** Zustand جهت مدیریت وضعیتهای سراسری وبسایت.
|
||||
|
||||
### ویژگیها و قابلیتهای بصری فرانتاند
|
||||
|
||||
1. **بومیسازی و پشتیبانی دو زبانه (Localization):**
|
||||
* پیادهسازی شده با استفاده از `useLang()` در `src/context/LangContext`.
|
||||
* تغییر خودکار چیدمان صفحه به RTL برای زبان فارسی و LTR برای زبان انگلیسی با هدایت ویژگی `dir`.
|
||||
* ترجمه درونبرنامهای کامپوننتها با ساختار دیکشنریهای محلی درون هر ماژول.
|
||||
2. **تقویم رویدادهای جلالی بومی (`EventCalendar.tsx`):**
|
||||
* طراحی شده به صورت کاملاً اختصاصی و بدون استفاده از کتابخانههای سنگین خارجی.
|
||||
* استفاده از موتور بومی مرورگر با متد `Intl.DateTimeFormat('en-US-u-ca-persian')` برای ساخت گرید روزها و ماههای هجری شمسی.
|
||||
3. **مدیریت عملکرد و اسکرول:**
|
||||
* غیرفعالسازی Lenis در مرورگرهای موبایل و تبلت جهت جلوگیری از لگ یا تاخیر لمسی و سپردن اسکرول به سیستمعامل بومی دستگاه.
|
||||
* بهینهسازی تصاویر با فرمت مدرن WebP.
|
||||
|
||||
### دستورات بخش فرانتاند
|
||||
|
||||
این دستورات را در پوشه ریشه (Root) پروژه اجرا کنید:
|
||||
|
||||
```bash
|
||||
# نصب پکیجها
|
||||
npm install
|
||||
|
||||
# اجرای سرور توسعه محلی ریاکت
|
||||
npm run dev
|
||||
|
||||
# بررسی خطاهای تایپاسکریپت (سریعترین ابزار اعتبارسنجی لوپ توسعه)
|
||||
npx tsc -b --noEmit
|
||||
|
||||
# بررسی و رفع کدهای غیراستاندارد با ESLint
|
||||
npm run lint
|
||||
|
||||
# ایجاد پکیج خروجی نهایی (Production Build)
|
||||
npm run build
|
||||
|
||||
# پیشنمایش نسخه نهایی بیلد شده روی سیستم محلی
|
||||
npm run preview
|
||||
```
|
||||
> جدولِ `prices` (اسکرپرِ قدیمیِ tgju) دیگر استفاده نمیشود؛ اسکرپرِ زنده حذف شده و صفحهی Market از `market_prices` میخواند.
|
||||
|
||||
---
|
||||
|
||||
## ۳. پنل مدیریت (Backend / Admin Panel)
|
||||
## ۵. احراز هویت و اعضا
|
||||
|
||||
پنل مدیریت در پوشه `panel/` قرار دارد و وظیفه ذخیرهسازی دادههای ساختاریافته وبسایت و مدیریت کاربران ادمین را بر عهده دارد.
|
||||
دو نشستِ مجزا:
|
||||
- **ادمینِ پنل:** کوکیِ `session` (جدولِ `users`). نقشِ `owner` میتواند ادمینها و اعضا را حذف کند.
|
||||
- **عضوِ سایت:** کوکیِ `member_session` (جدولِ `members`).
|
||||
|
||||
### پشته فناوری بکاند
|
||||
**ورود/ثبتنامِ اعضا با موبایل + کدِ یکبارمصرف (OTP):** ارسالِ پیامک با **کاوهنگار** (`KAVENEGAR_API_KEY`). در نبودِ کلید (محیطِ لوکال) کد در کنسولِ سرور چاپ میشود تا تست ممکن باشد. بازنشانیِ رمز هم از طریقِ SMS ممکن است.
|
||||
|
||||
* **فریمورک سرور:** Express.js 4 مبتنی بر Node.js (نسخه ۲۰ به بالا).
|
||||
* **پایگاه داده:** PostgreSQL (سیستم میزبانی ابری لیارا) به همراه ماژول `pg`. برای تسهیل مهاجرت از SQLite قبلی، یک نمای هماهنگ شبیه به متدهای `better-sqlite3` در فایل `db.js` ایجاد شده است.
|
||||
* **امنیت و احراز هویت:**
|
||||
* `bcryptjs` برای رمزنگاری امن گذرواژهها.
|
||||
* `jsonwebtoken` (JWT) برای احراز هویت کاربران ادمین و اعضا. کوکیهای امن با ویژگیهای `HttpOnly` و `Secure/SameSite` به مرورگر فرستاده میشوند تا از حملات XSS جلوگیری شود.
|
||||
* `helmet` جهت اضافه کردن هدرهای امنیتی HTTP.
|
||||
* `express-rate-limit` برای محدود کردن تعداد درخواستها به اندپوینتهای حساس نظیر فرم ورود و فرم تماس با ما.
|
||||
* **پردازش تصاویر و فایلها:**
|
||||
* `multer` برای مدیریت آپلود فایلهای چندرسانهای به صورت بافر حافظه.
|
||||
* `sharp` جهت پردازش خودکار تصاویر آپلود شده (فشردهسازی داینامیک، چرخش صحیح بر اساس EXIF، ریسایز تا سقف ۲۰۰۰ پیکسل و خروجی با فرمت فوقالعاده بهینه WebP).
|
||||
* `pdf-parse` برای خواندن و استخراج متون داخل اسناد PDF گزارشها جهت تحلیل توسط هوش مصنوعی.
|
||||
* **ذخیرهسازی فایل (Storage):**
|
||||
* پشتیبانی از پروتکل **S3-compatible Object Storage** (مانند فضای ذخیرهسازی ابری لیارا). در صورت تنظیم متغیرهای محیطی S3، فایلها مستقیماً آپلود شده و آدرس مستقیم CDN دریافت میشود. در غیر این صورت، پروژه به صورت خودکار به حالت آپلود روی دیسک محلی (`panel/uploads/`) تغییر مسیر میدهد.
|
||||
* **یکپارچهسازی هوش مصنوعی (AI Integration):**
|
||||
* استفاده از SDKهای رسمی **OpenAI** و **Anthropic (Claude)** برای ایجاد خلاصهها، استخراج دادههای کلیدی از اسناد آپلود شده و کمک به آمادهسازی مطالب برای ادمین.
|
||||
**مدیریتِ کاربران در پنل** (تب «کاربران سایت»): فهرستِ اعضا با شماره و **آخرین ورود**، صفحهی جزئیات (خریدها + **تایملاینِ فعالیت: بازدید/کلیک**)، **مسدودسازی/رفعِ مسدودی** (`is_active`) و **حذف**. عضوِ مسدودشده در سمتِ سایت هم اثر میگیرد: `AuthContext` نشست را هنگامِ لود و بازگشتِ فوکوسِ تب با `/api/members/me` اعتبارسنجی میکند و در صورتِ `403` کاربر را خارج میکند.
|
||||
|
||||
### ساختار و معماری پایگاه داده
|
||||
---
|
||||
|
||||
پایگاه داده در زمان استارت آپ اپلیکیشن به صورت خودکار با جداول زیر مقداردهی اولیه (ایجاد ایندکسها و جداول در صورت عدم وجود) میشود:
|
||||
## ۶. اتصال فرانتاند به پنل (Wiring)
|
||||
|
||||
1. **`users`**: ذخیره ادمینها با نقشهای `admin` یا `owner` (صاحب پنل که دسترسی ویرایش سایر ادمینها را دارد).
|
||||
2. **`articles`**: مقالات و گزارشهای تحلیلی. شامل فیلدهای عنوان، دستهبندی، نویسنده، مشخصات صفحات، قیمت، تگها و متن اصلی.
|
||||
3. **`risk_signals`**: سیگنالهای ریسک ژئوپلیتیک و صنعتی با سطوح بحرانی متفاوت (`critical`, `high`, `medium`, `low`, `opportunity`).
|
||||
4. **`events`**: رویدادهای تقویم اندیشکده (سمینارها، همایشها، نمایشگاهها).
|
||||
5. **`team_members`**: اعضای تیم پژوهشی، تخصصها، راههای ارتباطی و شمارش گزارشهای منتشر شده.
|
||||
6. **`radar_items`**: گزارشهای رادار بازار در ۵ دستهبندی اصلی: بازار (Market)، فناوری (Tech)، کالا (Commodity)، ژئوپلیتیک (Geo) و انرژی (Energy).
|
||||
7. **`prices`**: قیمتهای ثبت شده و شاخصهای مرتبط.
|
||||
8. **`members` & `purchases`**: مدیریت خرید اعضا و دسترسی به فایلهای گزارشات غیر رایگان.
|
||||
فرانتاند هنگامِ استارتآپ، محتوای چند بخش را از پنل واکشی میکند (در `frontend/src/main.tsx` توابعِ `bootstrap*` فراخوانی میشوند) و در صورتِ در دسترس نبودنِ پنل به دادهٔ ایستای داخلِ `src/content` و `src/data` بهعنوان fallback برمیگردد. نمونهها: `/api/articles` (گزارشها)، `/api/radar` (در یک نگاه)، `/api/events`، `/api/market-prices` و غیره. احراز هویتِ اعضا از طریقِ `AuthContext` و مسیرهای `/api/members/*` انجام میشود.
|
||||
|
||||
### سرویسهای ویژه پنل مدیریت
|
||||
آدرسِ پنل از متغیرِ `VITE_PANEL_API` خوانده میشود (در پروداکشن هنگامِ build در Dockerfile مقدارِ `https://cms-steelforesight.liara.run` به باندل تزریق میشود).
|
||||
|
||||
* **واردکننده خودکار داده (Import API):**
|
||||
امکان وارد کردن فایلهای JSON تولید شده توسط هوش مصنوعی Gemini که شامل خلاصهسازیها، تگها و آیتمهای رادار است با قابلیت پاکسازی خودکار کاراکترهای اضافی ارجاع دهی هوش مصنوعی (`[cite: ...]`).
|
||||
* **مبدل خودکار تصاویر به WebP:**
|
||||
تبدیل و بهینهسازی حجم تصاویر آپلود شده به صورت بیدرنگ جهت بهبود سرعت لود کلاینت.
|
||||
---
|
||||
|
||||
### دستورات بخش پنل مدیریت
|
||||
## ۷. فهرست APIها
|
||||
|
||||
ابتدا به پوشه پنل بروید: `cd panel`
|
||||
خواندنیها عمومیاند؛ نوشتنیها (POST/PUT/PATCH/DELETE) نیازمندِ کوکیِ نشست یا هدرِ `Authorization: Bearer <token>` هستند.
|
||||
|
||||
```bash
|
||||
# نصب وابستگیهای بکاند
|
||||
npm install
|
||||
|
||||
# ساخت حساب کاربری ادمین اولیه با استفاده از مقادیر .env
|
||||
npm run seed
|
||||
|
||||
# اجرای سرور در حالت پروداکشن
|
||||
npm start
|
||||
|
||||
# اجرای سرور در حالت توسعه با قابلیت راهاندازی مجدد خودکار هنگام تغییر کدها (Watch mode)
|
||||
npm run dev
|
||||
|
||||
# بازسازی ماژول پایگاه داده در صورت تغییر نسخه Node.js سیستم
|
||||
npm rebuild better-sqlite3
|
||||
```
|
||||
|
||||
### لیست APIهای پنل مدیریت
|
||||
|
||||
تمامی متدهای ویرایشی (POST/PUT/DELETE) نیازمند ارسال توکن JWT در کوکی مرورگر یا هدر درخواست (`Authorization: Bearer <token>`) هستند.
|
||||
|
||||
| متد | مسیر | نیاز به احراز هویت | توضیحات |
|
||||
| متد | مسیر | احراز هویت | توضیح |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **POST** | `/api/auth/login` | خیر | ورود ادمین و ثبت کوکی نشست امن |
|
||||
| **POST** | `/api/auth/logout` | خیر | خروج از حساب کاربری و پاک کردن کوکی |
|
||||
| **GET** | `/api/auth/me` | بله | دریافت مشخصات کاربر جاری |
|
||||
| **GET** | `/api/articles` | خیر | لیست مقالات و گزارشها (دارای فیلتر دستهبندی و نوع) |
|
||||
| **POST** | `/api/articles` | بله (ادمین) | ایجاد مقاله جدید |
|
||||
| **PUT** | `/api/articles/:id` | بله (ادمین) | ویرایش مقاله موجود |
|
||||
| **DELETE** | `/api/articles/:id` | بله (ادمین) | حذف مقاله |
|
||||
| **POST** | `/api/uploads` | bله (ادمین) | آپلود تصویر و تبدیل خودکار به WebP بهینه |
|
||||
| **POST** | `/api/uploads/raw` | بله (ادمین) | آپلود ویدیو و اسناد PDF به فضای ابری S3 |
|
||||
| **GET** | `/api/events` | خیر | لیست کل رویدادهای تقویم |
|
||||
| **GET** | `/api/radar` | خیر | لیست تحلیلهای رادار بازار |
|
||||
| **POST** | `/api/admin/import` | بله (ادمین) | ایمپورت مستقیم دادههای تحلیلی هوش مصنوعی |
|
||||
| **GET** | `/api/health` | خیر | بررسی سلامت سرور |
|
||||
| POST | `/api/auth/login` `/logout` | — / — | ورود/خروجِ ادمین |
|
||||
| GET | `/api/auth/me` | ادمین | مشخصاتِ ادمینِ جاری |
|
||||
| GET/POST/PUT/DELETE | `/api/articles[...]` | خواندن آزاد / نوشتن ادمین | CRUD مقالات |
|
||||
| GET/POST/PUT/DELETE | `/api/radar`, `/api/radar-pages`, `/api/events`, `/api/banners`, `/api/risks`, `/api/team`, `/api/market-prices` … | خواندن آزاد / نوشتن ادمین | CRUD بخشهای محتوایی |
|
||||
| POST | `/api/uploads`, `/api/uploads/raw` | ادمین | آپلودِ تصویر (→WebP) / ویدیو و PDF (→S3) |
|
||||
| POST | `/api/admin/import` | ادمین | ایمپورتِ دادهٔ تحلیلیِ هوش مصنوعی |
|
||||
| POST | `/api/members/otp/send` `/otp/verify` | — | ورود/ثبتنام با OTP |
|
||||
| POST | `/api/members/register-verify` | — | تکمیلِ ثبتنام پس از تأییدِ کد |
|
||||
| GET/PUT | `/api/members/me` | عضو | پروفایلِ عضو |
|
||||
| GET | `/api/members/me/purchases[...]` | عضو | خریدها و دانلودِ گزارش |
|
||||
| POST | `/api/members/activity` | عضو | ثبتِ فعالیت (بازدید/کلیک) |
|
||||
| GET | `/api/members` `/api/members/:id` `/:id/activity` | ادمین | فهرست/جزئیات/فعالیتِ اعضا |
|
||||
| PATCH/DELETE | `/api/members/:id` | ادمین/owner | مسدودسازی/حذفِ عضو |
|
||||
| POST/GET/DELETE | `/api/newsletter[...]` | نوشتن آزاد / خواندن ادمین | عضویت در خبرنامه و مدیریتِ آن |
|
||||
| POST | `/api/contact` | — | فرمِ تماس با ما |
|
||||
| GET | `/api/health` | — | سلامتِ سرور |
|
||||
|
||||
---
|
||||
|
||||
## ۴. نحوه اتصال فرانتاند به پنل مدیریت (Wiring)
|
||||
## ۸. استقرار (Deployment)
|
||||
|
||||
در حال حاضر بخش زیادی از اطلاعات فرانتاند به صورت ایستا (Static / Mock Data) از پوشه `src/data/` یا `src/content/` لود میشود تا فرانتبخش بدون وابستگی به سرور قابل نمایش باشد.
|
||||
هر دو اپ روی **لیارا (Liara)** مستقر میشوند (دو اپِ جدا).
|
||||
|
||||
برای داینامیک کردن دادهها و اتصال فرانتاند به پنل مدیریت مراحل زیر پیشنهاد میشود:
|
||||
### فرانتاند — اپِ `steelforesight`
|
||||
خروجیِ استاتیک، با Dockerfileِ دومرحلهای: مرحلهی Node برای `vite build` و سپس Nginxِ سبک برای سروِ `dist/` روی پورتِ **۷۸۶۰** (سازگار با Hugging Face Spaces).
|
||||
```bash
|
||||
cd frontend
|
||||
liara deploy --app steelforesight --platform docker --port 7860 --build-location iran
|
||||
```
|
||||
`VITE_PANEL_API` هنگامِ build از طریقِ `ARG` در Dockerfile تزریق میشود.
|
||||
|
||||
1. در ریشه پروژه، متغیر محیطی آدرس بکاند را در فایل `.env.local` تعریف کنید:
|
||||
```env
|
||||
VITE_PANEL_API=http://localhost:3001
|
||||
```
|
||||
2. یک سرویس کلاینت (مثلا `src/lib/api.ts`) برای واکشی دادهها بسازید:
|
||||
```typescript
|
||||
const API_URL = import.meta.env.VITE_PANEL_API || 'http://localhost:3001';
|
||||
|
||||
export async function getArticles(category?: string) {
|
||||
const url = category ? `${API_URL}/api/articles?category=${encodeURIComponent(category)}` : `${API_URL}/api/articles`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('خطا در دریافت اطلاعات از سرور');
|
||||
return res.json();
|
||||
}
|
||||
```
|
||||
3. در کامپوننتهای فرانتاند (مانند بخش نمایش گزارشهای صفحه اصلی)، از React Query (که پکیج آن در پروژه نصب است) یا هوک `useEffect` برای دریافت پویای دادهها به شکل زیر استفاده کنید:
|
||||
```typescript
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getArticles } from '@/lib/api';
|
||||
|
||||
// در داخل کامپوننت ریاکت
|
||||
const { data: reports, isLoading } = useQuery({
|
||||
queryKey: ['articles', category],
|
||||
queryFn: () => getArticles(category),
|
||||
placeholderData: fallbackMockData // استفاده از دادههای ماک قبلی در زمان لودینگ یا خطا
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ۵. استقرار و دگرگونیها (Deployment)
|
||||
|
||||
### فرانتاند
|
||||
فرانتاند پروژه به دلیل ایستا (Static) بودن خروجی نهایی، به راحتی به کمک **Dockerfile** موجود استقرار مییابد.
|
||||
* داکرفایل از ساختار دو مرحلهای (Multi-stage build) استفاده میکند:
|
||||
1. مرحله اول: استفاده از ایمیج Node جهت نصب پکیجها و ایجاد خروجی نهایی بیلد در پوشه `dist`.
|
||||
2. مرحله دوم: استفاده از سرور بسیار سبک Nginx جهت کپی و سرو کردن فایلهای پوشه `dist` روی پورت `7860`.
|
||||
* این پورت برای آپلود مستقیم در پلتفرمهایی مانند **Hugging Face Spaces** بهینه شده است (تنظیمات متا دیتا در بالای فایل `README.md` ریشه قید شده است).
|
||||
|
||||
### پنل مدیریت (بکاند)
|
||||
* پنل مدیریت به پایگاه داده و در صورت تمایل سیستم فایل نیاز دارد.
|
||||
* برای دیپلوی روی **لیارا (Liara)**، فایلهای کانفیگ نظیر `liara.json` و فایل نادیدهگیری `.liaraignore` تعبیه شدهاند.
|
||||
* حتما باید متغیرهای محیطی زیر در کنترل پنل هاست تعریف شوند:
|
||||
* `PORT`: پورت سرور (به عنوان مثال ۳۰۰۱).
|
||||
* `JWT_SECRET`: یک رشته هگزادسیمل تصادفی طولانی (حداقل ۳۲ کاراکتر).
|
||||
* `DATABASE_URL`: آدرس اتصال به پایگاه داده PostgreSQL ابری شما.
|
||||
* `ALLOWED_ORIGINS`: آدرس دامنه فرانتاند جهت عبور از گیت CORS.
|
||||
* متغیرهای سرویس فایل ابری در صورت نیاز (`LIARA_ENDPOINT`, `LIARA_BUCKET`, `LIARA_ACCESS_KEY`, `LIARA_SECRET_KEY`).
|
||||
### پنل — اپِ `cms-steelforesight`
|
||||
```bash
|
||||
cd panel
|
||||
liara deploy --app cms-steelforesight --platform node --build-location iran
|
||||
```
|
||||
متغیرهای محیطیِ لازم (در کنترلپنلِ هاست):
|
||||
- `DATABASE_URL` — اتصالِ PostgreSQL.
|
||||
- `JWT_SECRET` — رشتهی تصادفیِ بلند (≥۳۲ کاراکتر).
|
||||
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` — برای `npm run seed`.
|
||||
- `ALLOWED_ORIGINS` — دامنهی فرانت برای عبور از CORS.
|
||||
- `KAVENEGAR_API_KEY` — ارسالِ پیامکِ OTP.
|
||||
- اختیاری: متغیرهای ایمیل و object storage (`LIARA_ENDPOINT`, `LIARA_BUCKET`, `LIARA_ACCESS_KEY`, `LIARA_SECRET_KEY`).
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
image: andishkade-foolad:latest
|
||||
container_name: andishkade-foolad
|
||||
|
|
|
|||
BIN
fig-profile.png
BIN
fig-profile.png
Binary file not shown.
|
Before Width: | Height: | Size: 271 KiB |
|
|
@ -11,16 +11,11 @@
|
|||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"apexcharts": "^5.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cobe": "^2.0.1",
|
||||
"dotted-map": "^3.1.0",
|
||||
"framer-motion": "^12.40.0",
|
||||
"gsap": "^3.15.0",
|
||||
"jalaali-js": "^1.2.8",
|
||||
"lenis": "^1.3.23",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
|
|
@ -30,14 +25,11 @@
|
|||
"react-router-dom": "^7.15.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"three": "^0.184.0",
|
||||
"three-globe": "^2.45.2",
|
||||
"vazir-font": "^30.1.0",
|
||||
"zustand": "^5.0.13"
|
||||
"three-globe": "^2.45.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/jalaali-js": "^1.2.0",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
|
@ -1017,32 +1009,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tabler/icons": {
|
||||
"version": "3.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.44.0.tgz",
|
||||
"integrity": "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/icons-react": {
|
||||
"version": "3.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.44.0.tgz",
|
||||
"integrity": "sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tabler/icons": "3.44.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
|
||||
|
|
@ -1315,32 +1281,6 @@
|
|||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.100.14",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz",
|
||||
"integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.100.14",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz",
|
||||
"integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.100.14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@turf/boolean-point-in-polygon": {
|
||||
"version": "7.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.5.tgz",
|
||||
|
|
@ -1427,13 +1367,6 @@
|
|||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jalaali-js": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/jalaali-js/-/jalaali-js-1.2.0.tgz",
|
||||
"integrity": "sha512-DRQKjbfFe0/v3siCou9eFzYjDxJItkJpxbU+/CSptvoRqq2/VmTA6y8QvqK/VELKW6xX3qb174nLZlrFV9OT3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
|
|
@ -2031,12 +1964,6 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cobe": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cobe/-/cobe-2.0.1.tgz",
|
||||
"integrity": "sha512-aaa6vcIlaC8C1SF50LDH0Anybo/EAXnrxqe+bwvr4+YUtZydqjeBjTTD7ziCCkbRrRGSns3I3F6cZsf3W+L+ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
|
|
@ -2322,19 +2249,6 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dotted-map": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/dotted-map/-/dotted-map-3.1.0.tgz",
|
||||
"integrity": "sha512-E0z9o5IaTf44FnWHvbyg4QQcBwXgzZJr82HJASWb1dKUCULHfnlWKRpfe5EFHTk/yaoS1sQSLMyMrL6o74/sIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@turf/boolean-point-in-polygon": "^7.3.4",
|
||||
"proj4": "^2.20.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/draco3d": {
|
||||
"version": "1.5.7",
|
||||
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
|
||||
|
|
@ -2951,12 +2865,6 @@
|
|||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jalaali-js": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz",
|
||||
"integrity": "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jerrypick": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz",
|
||||
|
|
@ -3454,12 +3362,6 @@
|
|||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mgrs": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz",
|
||||
"integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
|
|
@ -3708,19 +3610,6 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proj4": {
|
||||
"version": "2.20.8",
|
||||
"resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.8.tgz",
|
||||
"integrity": "sha512-1C8sfT4xY4PAPwk0MroFBTGF4R4bzDXdmPQTGYVLsoNssrZ9odzObxS2dTeGBty8jW8KO7h16C1Hs2JP+ctfFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mgrs": "1.0.0",
|
||||
"wkt-parser": "^1.5.5"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ahocevar"
|
||||
}
|
||||
},
|
||||
"node_modules/promise-worker-transferable": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
|
||||
|
|
@ -4420,13 +4309,6 @@
|
|||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/vazir-font": {
|
||||
"version": "30.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vazir-font/-/vazir-font-30.1.0.tgz",
|
||||
"integrity": "sha512-XN2Uprw/Q3QhAAApITykf+v0l4p4FpqvIh0h2XDSJRjwUiYeDpwQxECQF/93ZOCDO2t8haBi6BZZqO8sifYNRg==",
|
||||
"deprecated": "vazir-font no longer supported. Use vazirmatn instead.",
|
||||
"license": "OFL"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.14",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
|
||||
|
|
@ -4531,15 +4413,6 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wkt-parser": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.5.tgz",
|
||||
"integrity": "sha512-/zMYi94/7D7fxcOSlVmWn6vnOMj3Gq5d1xvVjaYOS9n6h0qOJ4I7YYVxBWYcH1vq9+suhqzXkn05Yx47zQNUIA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ahocevar"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
|
|
|||
|
|
@ -13,16 +13,11 @@
|
|||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"apexcharts": "^5.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cobe": "^2.0.1",
|
||||
"dotted-map": "^3.1.0",
|
||||
"framer-motion": "^12.40.0",
|
||||
"gsap": "^3.15.0",
|
||||
"jalaali-js": "^1.2.8",
|
||||
"lenis": "^1.3.23",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
|
|
@ -32,14 +27,11 @@
|
|||
"react-router-dom": "^7.15.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"three": "^0.184.0",
|
||||
"three-globe": "^2.45.2",
|
||||
"vazir-font": "^30.1.0",
|
||||
"zustand": "^5.0.13"
|
||||
"three-globe": "^2.45.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/jalaali-js": "^1.2.0",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3e22f4390856dd4995a930e22c5026008b333d6b5429d4201ae71d6b3a397c41
|
||||
size 43690639
|
||||
|
|
@ -1,681 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🪙 داشبورد زنده قیمت سکه — اندیشکده فولاد آینده</title>
|
||||
|
||||
<!-- Modern Persian & English Typography -->
|
||||
<link href="https://cdn.jsdelivr.net/gh/rastikerdar/vazir-font@v30.1.0/dist/font-face.css" rel="stylesheet" type="text/css" />
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-gradient: linear-gradient(135deg, #0b0f19 0%, #111827 100%);
|
||||
--card-bg: rgba(17, 24, 39, 0.7);
|
||||
--card-border: rgba(255, 255, 255, 0.08);
|
||||
--text-primary: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--accent-gold: #f59e0b;
|
||||
--accent-gold-glow: rgba(245, 158, 11, 0.15);
|
||||
|
||||
--color-up: #10b981;
|
||||
--color-up-glow: rgba(16, 185, 129, 0.2);
|
||||
--color-down: #ef4444;
|
||||
--color-down-glow: rgba(239, 68, 68, 0.2);
|
||||
--color-neutral: #6b7280;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Vazir', 'Inter', -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-gradient);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.dashboard-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.brand-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
font-size: 2.5rem;
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(to left, #ffffff, #f59e0b);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Status Pill and Countdown */
|
||||
.status-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.2rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--card-border);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-up);
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--color-up);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 0 var(--color-up-glow);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.timer-section {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
border-right: 1px solid var(--card-border);
|
||||
padding-right: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.timer-val {
|
||||
font-weight: 900;
|
||||
color: var(--accent-gold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* main price grid */
|
||||
.price-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Card styling - Glassmorphism */
|
||||
.coin-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 20px;
|
||||
padding: 1.8rem 1.5rem;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.coin-card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
box-shadow: 0 12px 40px 0 rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
|
||||
/* Price Flashes */
|
||||
@keyframes up-flash {
|
||||
0% { border-color: var(--color-up); box-shadow: 0 0 25px var(--color-up-glow); }
|
||||
100% { border-color: var(--card-border); box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3); }
|
||||
}
|
||||
|
||||
@keyframes down-flash {
|
||||
0% { border-color: var(--color-down); box-shadow: 0 0 25px var(--color-down-glow); }
|
||||
100% { border-color: var(--card-border); box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3); }
|
||||
}
|
||||
|
||||
.up-flash {
|
||||
animation: up-flash 1.5s ease-out;
|
||||
}
|
||||
|
||||
.down-flash {
|
||||
animation: down-flash 1.5s ease-out;
|
||||
}
|
||||
|
||||
/* Card Header */
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.coin-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.coin-badge {
|
||||
font-size: 1.5rem;
|
||||
background: var(--accent-gold-glow);
|
||||
padding: 0.4rem;
|
||||
border-radius: 12px;
|
||||
color: var(--accent-gold);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
/* Card Price Body */
|
||||
.price-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.price-toman {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 900;
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.price-toman span {
|
||||
font-size: 0.85rem;
|
||||
font-weight: normal;
|
||||
color: var(--text-muted);
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.price-rial {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Change badge indicators */
|
||||
.change-badge {
|
||||
align-self: flex-start;
|
||||
font-size: 0.78rem;
|
||||
font-weight: bold;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
direction: ltr; /* English layout inside pill */
|
||||
}
|
||||
|
||||
.change-up {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--color-up);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.change-down {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--color-down);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.change-neutral {
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
color: var(--color-neutral);
|
||||
border: 1px solid rgba(107, 114, 128, 0.2);
|
||||
}
|
||||
|
||||
/* Card Footer Info */
|
||||
.card-footer {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
padding-top: 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-val {
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* History table area */
|
||||
.history-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 20px;
|
||||
padding: 1.8rem;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: right;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
th {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: var(--text-muted);
|
||||
font-weight: bold;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 3rem;
|
||||
border-top: 1px solid var(--card-border);
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 1rem;
|
||||
}
|
||||
header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.status-panel {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="dashboard-container">
|
||||
|
||||
<!-- Brand Header -->
|
||||
<header>
|
||||
<div class="brand-section">
|
||||
<div class="brand-logo">🪙</div>
|
||||
<div>
|
||||
<h1>داشبورد زنده قیمت سکه</h1>
|
||||
<div class="brand-sub">اندیشکده فولاد آینده — مرکز تحلیل راهبردی بازار طلا و صنعت</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Real-time updates panel -->
|
||||
<div class="status-panel">
|
||||
<div class="status-pill">
|
||||
<div class="pulse-dot"></div>
|
||||
<span>بروزرسانی زنده فعال</span>
|
||||
</div>
|
||||
<div class="timer-section">
|
||||
<span>داده جدید در: </span>
|
||||
<span class="timer-val" id="countdown-val">۶۰ ثانیه</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 5 Live Coin Cards -->
|
||||
<section class="price-grid" id="cards-grid">
|
||||
<!-- Cards will be dynamically rendered here -->
|
||||
</section>
|
||||
|
||||
<!-- Recent History Log Table -->
|
||||
<section class="history-card">
|
||||
<div class="history-header">
|
||||
<h2 class="history-title">⏳ تاریخچه نوسانات اخیر (ثبت خودکار هر ۱ دقیقه)</h2>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>زمان ثبت سیستم</th>
|
||||
<th>نام سکه</th>
|
||||
<th>قیمت فعلی (تومان)</th>
|
||||
<th>میزان و درصد تغییر</th>
|
||||
<th>کمترین قیمت روز</th>
|
||||
<th>بیشترین قیمت روز</th>
|
||||
<th>زمان بروزرسانی سایت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-tbody">
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; color: var(--text-muted); padding: 2rem;">در حال لود اطلاعات از سرور...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
مرکز مستقل تحلیل راهبردی و سیاستگذاری بازار و صنایع — تمامی نرخها به صورت مستقیم و بدون کش از بازار تهران دریافت میشوند.
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Real-time Dynamic script re-loader and state manager -->
|
||||
<script>
|
||||
let lastFetchedTime = null;
|
||||
let priceHistory = [];
|
||||
let previousPrices = {};
|
||||
let countdownSecs = 60;
|
||||
|
||||
// Format numbers helper
|
||||
function fmtNum(val) {
|
||||
if (typeof val === 'string') {
|
||||
val = parseInt(val.replace(/[^\d]/g, ''));
|
||||
}
|
||||
return val ? val.toLocaleString('fa-IR') : '۰';
|
||||
}
|
||||
|
||||
// Convert numbers to Persian characters
|
||||
const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
|
||||
function toPersianDigits(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return str.toString().replace(/[0-9]/g, w => persianDigits[+w]);
|
||||
}
|
||||
|
||||
// Load data from live_prices.js dynamically to bypass CORS file:// issues
|
||||
function reloadPriceScript() {
|
||||
// Remove any previously injected script
|
||||
const oldScript = document.getElementById('live-data-script');
|
||||
if (oldScript) {
|
||||
oldScript.remove();
|
||||
}
|
||||
|
||||
// Create a fresh script element with cache buster
|
||||
const script = document.createElement('script');
|
||||
script.id = 'live-data-script';
|
||||
script.src = 'live_prices.js?_t=' + Date.now();
|
||||
|
||||
script.onload = function() {
|
||||
if (window.LIVE_COIN_PRICES) {
|
||||
updateUI(window.LIVE_COIN_PRICES);
|
||||
}
|
||||
};
|
||||
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
function updateUI(data) {
|
||||
// If no new data fetched since last load, do nothing
|
||||
if (lastFetchedTime === data.last_fetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastFetchedTime = data.last_fetch;
|
||||
countdownSecs = 60; // Reset countdown on fresh fetch
|
||||
|
||||
const grid = document.getElementById('cards-grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
// Target emojis
|
||||
const coinEmojis = {
|
||||
"سکه امامی": "👑",
|
||||
"سکه بهار آزادی": "🕌",
|
||||
"نیم سکه": "🌓",
|
||||
"ربع سکه": "🌗",
|
||||
"سکه گرمی": "⚡"
|
||||
};
|
||||
|
||||
data.prices.forEach(coin => {
|
||||
// Compare price with previous to determine change flashes
|
||||
const oldPrice = previousPrices[coin.name];
|
||||
let flashClass = '';
|
||||
if (oldPrice !== undefined) {
|
||||
if (coin.price_toman > oldPrice) flashClass = 'up-flash';
|
||||
else if (coin.price_toman < oldPrice) flashClass = 'down-flash';
|
||||
}
|
||||
previousPrices[coin.name] = coin.price_toman;
|
||||
|
||||
// Parse change percentage and sign
|
||||
const isUp = coin.change.includes('+') || (!coin.change.includes('-') && !coin.change.startsWith('0') && !coin.change.includes('(0%'));
|
||||
const isZero = coin.change.startsWith('0') || coin.change.includes('(0%') || coin.change === '0';
|
||||
|
||||
let changeClass = 'change-neutral';
|
||||
let changeIcon = '●';
|
||||
if (!isZero) {
|
||||
if (isUp) {
|
||||
changeClass = 'change-up';
|
||||
changeIcon = '▲';
|
||||
} else {
|
||||
changeClass = 'change-down';
|
||||
changeIcon = '▼';
|
||||
}
|
||||
}
|
||||
|
||||
// Render card html
|
||||
const emoji = coinEmojis[coin.name] || "🪙";
|
||||
const cardHtml = `
|
||||
<div class="coin-card ${flashClass}">
|
||||
<div class="card-header">
|
||||
<span class="coin-title">${coin.name}</span>
|
||||
<span class="coin-badge">${emoji}</span>
|
||||
</div>
|
||||
|
||||
<div class="price-section">
|
||||
<div class="price-toman">
|
||||
${fmtNum(coin.price_toman)} <span>تومان</span>
|
||||
</div>
|
||||
<div class="price-rial">
|
||||
${fmtNum(coin.price_rial)} ریال
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="change-badge ${changeClass}">
|
||||
<span>${toPersianDigits(coin.change)}</span>
|
||||
<span>${changeIcon}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="info-row">
|
||||
<span>کمترین قیمت روز:</span>
|
||||
<span class="info-val">${fmtNum(coin.min_toman)} ت</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>بیشترین قیمت روز:</span>
|
||||
<span class="info-val">${fmtNum(coin.max_toman)} ت</span>
|
||||
</div>
|
||||
<div class="info-row" style="margin-top: 0.4rem; border-top: 1px dashed rgba(255,255,255,0.03); padding-top: 0.4rem;">
|
||||
<span>بروزرسانی سایت:</span>
|
||||
<span class="info-val" style="color: var(--accent-gold); font-weight: bold;">${toPersianDigits(coin.last_update)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
grid.insertAdjacentHTML('beforeend', cardHtml);
|
||||
|
||||
// Push record to local rolling history
|
||||
pushToRollingHistory(data.last_fetch, coin);
|
||||
});
|
||||
|
||||
// Update history table view
|
||||
renderHistoryTable();
|
||||
}
|
||||
|
||||
// Keep the last 15 ticks in local storage rolling history
|
||||
function pushToRollingHistory(fetchTime, coin) {
|
||||
// Avoid duplicate logs for the exact same fetch time
|
||||
const duplicate = priceHistory.some(h => h.time === fetchTime && h.name === coin.name);
|
||||
if (duplicate) return;
|
||||
|
||||
priceHistory.push({
|
||||
time: fetchTime,
|
||||
name: coin.name,
|
||||
price_toman: coin.price_toman,
|
||||
change: coin.change,
|
||||
min_toman: coin.min_toman,
|
||||
max_toman: coin.max_toman,
|
||||
last_update: coin.last_update
|
||||
});
|
||||
|
||||
// Limit size to last 50 entries
|
||||
if (priceHistory.length > 50) {
|
||||
priceHistory.shift();
|
||||
}
|
||||
|
||||
localStorage.setItem('coin_price_history_v2', JSON.stringify(priceHistory));
|
||||
}
|
||||
|
||||
function renderHistoryTable() {
|
||||
const tbody = document.getElementById('history-tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
// Display logs in reverse order (newest first)
|
||||
const reversedLogs = [...priceHistory].reverse();
|
||||
|
||||
if (reversedLogs.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--text-muted);">هیچ رکوردی در تاریخچه ثبت نشده است.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
reversedLogs.forEach(row => {
|
||||
const isUp = row.change.includes('+') || (!row.change.includes('-') && !row.change.startsWith('0') && !row.change.includes('(0%'));
|
||||
const isZero = row.change.startsWith('0') || row.change.includes('(0%') || row.change === '0';
|
||||
|
||||
let color = 'var(--text-primary)';
|
||||
if (!isZero) {
|
||||
color = isUp ? 'var(--color-up)' : 'var(--color-down)';
|
||||
}
|
||||
|
||||
const trHtml = `
|
||||
<tr>
|
||||
<td style="font-weight: bold; color: var(--accent-gold);">${toPersianDigits(row.time)}</td>
|
||||
<td style="font-weight: bold;">${row.name}</td>
|
||||
<td style="font-weight: 900;">${fmtNum(row.price_toman)} تومان</td>
|
||||
<td style="color: ${color}; font-weight: bold; direction: ltr;">${toPersianDigits(row.change)}</td>
|
||||
<td>${fmtNum(row.min_toman)} تومان</td>
|
||||
<td>${fmtNum(row.max_toman)} تومان</td>
|
||||
<td style="color: var(--text-muted);">${toPersianDigits(row.last_update)}</td>
|
||||
</tr>
|
||||
`;
|
||||
tbody.insertAdjacentHTML('beforeend', trHtml);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize state
|
||||
if (localStorage.getItem('coin_price_history_v2')) {
|
||||
try {
|
||||
priceHistory = JSON.parse(localStorage.getItem('coin_price_history_v2'));
|
||||
} catch(e) {
|
||||
priceHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Loop timer
|
||||
setInterval(() => {
|
||||
if (countdownSecs > 1) {
|
||||
countdownSecs--;
|
||||
document.getElementById('countdown-val').innerText = toPersianDigits(countdownSecs) + ' ثانیه';
|
||||
} else {
|
||||
document.getElementById('countdown-val').innerText = 'در حال دریافت...';
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Initial loading
|
||||
reloadPriceScript();
|
||||
|
||||
// Poll the JS file every 2 seconds for ultra-responsive updates
|
||||
setInterval(reloadPriceScript, 2000);
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
window.LIVE_COIN_PRICES = {
|
||||
"last_fetch": "2026-06-01 16:15:58",
|
||||
"prices": [
|
||||
{
|
||||
"name": "سکه امامی",
|
||||
"price_rial": 1845050000,
|
||||
"price_toman": 184505000,
|
||||
"change": "(3.07%) 55,000,000",
|
||||
"min_toman": 182480000,
|
||||
"max_toman": 184510000,
|
||||
"last_update": "16:13:54"
|
||||
},
|
||||
{
|
||||
"name": "سکه بهار آزادی",
|
||||
"price_rial": 1810100000,
|
||||
"price_toman": 181010000,
|
||||
"change": "(2.26%) 39,950,000",
|
||||
"min_toman": 177980000,
|
||||
"max_toman": 181020000,
|
||||
"last_update": "16:14:00"
|
||||
},
|
||||
{
|
||||
"name": "نیم سکه",
|
||||
"price_rial": 940000000,
|
||||
"price_toman": 94000000,
|
||||
"change": "(2.1%) 19,300,000",
|
||||
"min_toman": 93500000,
|
||||
"max_toman": 94000000,
|
||||
"last_update": "11:10:35"
|
||||
},
|
||||
{
|
||||
"name": "ربع سکه",
|
||||
"price_rial": 535000000,
|
||||
"price_toman": 53500000,
|
||||
"change": "(2.88%) 15,000,000",
|
||||
"min_toman": 52500000,
|
||||
"max_toman": 53500000,
|
||||
"last_update": "15:38:23"
|
||||
},
|
||||
{
|
||||
"name": "سکه گرمی",
|
||||
"price_rial": 275000000,
|
||||
"price_toman": 27500000,
|
||||
"change": "(1.85%) 5,000,000",
|
||||
"min_toman": 27000000,
|
||||
"max_toman": 27500000,
|
||||
"last_update": "15:55:20"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
{
|
||||
"last_fetch": "2026-06-01 16:15:58",
|
||||
"prices": [
|
||||
{
|
||||
"name": "سکه امامی",
|
||||
"price_rial": 1845050000,
|
||||
"price_toman": 184505000,
|
||||
"change": "(3.07%) 55,000,000",
|
||||
"min_toman": 182480000,
|
||||
"max_toman": 184510000,
|
||||
"last_update": "16:13:54"
|
||||
},
|
||||
{
|
||||
"name": "سکه بهار آزادی",
|
||||
"price_rial": 1810100000,
|
||||
"price_toman": 181010000,
|
||||
"change": "(2.26%) 39,950,000",
|
||||
"min_toman": 177980000,
|
||||
"max_toman": 181020000,
|
||||
"last_update": "16:14:00"
|
||||
},
|
||||
{
|
||||
"name": "نیم سکه",
|
||||
"price_rial": 940000000,
|
||||
"price_toman": 94000000,
|
||||
"change": "(2.1%) 19,300,000",
|
||||
"min_toman": 93500000,
|
||||
"max_toman": 94000000,
|
||||
"last_update": "11:10:35"
|
||||
},
|
||||
{
|
||||
"name": "ربع سکه",
|
||||
"price_rial": 535000000,
|
||||
"price_toman": 53500000,
|
||||
"change": "(2.88%) 15,000,000",
|
||||
"min_toman": 52500000,
|
||||
"max_toman": 53500000,
|
||||
"last_update": "15:38:23"
|
||||
},
|
||||
{
|
||||
"name": "سکه گرمی",
|
||||
"price_rial": 275000000,
|
||||
"price_toman": 27500000,
|
||||
"change": "(1.85%) 5,000,000",
|
||||
"min_toman": 27000000,
|
||||
"max_toman": 27500000,
|
||||
"last_update": "15:55:20"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@ import path from 'node:path';
|
|||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { db, rowToArticle, rowToRiskSignal, rowToPrice, rowToEvent, rowToTeamMember, rowToPlan, rowToBanner, rowToRadarItem, rowToRadarPage, rowToFactoryReport, rowToIntegration, rowToInstituteStat, rowToMarketPrice, rowToMarketChartPoint, rowToVisionItem, rowToAdvisoryMember } from './db.js';
|
||||
import { startScraperLoop, scrapeOnce } from './scraper.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.env.PORT || 3001);
|
||||
|
|
@ -1402,24 +1401,6 @@ app.delete('/api/newsletter/:id', authRequired, async (req, res) => {
|
|||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ---------- prices (scraped from tgju.org every 2 min) ----------
|
||||
app.get('/api/prices', async (_req, res) => {
|
||||
const rows = await db
|
||||
.prepare('SELECT symbol, name, value, unit, change_value, change_pct, source_url, fetched_at FROM prices ORDER BY name')
|
||||
.all();
|
||||
res.json(rows.map(rowToPrice));
|
||||
});
|
||||
|
||||
app.post('/api/prices/refresh', authRequired, async (_req, res) => {
|
||||
try {
|
||||
const n = await scrapeOnce();
|
||||
res.json({ ok: true, scraped: n });
|
||||
} catch (err) {
|
||||
console.error('[scraper] refresh error:', err);
|
||||
res.status(500).json({ error: 'scrape_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- banners ----------
|
||||
const BANNER_FIELDS = `id,overline_fa,overline_en,title_fa,title_en,subtitle_fa,subtitle_en,cta_label_fa,cta_label_en,cta_url,image,position,active,sort_order,created_at,updated_at`;
|
||||
|
||||
|
|
@ -1865,11 +1846,4 @@ app.get('/api/members/me/purchases/:purchaseId/download', memberRequired, async
|
|||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Panel running at ${PUBLIC_ORIGIN}`);
|
||||
const intervalMs = Number(process.env.SCRAPER_INTERVAL_MS) || 2 * 60 * 1000;
|
||||
if (process.env.SCRAPER_DISABLED !== '1') {
|
||||
startScraperLoop(intervalMs);
|
||||
console.log(`[scraper] tgju.org polling every ${intervalMs / 1000}s`);
|
||||
} else {
|
||||
console.log('[scraper] disabled via SCRAPER_DISABLED=1');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,300 +0,0 @@
|
|||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import ssl
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Ensure terminal output supports UTF-8 on Windows
|
||||
if sys.platform == 'win32':
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
# Persian/Arabic to English digit mapping
|
||||
FA_TO_EN = {
|
||||
'۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4',
|
||||
'۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9',
|
||||
'٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4',
|
||||
'٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9'
|
||||
}
|
||||
|
||||
def clean_persian_text(text):
|
||||
"""Clean HTML tags and standardize spacing."""
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
return text
|
||||
|
||||
def to_english_digits(text):
|
||||
"""Convert Persian/Arabic digits in a string to English digits."""
|
||||
if not text:
|
||||
return ""
|
||||
return "".join(FA_TO_EN.get(char, char) for char in text)
|
||||
|
||||
def parse_numeric(text):
|
||||
"""Convert a price string with commas and Persian digits into an integer."""
|
||||
if not text:
|
||||
return 0
|
||||
clean_text = to_english_digits(text)
|
||||
digits_only = re.sub(r'[^\d]', '', clean_text)
|
||||
try:
|
||||
return int(digits_only) if digits_only else 0
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def fetch_html_with_retry(url, retries=3, delay=2):
|
||||
"""Fetch URL with retries, custom headers, and disabled SSL checks."""
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'Accept-Language': 'fa,en-US;q=0.9,en;q=0.8',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache'
|
||||
}
|
||||
|
||||
# Make it a POST request by passing empty data (b"") to completely bypass CDN/ArvanCloud HTML caching
|
||||
req = urllib.request.Request(url, data=b"", headers=headers)
|
||||
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=ctx, timeout=10) as response:
|
||||
if response.status == 200:
|
||||
html = response.read().decode('utf-8')
|
||||
if "sekee" in html:
|
||||
return html
|
||||
else:
|
||||
raise ValueError("Incorrect page content returned (missing expected coin tags).")
|
||||
except Exception as e:
|
||||
if attempt < retries:
|
||||
time.sleep(delay)
|
||||
delay *= 1.5
|
||||
else:
|
||||
print(f"⚠️ Connection error on attempt {attempt}: {e}")
|
||||
return None
|
||||
|
||||
HISTORY_LOG = []
|
||||
HISTORY_INITIALIZED = False
|
||||
|
||||
def initialize_history_from_excel():
|
||||
global HISTORY_LOG, HISTORY_INITIALIZED
|
||||
excel_filename = "coin_prices.xlsx"
|
||||
if not HISTORY_INITIALIZED:
|
||||
if os.path.exists(excel_filename):
|
||||
try:
|
||||
import pandas as pd
|
||||
df = pd.read_excel(excel_filename, sheet_name="تاریخچه تغییرات")
|
||||
HISTORY_LOG = df.to_dict(orient="records")
|
||||
print(f"📥 Loaded {len(HISTORY_LOG)} history logs from existing '{excel_filename}'.")
|
||||
except Exception as e:
|
||||
print(f"💡 Initialized new history log in memory (failed to load: {e})")
|
||||
HISTORY_INITIALIZED = True
|
||||
|
||||
def save_live_js_and_json(scraped_data):
|
||||
"""Save scraped data to JSON and JS files for real-time HTML dashboard."""
|
||||
current_time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
live_records = []
|
||||
for row in scraped_data:
|
||||
live_records.append({
|
||||
"name": row["Name"],
|
||||
"price_rial": row["Price_Rial"],
|
||||
"price_toman": row["Price_Toman"],
|
||||
"change": row["Change"],
|
||||
"min_toman": row["Min_Price_Toman"],
|
||||
"max_toman": row["Max_Price_Toman"],
|
||||
"last_update": row["Last_Update"]
|
||||
})
|
||||
|
||||
data_to_save = {
|
||||
"last_fetch": current_time_str,
|
||||
"prices": live_records
|
||||
}
|
||||
|
||||
# Save as JSON
|
||||
try:
|
||||
with open("live_prices.json", "w", encoding="utf-8") as f:
|
||||
json.dump(data_to_save, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error saving live_prices.json: {e}")
|
||||
|
||||
# Save as JS (for file:// protocol CORS bypass)
|
||||
try:
|
||||
js_content = f"window.LIVE_COIN_PRICES = {json.dumps(data_to_save, ensure_ascii=False, indent=2)};"
|
||||
with open("live_prices.js", "w", encoding="utf-8") as f:
|
||||
f.write(js_content)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error saving live_prices.js: {e}")
|
||||
|
||||
def save_to_excel(scraped_data):
|
||||
"""Save scraped data to Excel file with Latest Prices and History sheets."""
|
||||
global HISTORY_LOG
|
||||
excel_filename = "coin_prices.xlsx"
|
||||
|
||||
# Initialize history from Excel if we haven't already
|
||||
initialize_history_from_excel()
|
||||
|
||||
# Create list for current scraped data
|
||||
new_rows = []
|
||||
current_time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
for row in scraped_data:
|
||||
new_rows.append({
|
||||
"زمان ثبت": current_time_str,
|
||||
"نام سکه": row["Name"],
|
||||
"قیمت فعلی (ریال)": row["Price_Rial"],
|
||||
"قیمت فعلی (تومان)": row["Price_Toman"],
|
||||
"میزان و درصد تغییر": row["Change"],
|
||||
"کمترین قیمت روز (تومان)": row["Min_Price_Toman"],
|
||||
"بیشترین قیمت روز (تومان)": row["Max_Price_Toman"],
|
||||
"زمان بهروزرسانی سایت": row["Last_Update"]
|
||||
})
|
||||
|
||||
import pandas as pd
|
||||
df_new = pd.DataFrame(new_rows)
|
||||
df_latest = df_new.drop(columns=["زمان ثبت"])
|
||||
|
||||
# Accumulate history in memory list
|
||||
temp_history_log = HISTORY_LOG + new_rows
|
||||
df_history = pd.DataFrame(temp_history_log)
|
||||
|
||||
# Write both sheets to Excel
|
||||
try:
|
||||
with pd.ExcelWriter(excel_filename, engine='openpyxl') as writer:
|
||||
df_latest.to_excel(writer, sheet_name="قیمتهای لحظهای", index=False)
|
||||
df_history.to_excel(writer, sheet_name="تاریخچه تغییرات", index=False)
|
||||
|
||||
# Write succeeded! Commit the temp log to our persistent memory HISTORY_LOG
|
||||
HISTORY_LOG = temp_history_log
|
||||
print(f"📊 Excel file updated: '{excel_filename}' (Latest & History sheets)")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Excel file is locked (likely open in Microsoft Excel).")
|
||||
print(f"⚠️ {len(new_rows)} rows kept in memory and will auto-save on the next minute cycle when you close Excel. (Error: {e})")
|
||||
return False
|
||||
|
||||
def run_scraper_cycle():
|
||||
"""Runs a single cycle of the scraper."""
|
||||
url = "https://www.tgju.org/coin"
|
||||
html = fetch_html_with_retry(url)
|
||||
|
||||
if not html:
|
||||
print("📡 Connection to TGJU failed. Retrying in next cycle...")
|
||||
return False
|
||||
|
||||
market_rows = re.findall(r'<tr[^>]*data-market-row="([^"]+)"[^>]*>([\s\S]*?)<\/tr>', html)
|
||||
|
||||
target_keys = {
|
||||
"sekee": "سکه امامی",
|
||||
"sekeb": "سکه بهار آزادی",
|
||||
"nim": "نیم سکه",
|
||||
"rob": "ربع سکه",
|
||||
"gerami": "سکه گرمی"
|
||||
}
|
||||
|
||||
scraped_data = []
|
||||
|
||||
for row_name, row_content in market_rows:
|
||||
if row_name in target_keys:
|
||||
coin_display_name = target_keys[row_name]
|
||||
tds = re.findall(r'<td[^>]*>([\s\S]*?)<\/td>', row_content)
|
||||
|
||||
if len(tds) >= 5:
|
||||
raw_price_rial = clean_persian_text(tds[0])
|
||||
raw_change = clean_persian_text(tds[1])
|
||||
raw_min = clean_persian_text(tds[2])
|
||||
raw_max = clean_persian_text(tds[3])
|
||||
raw_time = clean_persian_text(tds[4])
|
||||
|
||||
price_rial = parse_numeric(raw_price_rial)
|
||||
price_toman = price_rial // 10
|
||||
|
||||
min_rial = parse_numeric(raw_min)
|
||||
min_toman = min_rial // 10
|
||||
|
||||
max_rial = parse_numeric(raw_max)
|
||||
max_toman = max_rial // 10
|
||||
|
||||
clean_change = to_english_digits(raw_change)
|
||||
clean_time = to_english_digits(raw_time)
|
||||
|
||||
scraped_data.append({
|
||||
"Key": row_name,
|
||||
"Name": coin_display_name,
|
||||
"Price_Rial": price_rial,
|
||||
"Price_Toman": price_toman,
|
||||
"Change": clean_change,
|
||||
"Min_Price_Toman": min_toman,
|
||||
"Max_Price_Toman": max_toman,
|
||||
"Last_Update": clean_time
|
||||
})
|
||||
|
||||
if not scraped_data:
|
||||
print("⚠️ No coin rows were matched in the HTML. Table structure may have changed.")
|
||||
return False
|
||||
|
||||
# Save Excel
|
||||
try:
|
||||
save_to_excel(scraped_data)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error preparing Excel: {e}")
|
||||
|
||||
# Save JSON and JS for the dynamic HTML dashboard
|
||||
try:
|
||||
save_live_js_and_json(scraped_data)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error saving live assets: {e}")
|
||||
|
||||
# Print safely to console
|
||||
print("\n" + "=" * 80)
|
||||
print(f"{'نام سکه':<20} | {'قیمت (تومان)':<18} | {'تغییر':<18} | {'بهروزرسانی':<12}")
|
||||
print("-" * 80)
|
||||
for row in scraped_data:
|
||||
name_ascii = row["Name"].ljust(20)
|
||||
price_str = f"{row['Price_Toman']:,}"
|
||||
change_str = row["Change"]
|
||||
time_str = row["Last_Update"]
|
||||
try:
|
||||
print(f"{name_ascii} | {price_str:<18} | {change_str:<18} | {time_str:<12}")
|
||||
except Exception:
|
||||
print(f"{row['Key'].ljust(20)} | {price_str:<18} | {change_str:<18} | {time_str:<12}")
|
||||
print("=" * 80 + "\n")
|
||||
return True
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" 🪙 TGJU AUTOMATED COIN SCRAPER & SCHEDULER 🪙")
|
||||
print(" 🔄 Running every 1 minute | Output: Excel & Realtime HTML")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"🚀 Initializing scraper at {datetime.now().strftime('%H:%M:%S')}...")
|
||||
run_scraper_cycle()
|
||||
|
||||
while True:
|
||||
try:
|
||||
for remaining in range(60, 0, -1):
|
||||
sys.stdout.write(f"\r⏳ Next fetch in {remaining:02d} seconds... ")
|
||||
sys.stdout.flush()
|
||||
time.sleep(1)
|
||||
sys.stdout.write("\r📡 Fetching live prices... \n")
|
||||
sys.stdout.flush()
|
||||
|
||||
run_scraper_cycle()
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 Scraper stopped by user. Goodbye!")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"\n⚠️ Unexpected error in loop: {e}")
|
||||
print("⏳ Retrying in 10 seconds...")
|
||||
time.sleep(10)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue