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:
alireza 2026-06-27 16:00:59 +03:30
parent 6a3b0db2ec
commit 406717be13
14 changed files with 178 additions and 1465 deletions

4
.gitignore vendored
View File

@ -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

View File

@ -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

View File

@ -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`).

View File

@ -5,7 +5,7 @@
services:
web:
build:
context: .
context: ./frontend
dockerfile: Dockerfile
image: andishkade-foolad:latest
container_name: andishkade-foolad

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

View File

@ -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",

View File

@ -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",

View File

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3e22f4390856dd4995a930e22c5026008b333d6b5429d4201ae71d6b3a397c41
size 43690639

View File

@ -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>

View File

@ -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"
}
]
};

View File

@ -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"
}
]
}

View File

@ -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');
}
});

BIN
rtk.exe

Binary file not shown.

View File

@ -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()