feat: add source websites with automated endpoint analysis, target queue dispatch ordering, and markdown styling

This commit is contained in:
mamad
2026-08-28 22:05:48 +03:30
parent 462ad3be93
commit 446f3ea041
11 changed files with 1100 additions and 32 deletions
+17 -8
View File
@@ -2,12 +2,21 @@
👤 <b>ویرایش‌کننده:</b> <code>mamad</code>
🔹 <b>ارسال پیام‌های زمینه کانال مبدا به هوش مصنوعی (Source Context History):</b>
افزودن گزینه تنظیم تعداد پیام‌های قبلی کانال مبدا (۰ تا ۱۰ پیام) در منوی مدیریت کانال‌های مبدا در ربات تلگرام.
استخراج خودکار آخرین پیام‌های معتبر هر کانال و تزریق آن‌ها به عنوان «زمینه و خط داستانی» به پرامپت هوش مصنوعی تا پست جدید با آگاهی از سیر رویدادهای قبلی بازنویسی شود.
📁 <i>فایل‌های تغییریافته:</i> <code>services/ai_processor.py</code>, <code>services/admin_bot.py</code>, <code>db/models.py</code>, <code>db/repository.py</code>, <code>tests/test_context_and_source_time.py</code>
🔹 <b>وبسایت‌های مبدا و کشف خودکار API با هوش مصنوعی (Source Websites & AI Discovery):</b>
امکان تعریف انواع وبسایت‌ها و خبرگزاری‌ها به عنوان مبدا ورودی محتوا در کنار کانال‌های تلگرام.
تحلیل خودکار ساختار سایت، کشف اندپوینت‌های REST API و فیدهای RSS توسط هوش مصنوعی و ذخیره در تنظیمات وبسایت.
• استخراج دوره‌ای اخبار جدید، تشخیص تکراری بودن با هوش مصنوعی و ارسال به صف انتشار کانال‌های مقصد.
• اعلام هوشمند خطاهای دریافت در کانال ادمین همراه با کلید «🤖 تحلیل مجدد هوشمند AI و تولید مجدد API».
📁 <i>فایل‌های تغییریافته:</i> <code>services/website_analyzer.py</code>, <code>services/website_collector.py</code>, <code>services/admin_bot.py</code>, <code>db/database.py</code>, <code>db/models.py</code>, <code>db/repository.py</code>, <code>main.py</code>
🔹 <b>ثبت زمان واقعی ارسال پست در مبدا (Source Sent Timestamp):</b>
• افزودن فیلد <code>source_created_at</code> به پایگاه‌داده و ذخیره مستقیم زمان واقعی ارسال پست در تلگرام (به جای صرفاً زمان دریافت محلی).
• مرتب‌سازی دقیق و زمانی پیام‌های اخیر بر اساس تاریخچه انتشار واقعی در کانال.
📁 <i>فایل‌های تغییریافته:</i> <code>db/database.py</code>, <code>db/models.py</code>, <code>services/collector.py</code>
🔹 <b>تنظیم شیوه ارسال پست‌ها از صف (ترتیبی FIFO یا تصادفی):</b>
• افزودن گزینه «🔀 شیوه ارسال» در تنظیمات هر کانال مقصد با قابلیت سوییچ بین حالت‌های <b>«به ترتیب ورود»</b> و <b>«تصادفی (Random)»</b>.
📁 <i>فایل‌های تغییریافته:</i> <code>core/queue.py</code>, <code>services/publisher.py</code>, <code>services/admin_bot.py</code>, <code>db/models.py</code>, <code>db/repository.py</code>
🔹 <b>پشتیبانی کامل از استایل‌های Markdown در ارسال پست‌ها به تلگرام:</b>
• پشتیبانی از فرمت‌بندی غنی Markdown (بولد، ایتالیک، لینک‌ها و کدهای برنامه‌نویسی) خروجی هوش مصنوعی در ارسال به کانال‌های مقصد.
📁 <i>فایل‌های تغییریافته:</i> <code>services/publisher.py</code>
🔹 <b>مدیریت هوشمند ارسال گزارش انتشار (Release Notes Broadcast Hash Tracking):</b>
• ردیابی هش گزارش تغییرات جهت جلوگیری از ارسال پیام‌های تکراری هنگام ری‌استارت سیستم.
📁 <i>فایل‌های تغییریافته:</i> <code>services/admin_bot.py</code>
+17 -2
View File
@@ -30,11 +30,26 @@ class RedisQueue:
await self.client.rpush(key, raw_json)
logger.info(f"Enqueued post {payload.get('post_id')} to Target #{target_id} queue [{key}]")
async def pop_target_post(self, target_id: int) -> Optional[Dict[str, Any]]:
async def pop_target_post(self, target_id: int, dispatch_order: str = "order") -> Optional[Dict[str, Any]]:
if not self.client:
await self.connect()
key = self._get_target_key(target_id)
raw = await self.client.lpop(key)
if dispatch_order == "random":
import random
length = await self.client.llen(key)
if length == 0:
return None
if length == 1:
raw = await self.client.lpop(key)
else:
idx = random.randint(0, length - 1)
raw = await self.client.lindex(key, idx)
if raw:
await self.client.lrem(key, 1, raw)
else:
raw = await self.client.lpop(key)
if raw:
try:
return json.loads(raw)
+20
View File
@@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS targets (
is_sleep_enabled BOOLEAN DEFAULT FALSE,
auto_source_ids BIGINT[] DEFAULT '{}',
language VARCHAR(32) DEFAULT 'fa',
dispatch_order VARCHAR(32) DEFAULT 'order',
last_post_time TIMESTAMPTZ,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
@@ -131,6 +132,24 @@ CREATE TABLE IF NOT EXISTS channel_categories (
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS source_websites (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
url TEXT NOT NULL UNIQUE,
category_id INT REFERENCES channel_categories(id) ON DELETE SET NULL,
check_interval_min INT DEFAULT 30,
auto_reanalyze_hours INT DEFAULT 24,
last_reanalyzed_at TIMESTAMPTZ,
last_fetched_at TIMESTAMPTZ,
api_config JSONB DEFAULT '{}'::jsonb,
last_error TEXT,
last_error_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_source_websites_active ON source_websites(is_active);
CREATE INDEX IF NOT EXISTS idx_ai_providers_active ON ai_providers(is_active);
-- Migration safety for existing tables
@@ -152,6 +171,7 @@ ALTER TABLE targets ADD COLUMN IF NOT EXISTS is_sleep_enabled BOOLEAN DEFAULT FA
ALTER TABLE targets ADD COLUMN IF NOT EXISTS auto_source_ids BIGINT[] DEFAULT '{}';
ALTER TABLE targets ADD COLUMN IF NOT EXISTS language VARCHAR(32) DEFAULT 'fa';
ALTER TABLE targets ADD COLUMN IF NOT EXISTS custom_prompt TEXT DEFAULT '';
ALTER TABLE targets ADD COLUMN IF NOT EXISTS dispatch_order VARCHAR(32) DEFAULT 'order';
ALTER TABLE sources ADD COLUMN IF NOT EXISTS context_message_count INT DEFAULT 0;
ALTER TABLE posts ADD COLUMN IF NOT EXISTS source_created_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_posts_source_created ON posts(source_channel_id, source_created_at DESC);
+17
View File
@@ -20,6 +20,22 @@ class SourceChannel:
is_active: bool = True
created_at: Optional[str] = None
@dataclass
class SourceWebsite:
id: Optional[int]
name: str
url: str
category_id: Optional[int] = None
check_interval_min: int = 30
auto_reanalyze_hours: int = 24
last_reanalyzed_at: Optional[str] = None
last_fetched_at: Optional[str] = None
api_config: Dict[str, Any] = field(default_factory=dict)
last_error: Optional[str] = None
last_error_at: Optional[str] = None
is_active: bool = True
created_at: Optional[str] = None
@dataclass
class TargetChannel:
id: Optional[int]
@@ -37,6 +53,7 @@ class TargetChannel:
# Telegram channel_ids of sources whose posts are queued to this target automatically.
auto_source_ids: List[int] = field(default_factory=list)
language: str = "fa"
dispatch_order: str = "order" # "order" (FIFO) or "random"
last_post_time: Optional[str] = None
is_active: bool = True
created_at: Optional[str] = None
+133 -1
View File
@@ -2,7 +2,7 @@ import json
import asyncpg
from datetime import datetime, timezone
from typing import List, Optional, Dict, Any, Tuple
from db.models import SourceChannel, TargetChannel, Post, Setting, AILog, AIProviderProfile, ChannelCategory
from db.models import SourceChannel, SourceWebsite, TargetChannel, Post, Setting, AILog, AIProviderProfile, ChannelCategory
from db.database import get_db_pool
@@ -17,6 +17,18 @@ def _parse_post_row(row: asyncpg.Record) -> Post:
data["published_to"] = []
return Post(**data)
def _parse_source_website_row(row: asyncpg.Record) -> SourceWebsite:
data = dict(row)
if isinstance(data.get("api_config"), str):
try:
data["api_config"] = json.loads(data["api_config"])
except Exception:
data["api_config"] = {}
elif data.get("api_config") is None:
data["api_config"] = {}
return SourceWebsite(**data)
class Repository:
def __init__(self, dsn: Optional[str] = None):
self.dsn = dsn
@@ -72,6 +84,117 @@ class Repository:
async with pool.acquire() as conn:
await conn.execute("UPDATE sources SET is_active = FALSE WHERE id = $1;", source_id)
# --- Source Websites ---
async def add_source_website(
self,
name: str,
url: str,
category_id: Optional[int] = None,
check_interval_min: int = 30,
auto_reanalyze_hours: int = 24,
api_config: Optional[Dict[str, Any]] = None
) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
cfg_json = json.dumps(api_config or {})
row = await conn.fetchrow(
"""
INSERT INTO source_websites (name, url, category_id, check_interval_min, auto_reanalyze_hours, api_config)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
ON CONFLICT(url) DO UPDATE SET
name = EXCLUDED.name,
category_id = COALESCE(EXCLUDED.category_id, source_websites.category_id),
is_active = TRUE
RETURNING id;
""",
name, url, category_id, check_interval_min, auto_reanalyze_hours, cfg_json
)
return row["id"]
async def get_active_source_websites(self) -> List[SourceWebsite]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM source_websites WHERE is_active = TRUE ORDER BY id ASC;")
return [_parse_source_website_row(r) for r in rows]
async def get_source_websites(self) -> List[SourceWebsite]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM source_websites WHERE is_active = TRUE ORDER BY id ASC;")
return [_parse_source_website_row(r) for r in rows]
async def get_source_website_by_id(self, site_id: int) -> Optional[SourceWebsite]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM source_websites WHERE id = $1;", site_id)
return _parse_source_website_row(row) if row else None
async def get_source_websites_by_category(self, category_id: Optional[int]) -> List[SourceWebsite]:
pool = await self._get_pool()
async with pool.acquire() as conn:
if category_id is None:
rows = await conn.fetch("SELECT * FROM source_websites WHERE is_active = TRUE AND category_id IS NULL ORDER BY id ASC;")
else:
rows = await conn.fetch("SELECT * FROM source_websites WHERE is_active = TRUE AND category_id = $1 ORDER BY id ASC;", category_id)
return [_parse_source_website_row(r) for r in rows]
async def update_source_website_category(self, site_id: int, category_id: Optional[int]) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE source_websites SET category_id = $1 WHERE id = $2;", category_id, site_id)
async def update_source_website_api_config(self, site_id: int, api_config: Dict[str, Any]) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE source_websites
SET api_config = $1::jsonb,
last_reanalyzed_at = CURRENT_TIMESTAMP,
last_error = NULL,
last_error_at = NULL
WHERE id = $2;
""",
json.dumps(api_config), site_id
)
async def update_source_website_fetch_status(self, site_id: int, error: Optional[str] = None) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
if error:
await conn.execute(
"""
UPDATE source_websites
SET last_error = $1, last_error_at = CURRENT_TIMESTAMP
WHERE id = $2;
""",
error, site_id
)
else:
await conn.execute(
"""
UPDATE source_websites
SET last_fetched_at = CURRENT_TIMESTAMP, last_error = NULL, last_error_at = NULL
WHERE id = $1;
""",
site_id
)
async def update_source_website_interval(self, site_id: int, interval_min: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE source_websites SET check_interval_min = $1 WHERE id = $2;", max(1, interval_min), site_id)
async def update_source_website_reanalyze_hours(self, site_id: int, hours: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE source_websites SET auto_reanalyze_hours = $1 WHERE id = $2;", max(0, hours), site_id)
async def delete_source_website(self, site_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE source_websites SET is_active = FALSE WHERE id = $1;", site_id)
async def delete_target(self, target_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
@@ -142,6 +265,15 @@ class Repository:
custom_prompt, target_id
)
async def update_target_dispatch_order(self, target_id: int, dispatch_order: str) -> None:
order = "random" if str(dispatch_order).strip().lower() == "random" else "order"
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE targets SET dispatch_order = $1 WHERE id = $2;",
order, target_id
)
async def update_target_schedule(
self,
+15
View File
@@ -13,6 +13,8 @@ from services.ai_processor import AIProcessor
from services.collector import CollectorService
from services.admin_bot import AdminBotService
from services.publisher import PublisherService
from services.website_analyzer import WebsiteAnalyzer
from services.website_collector import WebsiteCollectorService
# Load environment variables
load_dotenv()
@@ -51,6 +53,17 @@ async def main():
)
ai_processor = AIProcessor(repo=repo, llm=llm)
admin_bot.set_ai_processor(ai_processor)
website_analyzer = WebsiteAnalyzer(llm=llm)
website_collector = WebsiteCollectorService(
repo=repo,
analyzer=website_analyzer,
ai_processor=ai_processor,
on_post_received=admin_bot.handle_collected_post,
on_error_alert=admin_bot.on_website_error_alert
)
admin_bot.set_website_services(website_analyzer, website_collector)
collector = CollectorService(repo=repo, on_post_received=admin_bot.handle_collected_post, ai_processor=ai_processor)
admin_bot.set_collector(collector)
@@ -67,6 +80,7 @@ async def main():
# 4. Start all services
await admin_bot.start()
await collector.start(notify_fn=admin_bot.notify_admins)
await website_collector.start()
await publisher.start()
logger.info("All Copykar services are active and running.")
@@ -91,6 +105,7 @@ async def main():
pass
finally:
logger.info("Shutting down Copykar services...")
await website_collector.stop()
await collector.stop()
await publisher.stop()
await admin_bot.stop()
+310 -6
View File
@@ -46,10 +46,11 @@ def get_persian_main_menu(is_paused: bool = False):
return [
[Button.text("📊 آمار و وضعیت ناوگان", resize=True), Button.text("🔑 درخواست کد لاگین", resize=True)],
[Button.text("📡 کانال‌های مبدا", resize=True), Button.text("🎯 کانال‌های مقصد", resize=True)],
[Button.text("📂 دسته‌بندی کانال‌ها", resize=True), Button.text("🧠 تنظیمات و لاگ‌های AI", resize=True)],
[Button.text("🌐 وبسایت‌های مبدا", resize=True), Button.text("📂 دسته‌بندی کانال‌ها", resize=True)],
[Button.text("🧠 تنظیمات و لاگ‌های AI", resize=True), Button.text("⚠️ خطاهای سیستم", resize=True)],
[Button.text(" افزودن کانال مبدا", resize=True), Button.text(" افزودن کانال مقصد", resize=True)],
[Button.text("📨 ارسال پست‌های بررسی‌نشده", resize=True), Button.text("⚠️ خطاهای سیستم", resize=True)],
[Button.text(pause_btn, resize=True), Button.text("❓ راهنمای سیستم", resize=True)]
[Button.text("📨 ارسال پست‌های بررسی‌نشده", resize=True), Button.text(pause_btn, resize=True)],
[Button.text("❓ راهنمای سیستم", resize=True)]
]
@@ -120,6 +121,23 @@ class AdminBotService:
def set_queue(self, queue: RedisQueue):
self.queue = queue
def set_website_services(self, analyzer, collector):
self.website_analyzer = analyzer
self.website_collector = collector
async def on_website_error_alert(self, alert_text: str, site_id: int):
buttons = [[Button.inline("🤖 تحلیل مجدد هوشمند وبسایت", data=f"web_reanalyze:{site_id}")]]
if self.review_channel_id:
try:
await self.client.send_message(self.review_channel_id, alert_text, parse_mode="html", buttons=buttons)
except Exception as e:
logger.error(f"Failed to send website alert to review channel: {e}")
for admin_id in self.admin_user_ids:
try:
await self.client.send_message(admin_id, alert_text, parse_mode="html", buttons=buttons)
except Exception:
pass
def is_admin(self, user_id: int) -> bool:
return not self.admin_user_ids or user_id in self.admin_user_ids
@@ -250,12 +268,16 @@ class AdminBotService:
cat_name = cat.name
custom_p = getattr(target, "custom_prompt", "") or ""
order_mode = getattr(target, "dispatch_order", "order") or "order"
order_label = "🟢 به ترتیب ورود (FIFO)" if order_mode == "order" else "🎲 تصادفی (Random)"
card_text = (
f"🎯 <b>تنظیمات کانال مقصد:</b> <b>{target.title}</b>\n\n"
f"• 🆔 <b>شناسه کانال:</b> <code>{target.channel_id}</code> (ID: <code>{target.id}</code>)\n"
f"• 🔗 <b>یوزرنیم:</b> @{target.username or 'ندارد'}\n"
f"• 📁 <b>دسته‌بندی:</b> <b>{cat_name}</b>\n"
f"• 🌐 <b>زبان کانال:</b> <b>{lang_label}</b>\n"
f"• 🔀 <b>ترتیب ارسال از صف:</b> <b>{order_label}</b>\n"
f"• ⏱ <b>فاصله ارسال پست‌ها:</b> هر <b>{target.post_interval_min} دقیقه</b>\n"
f"• 🌙 <b>وضعیت ساعت خواب:</b> <b>{sleep_st}</b>\n"
f"• 📥 <b>پست‌های منتظر در صف:</b> <b>{qsize} پست</b>\n"
@@ -280,10 +302,13 @@ class AdminBotService:
],
[
Button.inline("🌐 تنظیم زبان", data=f"st_lang:{target.id}"),
Button.inline("⏱ تغییر فاصله ارسال", data=f"st_intv:{target.id}")
Button.inline(f"🔀 شیوه ارسال ({'ترتیبی' if order_mode == 'order' else 'تصادفی'})", data=f"trg_order:{target.id}")
],
[
Button.inline("⏱ تغییر فاصله ارسال", data=f"st_intv:{target.id}"),
Button.inline("🌙 تنظیم ساعت خواب", data=f"st_slp:{target.id}")
],
[
Button.inline("🌙 تنظیم ساعت خواب", data=f"st_slp:{target.id}"),
Button.inline("🤖 ارسال خودکار از مبدا", data=f"auto_src:{target.id}")
]
]
@@ -905,6 +930,93 @@ class AdminBotService:
]
return text, buttons
async def _render_website_list(self) -> Tuple[str, List[List[Button]]]:
sites = await self.repo.get_source_websites()
if not sites:
text = (
"🌐 <b>لیست وبسایت‌های مبدا (Source Websites)</b>\n\n"
"هنوز هیچ وبسایت مبدایی ثبت نشده است.\n"
"شما می‌توانید هر وبسایت، خبرگزاری یا وبلاگی را به عنوان مبدا ثبت کنید تا هوش مصنوعی به‌طور خودکار ساختار آن را تحلیل و محتوای جدید را استخراج کند."
)
buttons = [
[Button.inline("➕ افزودن وبسایت مبدا جدید", data="add_web")],
[Button.inline("🔙 بازگشت به منوی اصلی", data="back_to_main")]
]
return text, buttons
text = f"🌐 <b>لیست وبسایت‌های مبدا ({len(sites)} وبسایت):</b>\n\nبرای مشاهده تنظیمات، دریافت دستی یا تحلیل مجدد روی وبسایت کلیک کنید:"
buttons = []
for s in sites:
status_icon = "🔴" if s.last_error else "🟢"
buttons.append([Button.inline(f"{status_icon} {s.name} ({s.check_interval_min}m)", data=f"web_view:{s.id}")])
buttons.append([Button.inline("➕ افزودن وبسایت مبدا جدید", data="add_web")])
buttons.append([Button.inline("🔙 بازگشت به منوی اصلی", data="back_to_main")])
return text, buttons
async def _render_website_config(self, site_id: int) -> Tuple[str, List[List[Button]]]:
site = await self.repo.get_source_website_by_id(site_id)
if not site:
return "❌ وبسایت یافت نشد.", []
cat_name = "بدون دسته‌بندی"
if site.category_id:
cat = await self.repo.get_category_by_id(site.category_id)
if cat:
cat_name = cat.name
api_cfg = site.api_config or {}
parser_type = api_cfg.get("parser_type", "نامشخص")
endpoint = api_cfg.get("endpoint_url", site.url)
err_block = f"⚠️ <b>خطای اخیر:</b> <code>{site.last_error}</code>\n\n" if site.last_error else ""
last_fe = site.last_fetched_at.strftime("%Y-%m-%d %H:%M") if site.last_fetched_at else "هنوز دریافت نشده"
last_re = site.last_reanalyzed_at.strftime("%Y-%m-%d %H:%M") if site.last_reanalyzed_at else "انجام نشده"
text = (
f"🌐 <b>وبسایت مبدا:</b> <b>{site.name}</b>\n\n"
f"• 🔗 <b>آدرس وبسایت:</b> {site.url}\n"
f"• 🔌 <b>اندپوینت دریافت:</b> <code>{endpoint}</code>\n"
f"• 🏷 <b>نوع ساختار:</b> <code>{parser_type}</code>\n"
f"• 📁 <b>دسته‌بندی:</b> <b>{cat_name}</b>\n"
f"• ⏱ <b>فاصله بررسی:</b> هر <b>{site.check_interval_min} دقیقه</b>\n"
f"• 🔄 <b>دوره تحلیل مجدد AI:</b> هر <b>{site.auto_reanalyze_hours} ساعت</b>\n"
f"• 📥 <b>آخرین دریافت:</b> <code>{last_fe}</code>\n"
f"• 🧠 <b>آخرین تحلیل هوش مصنوعی:</b> <code>{last_re}</code>\n\n"
f"{err_block}"
f"<i>👇 عملیات مورد نظر را انتخاب کنید:</i>"
)
buttons = [
[
Button.inline("🔄 دریافت دستی (Fetch Now)", data=f"web_fetch:{site.id}"),
Button.inline("🤖 تحلیل مجدد هوشمند AI", data=f"web_reanalyze:{site.id}")
],
[
Button.inline("⏱ تغییر فاصله بررسی", data=f"web_intv:{site.id}"),
Button.inline("🔄 دوره تحلیل مجدد AI", data=f"web_reintv:{site.id}")
],
[
Button.inline("📁 تعیین دسته‌بندی", data=f"web_cat:{site.id}"),
Button.inline("🗑 حذف این وبسایت", data=f"del_web:{site.id}")
],
[
Button.inline("🔙 بازگشت به لیست وبسایت‌ها", data="list_web")
]
]
return text, buttons
async def _render_website_category_menu(self, site_id: int):
site = await self.repo.get_source_website_by_id(site_id)
if not site:
return "❌ وبسایت یافت نشد.", []
cats = await self.repo.get_categories()
text = f"📁 <b>انتخاب دسته‌بندی برای وبسایت «{site.name}»:</b>"
buttons = []
for c in cats:
mark = "" if site.category_id == c.id else ""
buttons.append([Button.inline(f"{mark}{c.name}", data=f"web_set_cat:{site.id}:{c.id}")])
buttons.append([Button.inline("❌ بدون دسته‌بندی", data=f"web_set_cat:{site.id}:none")])
buttons.append([Button.inline("🔙 بازگشت به تنظیمات وبسایت", data=f"web_view:{site.id}")])
return text, buttons
async def _render_auto_sources(self, target_id: int):
"""Toggle screen listing every source with its on/off state for this target."""
@@ -1217,6 +1329,14 @@ class AdminBotService:
text, buttons = await self._render_target_list()
await event.reply(text, parse_mode="html", buttons=buttons or None)
# --- Source Websites ---
@self.client.on(events.NewMessage(pattern=r"(?i)^(/websites|/sites|🌐 وبسایت‌های مبدا)$"))
async def cmd_websites(event: events.NewMessage.Event):
if not self.is_admin(event.sender_id):
return
text, buttons = await self._render_website_list()
await event.reply(text, parse_mode="html", buttons=buttons or None)
# --- Categories Management ---
@self.client.on(events.NewMessage(pattern=r"(?i)^(/categories|/cats|📂 دسته‌بندی کانال‌ها)$"))
async def cmd_categories(event: events.NewMessage.Event):
@@ -1333,9 +1453,23 @@ class AdminBotService:
return "📢 <b>گزارش تغییرات سیستم (Release Notes):</b>\nسیستم با آخرین به‌روزرسانی‌ها در حال اجرا است."
async def broadcast_change_notes(self):
"""Send latest change notes to admins and review channel."""
"""Send latest change notes to admins and review channel if they are new."""
import hashlib
notes = self.get_latest_change_notes()
current_hash = hashlib.sha256(notes.encode("utf-8")).hexdigest()
last_hash = await self.repo.get_setting("last_broadcast_release_hash")
if last_hash == current_hash:
logger.info("Release notes have not changed since last broadcast. Skipping automatic broadcast.")
return
logger.info(f"Broadcasting new release notes (hash: {current_hash[:8]})...")
await self.notify_admins(notes)
await self.repo.set_setting(
"last_broadcast_release_hash",
current_hash,
description="Hash of last broadcast release notes"
)
# --- Step-by-Step Parameter & Text Input Message Handler ---
@@ -1593,6 +1727,73 @@ class AdminBotService:
menu = await self.get_menu()
await event.reply(res, parse_mode="html", buttons=menu)
# 8.1. Waiting for Website Input (Name and URL)
elif action == "wait_website_input":
self.user_states.pop(event.sender_id, None)
parts = text.split()
if len(parts) < 2:
menu = await self.get_menu()
await event.reply("❌ فرمت نامعتبر است. لطفاً نام و آدرس را با فاصله ارسال کنید (مثال: <code>دیجیاتو https://digiato.com</code>).", parse_mode="html", buttons=menu)
return
# If last part is URL:
if parts[-1].startswith(("http://", "https://", "www.")) or "." in parts[-1]:
url = parts[-1]
name = " ".join(parts[:-1])
else:
url = parts[0]
name = " ".join(parts[1:])
if not url.startswith("http://") and not url.startswith("https://"):
url = "https://" + url
loading_msg = await event.reply(
f"🤖 <b>در حال تحلیل وبسایت «{name}» ({url}) توسط هوش مصنوعی...</b>\n<i>(کشف خودکار REST APIها، فیدهای RSS و ساختار داده)</i>",
parse_mode="html"
)
api_cfg = {}
if self.website_analyzer:
ok, cfg, summary = await self.website_analyzer.analyze_website(url)
if ok and cfg:
api_cfg = cfg
site_id = await self.repo.add_source_website(name=name, url=url, api_config=api_cfg)
card, buttons = await self._render_website_config(site_id)
await loading_msg.edit(
f"✅ <b>وبسایت «{name}» با موفقیت افزوده و پیکربندی شد!</b>\n\n{card}",
parse_mode="html",
buttons=buttons
)
# 8.2. Waiting for Website Fetch Interval
elif action == "wait_website_intv":
site_id = state.get("site_id")
self.user_states.pop(event.sender_id, None)
try:
val = int(text.strip())
await self.repo.update_source_website_interval(site_id, val)
card, buttons = await self._render_website_config(site_id)
await event.reply(f"✅ فاصله بررسی وبسایت روی هر <b>{val} دقیقه</b> تنظیم شد.", parse_mode="html")
await event.reply(card, parse_mode="html", buttons=buttons)
except ValueError:
menu = await self.get_menu()
await event.reply("❌ مقدار وارد شده باید یک عدد صحیح (به دقیقه) باشد.", buttons=menu)
# 8.3. Waiting for Website Auto Re-analysis Interval
elif action == "wait_website_reintv":
site_id = state.get("site_id")
self.user_states.pop(event.sender_id, None)
try:
val = int(text.strip())
await self.repo.update_source_website_reanalyze_hours(site_id, val)
card, buttons = await self._render_website_config(site_id)
await event.reply(f"✅ دوره تحلیل مجدد خودکار وبسایت روی هر <b>{val} ساعت</b> تنظیم شد.", parse_mode="html")
await event.reply(card, parse_mode="html", buttons=buttons)
except ValueError:
menu = await self.get_menu()
await event.reply("❌ مقدار وارد شده باید یک عدد صحیح (به ساعت) باشد.", buttons=menu)
# 9. Waiting for AI Model
elif action == "wait_ai_model":
self.user_states.pop(event.sender_id, None)
@@ -2275,6 +2476,109 @@ class AdminBotService:
await event.edit(card, parse_mode="html", buttons=buttons)
await event.answer()
elif data == "list_web":
text, buttons = await self._render_website_list()
await event.edit(text, parse_mode="html", buttons=buttons or None)
await event.answer()
elif data.startswith("web_view:"):
site_id = int(data.split(":")[1])
card, buttons = await self._render_website_config(site_id)
await event.edit(card, parse_mode="html", buttons=buttons)
await event.answer()
elif data == "add_web":
self.user_states[event.sender_id] = {"action": "wait_website_input"}
guide = (
"🌐 <b>افزودن وبسایت مبدا جدید:</b>\n\n"
"لطفاً نام وبسایت و آدرس URL آن را با یک فاصله یا در دو خط ارسال کنید:\n"
"<i>(مثال: <code>دیجیاتو https://digiato.com</code>)</i>\n\n"
"🤖 هوش مصنوعی بلافاصله وبسایت را بررسی کرده و اندپوینت‌های دریافت خبر را کشف می‌کند."
)
await event.reply(guide, parse_mode="html", buttons=get_cancel_button())
await event.answer()
elif data.startswith("web_fetch:"):
site_id = int(data.split(":")[1])
await event.answer("🔄 در حال دریافت آخرین پست‌ها از وبسایت...")
cnt = await self.website_collector.fetch_website_posts(site_id) if self.website_collector else 0
card, buttons = await self._render_website_config(site_id)
await event.edit(card, parse_mode="html", buttons=buttons)
await event.answer(f"✅ تعداد {cnt} پست جدید دریافت و پردازش شد.", alert=True)
elif data.startswith("web_reanalyze:"):
site_id = int(data.split(":")[1])
await event.answer("🤖 در حال تحلیل مجدد ساختار وبسایت توسط هوش مصنوعی...")
ok, msg = await self.website_collector.reanalyze_website(site_id) if self.website_collector else (False, "سرویس در دسترس نیست")
card, buttons = await self._render_website_config(site_id)
await event.edit(card, parse_mode="html", buttons=buttons)
await event.answer("✅ تحلیل مجدد با موفقیت انجام شد." if ok else f"❌ خطا: {msg}", alert=True)
elif data.startswith("web_intv:"):
site_id = int(data.split(":")[1])
site = await self.repo.get_source_website_by_id(site_id)
if not site:
await event.answer("وبسایت یافت نشد.", alert=True)
return
self.user_states[event.sender_id] = {"action": "wait_website_intv", "site_id": site_id}
await event.reply(
f"⏱ <b>تنظیم فاصله بررسی وبسایت «{site.name}»:</b>\n\n"
f"فاصله زمانی بررسی و استخراج اخبار (به دقیقه) را ارسال کنید:\n"
f"<i>(مثال: <code>30</code> برای هر ۳۰ دقیقه)</i>",
parse_mode="html",
buttons=get_cancel_button()
)
await event.answer()
elif data.startswith("web_reintv:"):
site_id = int(data.split(":")[1])
site = await self.repo.get_source_website_by_id(site_id)
if not site:
await event.answer("وبسایت یافت نشد.", alert=True)
return
self.user_states[event.sender_id] = {"action": "wait_website_reintv", "site_id": site_id}
await event.reply(
f"🔄 <b>تنظیم دوره تحلیل مجدد هوشمند وبسایت «{site.name}»:</b>\n\n"
f"فاصله تحلیل خودکار ساختار و کشف اندپوینت‌ها توسط AI (به ساعت) را ارسال کنید:\n"
f"<i>(مثال: <code>24</code> برای هر ۲۴ ساعت، یا <code>0</code> برای غیرفعال‌سازی)</i>",
parse_mode="html",
buttons=get_cancel_button()
)
await event.answer()
elif data.startswith("web_cat:"):
site_id = int(data.split(":")[1])
text, buttons = await self._render_website_category_menu(site_id)
await event.edit(text, parse_mode="html", buttons=buttons)
await event.answer()
elif data.startswith("web_set_cat:"):
_, site_id_str, cat_id_str = data.split(":")
site_id = int(site_id_str)
cat_id = None if cat_id_str == "none" else int(cat_id_str)
await self.repo.update_source_website_category(site_id, cat_id)
card, buttons = await self._render_website_config(site_id)
await event.edit(card, parse_mode="html", buttons=buttons)
await event.answer("✅ دسته‌بندی وبسایت به‌روز شد.")
elif data.startswith("del_web:"):
site_id = int(data.split(":")[1])
await self.repo.delete_source_website(site_id)
text, buttons = await self._render_website_list()
await event.edit(text, parse_mode="html", buttons=buttons or None)
await event.answer("🗑 وبسایت مبدا با موفقیت حذف شد.", alert=True)
elif data.startswith("trg_order:"):
target_id = int(data.split(":")[1])
target = await self.repo.get_target_by_id(target_id)
if target:
curr_order = getattr(target, "dispatch_order", "order") or "order"
new_order = "random" if curr_order == "order" else "order"
await self.repo.update_target_dispatch_order(target_id, new_order)
card, buttons = await self._render_target_config(target_id)
await event.edit(card, parse_mode="html", buttons=buttons)
await event.answer("🎲 شیوه ارسال روی تصادفی تنظیم شد." if new_order == "random" else "🟢 شیوه ارسال روی به ترتیب تنظیم شد.")
elif data.startswith("auto_src:"):
target_id = int(data.split(":")[1])
text, buttons = await self._render_auto_sources(target_id)
+31 -15
View File
@@ -2,7 +2,7 @@ import os
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional, List, Awaitable, Callable, Set, Tuple
from typing import Optional, List, Awaitable, Callable, Set, Tuple, Any
from telethon import TelegramClient
from telethon.errors import (
ChannelPrivateError,
@@ -35,6 +35,33 @@ PERMANENT_DELIVERY_ERRORS = (
PeerIdInvalidError,
)
async def send_styled_message(client: TelegramClient, entity: Any, text: str, file_path: Optional[str] = None):
"""Deliver message or media trying Markdown first, then HTML, then raw plain text."""
if not text and not file_path:
return None
# 1. Try standard Markdown first (AI's natural formatting)
try:
if file_path and os.path.exists(file_path):
return await client.send_file(entity, file=file_path, caption=text, parse_mode="md")
return await client.send_message(entity, text, parse_mode="md")
except Exception as md_err:
logger.debug(f"Markdown send failed ({md_err}), trying HTML...")
# 2. Try HTML format fallback
try:
if file_path and os.path.exists(file_path):
return await client.send_file(entity, file=file_path, caption=text, parse_mode="html")
return await client.send_message(entity, text, parse_mode="html")
except Exception as html_err:
logger.debug(f"HTML send failed ({html_err}), falling back to plain text...")
# 3. Plain text safety fallback
if file_path and os.path.exists(file_path):
return await client.send_file(entity, file=file_path, caption=text, parse_mode=None)
return await client.send_message(entity, text, parse_mode=None)
class PublisherService:
def __init__(
self,
@@ -129,7 +156,8 @@ class PublisherService:
continue
# 3. Pop next post payload for this target
payload = await self.queue.pop_target_post(target.id)
disp_order = getattr(target, "dispatch_order", "order") or "order"
payload = await self.queue.pop_target_post(target.id, dispatch_order=disp_order)
if not payload:
continue
@@ -138,19 +166,7 @@ class PublisherService:
media_path = payload.get("media_path")
try:
if media_path and os.path.exists(media_path):
await self.client.send_file(
target.channel_id,
file=media_path,
caption=text,
parse_mode="html"
)
else:
await self.client.send_message(
target.channel_id,
text,
parse_mode="html"
)
await send_styled_message(self.client, target.channel_id, text, file_path=media_path)
# Record publication in database
await self.repo.record_post_published_to_target(post_id, target.id, target.title or "Target")
+130
View File
@@ -0,0 +1,130 @@
import re
import json
import logging
import httpx
from typing import Optional, Dict, Any, Tuple
from core.llm import LLMClient
logger = logging.getLogger(__name__)
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
WEBSITE_ANALYSIS_SYSTEM_PROMPT = """
You are an expert web scraping and REST API discovery agent.
Your job is to analyze website HTML, RSS feeds, or API responses, and configure the optimal extraction schema for pulling the latest articles or news posts.
You MUST respond with valid JSON matching this schema:
{
"status": "success",
"endpoint_url": "URL to fetch latest items from (e.g. RSS feed, WordPress JSON endpoint, or HTML page)",
"parser_type": "rss" | "wordpress_json" | "rest_json" | "html",
"http_method": "GET",
"headers": {
"User-Agent": "Mozilla/5.0 ..."
},
"items_path": "JSON key containing the list of items (e.g. 'items', 'articles', 'data') or empty if root is a list or RSS channel.item",
"field_mappings": {
"title": "field or selector for article title",
"content": "field or selector for summary or body text",
"link": "field or selector for full article URL",
"image_url": "field or selector for featured image URL (or empty if none)",
"published_at": "field or selector for date/time (or empty)"
},
"summary": "Brief Persian explanation of the discovered API/source (e.g. 'از طریق API رسمی وردپرس شناسایی شد')."
}
"""
class WebsiteAnalyzer:
def __init__(self, llm: LLMClient):
self.llm = llm
async def analyze_website(self, url: str) -> Tuple[bool, Dict[str, Any], str]:
"""Discover endpoints and prompt AI to build the extractor schema."""
clean_url = url.strip()
if not clean_url.startswith("http://") and not clean_url.startswith("https://"):
clean_url = "https://" + clean_url
discovered_info = []
sample_data = ""
# 1. Fetch homepage and look for RSS/JSON endpoints
headers = {"User-Agent": USER_AGENT, "Accept": "*/*"}
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
try:
resp = await client.get(clean_url, headers=headers)
resp.raise_for_status()
html_text = resp.text
# Check RSS / Atom links in HTML
rss_links = re.findall(r'<link[^>]+type=["\']application/(?:rss|atom)\+xml["\'][^>]+href=["\']([^"\']+)["\']', html_text, re.I)
if not rss_links:
rss_links = re.findall(r'<link[^>]+href=["\']([^"\']+)["\'][^>]+type=["\']application/(?:rss|atom)\+xml["\']', html_text, re.I)
# Check for WordPress REST API link
wp_api = re.findall(r'<link[^>]+rel=["\']https://api\.w\.org/["\'][^>]+href=["\']([^"\']+)["\']', html_text, re.I)
if wp_api:
discovered_info.append(f"Discovered WordPress REST API base: {wp_api[0]}")
try:
wp_posts_url = wp_api[0].rstrip('/') + '/wp/v2/posts?per_page=5'
wp_res = await client.get(wp_posts_url, headers=headers)
if wp_res.status_code == 200:
discovered_info.append(f"WordPress Posts API returned 200 OK: {wp_posts_url}")
sample_data = f"Sample from WordPress API ({wp_posts_url}):\n" + wp_res.text[:2500]
except Exception as e:
logger.debug(f"WP test fetch error: {e}")
if not sample_data and rss_links:
feed_url = rss_links[0]
if not feed_url.startswith("http"):
from urllib.parse import urljoin
feed_url = urljoin(clean_url, feed_url)
discovered_info.append(f"Discovered RSS feed link: {feed_url}")
try:
feed_res = await client.get(feed_url, headers=headers)
if feed_res.status_code == 200:
sample_data = f"Sample from RSS Feed ({feed_url}):\n" + feed_res.text[:2500]
except Exception as e:
logger.debug(f"RSS test fetch error: {e}")
# If no direct feed found, test common paths: /feed, /rss
if not sample_data:
for common_path in ["/feed", "/rss", "/feed.xml", "/rss.xml", "/wp-json/wp/v2/posts?per_page=5"]:
try:
from urllib.parse import urljoin
test_url = urljoin(clean_url, common_path)
test_res = await client.get(test_url, headers=headers)
if test_res.status_code == 200 and ("xml" in test_res.headers.get("content-type", "") or "json" in test_res.headers.get("content-type", "")):
discovered_info.append(f"Found working feed at: {test_url}")
sample_data = f"Sample from {test_url}:\n" + test_res.text[:2500]
break
except Exception:
pass
# If still no feed, provide HTML snippet
if not sample_data:
discovered_info.append("No standard RSS/API feed found; analyzing HTML structure.")
sample_data = f"HTML head/body sample from {clean_url}:\n" + html_text[:3000]
except Exception as e:
return False, {}, f"خطا در برقراری ارتباط با وبسایت: {e}"
prompt = (
f"Website Target URL: {clean_url}\n"
f"Discovery Notes:\n" + "\n".join(discovered_info) + "\n\n"
f"Data / Feed Sample:\n{sample_data}\n\n"
f"Determine the best endpoint_url, parser_type, and field mappings to regularly extract the latest news/articles from this site."
)
try:
res = await self.llm.generate_json(
prompt=prompt,
system_prompt=WEBSITE_ANALYSIS_SYSTEM_PROMPT,
action_name="analyze_source_website"
)
if not isinstance(res, dict) or not res.get("endpoint_url"):
return False, {}, "پاسخ هوش مصنوعی شامل ساختار معتبر API برای این سایت نبود."
return True, res, res.get("summary", "سایت با موفقیت تحلیل شد.")
except Exception as e:
return False, {}, f"خطا در تحلیل ساختار توسط هوش مصنوعی: {e}"
+263
View File
@@ -0,0 +1,263 @@
import os
import re
import html
import asyncio
import logging
import hashlib
import xml.etree.ElementTree as ET
from datetime import datetime, timezone, timedelta
from typing import Optional, List, Dict, Any, Callable, Awaitable
import httpx
from db.models import SourceWebsite
from db.repository import Repository
from services.website_analyzer import WebsiteAnalyzer, USER_AGENT
from core.error_logger import log_exception
logger = logging.getLogger(__name__)
def _clean_html(raw_html: str) -> str:
if not raw_html:
return ""
text = re.sub(r'<[^>]+>', ' ', raw_html)
text = html.unescape(text)
return re.sub(r'\s+', ' ', text).strip()
def _extract_nested(data: Any, path: str) -> Any:
if not path or not data:
return data
parts = path.split(".")
curr = data
for p in parts:
if isinstance(curr, dict):
curr = curr.get(p)
elif isinstance(curr, list) and p.isdigit():
idx = int(p)
curr = curr[idx] if idx < len(curr) else None
else:
return None
return curr
class WebsiteCollectorService:
def __init__(
self,
repo: Repository,
analyzer: WebsiteAnalyzer,
ai_processor = None,
on_post_received: Optional[Callable[[int], Awaitable[None]]] = None,
on_error_alert: Optional[Callable[[str, List[Any]], Awaitable[None]]] = None,
):
self.repo = repo
self.analyzer = analyzer
self.ai_processor = ai_processor
self.on_post_received = on_post_received
self.on_error_alert = on_error_alert
self._running = False
self._task: Optional[asyncio.Task] = None
async def start(self):
logger.info("Starting Website Collector Service...")
self._running = True
self._task = asyncio.create_task(self._collector_loop())
async def stop(self):
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("Website Collector Service stopped.")
async def _collector_loop(self):
while self._running:
try:
if not await self.repo.is_system_paused():
await self._process_websites()
except Exception as e:
await log_exception("website_collector.loop", e)
await asyncio.sleep(60)
async def _process_websites(self):
websites = await self.repo.get_active_source_websites()
now = datetime.now(timezone.utc)
for site in websites:
try:
# 1. Check Periodic Auto Re-analysis
if site.auto_reanalyze_hours > 0 and site.last_reanalyzed_at:
last_re = site.last_reanalyzed_at
if last_re.tzinfo is None:
last_re = last_re.replace(tzinfo=timezone.utc)
if (now - last_re).total_seconds() / 3600.0 >= site.auto_reanalyze_hours:
logger.info(f"Auto re-analyzing website #{site.id} ({site.name})...")
await self.reanalyze_website(site.id)
# 2. Check Fetch Interval
if site.last_fetched_at:
last_fe = site.last_fetched_at
if last_fe.tzinfo is None:
last_fe = last_fe.replace(tzinfo=timezone.utc)
if (now - last_fe).total_seconds() / 60.0 < site.check_interval_min:
continue
await self.fetch_website_posts(site.id)
except Exception as e:
logger.error(f"Error processing website {site.name}: {e}", exc_info=True)
async def reanalyze_website(self, site_id: int) -> Tuple[bool, str]:
site = await self.repo.get_source_website_by_id(site_id)
if not site:
return False, "وبسایت یافت نشد."
ok, api_cfg, summary = await self.analyzer.analyze_website(site.url)
if ok and api_cfg:
await self.repo.update_source_website_api_config(site_id, api_cfg)
return True, f"✅ وبسایت «{site.name}» با موفقیت تحلیل شد:\n{summary}"
else:
err_msg = summary or "خطا در تحلیل وبسایت"
await self.repo.update_source_website_fetch_status(site_id, error=err_msg)
if self.on_error_alert:
await self.on_error_alert(
f"⚠️ <b>خطا در تحلیل مجدد هوشمند وبسایت «{site.name}»:</b>\n<code>{err_msg}</code>",
site_id
)
return False, err_msg
async def fetch_website_posts(self, site_id: int) -> int:
site = await self.repo.get_source_website_by_id(site_id)
if not site or not site.is_active:
return 0
api_cfg = site.api_config or {}
endpoint_url = api_cfg.get("endpoint_url")
if not endpoint_url:
# Trigger initial analysis
ok, new_cfg, _ = await self.analyzer.analyze_website(site.url)
if ok and new_cfg:
await self.repo.update_source_website_api_config(site_id, new_cfg)
api_cfg = new_cfg
endpoint_url = api_cfg.get("endpoint_url")
else:
await self.repo.update_source_website_fetch_status(site_id, error="بدون اندپوینت معتبر")
return 0
headers = api_cfg.get("headers") or {"User-Agent": USER_AGENT, "Accept": "*/*"}
parser_type = api_cfg.get("parser_type", "rss")
mappings = api_cfg.get("field_mappings", {})
articles = []
try:
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
resp = await client.get(endpoint_url, headers=headers)
resp.raise_for_status()
if parser_type == "wordpress_json" or "json" in resp.headers.get("content-type", "") or parser_type == "rest_json":
raw_data = resp.json()
items = _extract_nested(raw_data, api_cfg.get("items_path", ""))
if isinstance(items, list):
for item in items[:15]:
title = _clean_html(str(_extract_nested(item, mappings.get("title", "title.rendered")) or ""))
content = _clean_html(str(_extract_nested(item, mappings.get("content", "content.rendered")) or ""))
link = str(_extract_nested(item, mappings.get("link", "link")) or "")
img_url = str(_extract_nested(item, mappings.get("image_url", "yoast_head_json.og_image.0.url")) or "")
pub_at = _extract_nested(item, mappings.get("published_at", "date_gmt"))
if title and link:
articles.append({
"title": title,
"content": content,
"link": link,
"image_url": img_url if img_url.startswith("http") else None,
"published_at": pub_at
})
elif parser_type == "rss" or "xml" in resp.headers.get("content-type", ""):
root = ET.fromstring(resp.content)
channel = root.find("channel") or root
items = channel.findall("item") or root.findall(".//item")
for item in items[:15]:
t_node = item.find("title")
l_node = item.find("link")
d_node = item.find("description")
p_node = item.find("pubDate")
enc_node = item.find("enclosure")
title = _clean_html(t_node.text if t_node is not None else "")
link = (l_node.text or "").strip() if l_node is not None else ""
desc = _clean_html(d_node.text if d_node is not None else "")
img_url = enc_node.attrib.get("url") if enc_node is not None else None
if title and link:
articles.append({
"title": title,
"content": desc,
"link": link,
"image_url": img_url,
"published_at": p_node.text if p_node is not None else None
})
await self.repo.update_source_website_fetch_status(site_id, error=None)
except Exception as e:
err_msg = str(e)
logger.warning(f"Error fetching website #{site.id} ({site.name}): {err_msg}")
await self.repo.update_source_website_fetch_status(site_id, error=err_msg)
if self.on_error_alert:
await self.on_error_alert(
f"⚠️ <b>خطا در دریافت اطلاعات از وبسایت «{site.name}»:</b>\n"
f"🌐 URL: <code>{endpoint_url}</code>\n"
f"❌ خطا: <code>{err_msg}</code>",
site_id
)
return 0
# Ingest new articles into database
new_count = 0
pseudo_channel_id = -900000000 - site.id
for art in reversed(articles):
link = art["link"]
text_body = f"📌 <b>{art['title']}</b>\n\n{art['content']}\n\n🔗 منبع: {link}"
msg_id = int(hashlib.md5(link.encode("utf-8")).hexdigest()[:8], 16) % 1000000000
# Tags & Semantic deduplication
tags, subject = [], art["title"]
if self.ai_processor:
try:
tags, subject = await self.ai_processor.extract_tags_and_subject(art['title'] + "\n" + art['content'])
except Exception:
pass
is_duplicate = False
duplicate_of_id = None
similarity_reason = None
if tags and self.ai_processor:
try:
candidates = await self.repo.find_candidate_posts_by_tags(tags, limit=10)
if candidates:
is_dup, dup_id, reason = await self.ai_processor.check_semantic_duplicate(text_body, candidates)
if is_dup:
is_duplicate = True
duplicate_of_id = dup_id
similarity_reason = reason
except Exception:
pass
post_id = await self.repo.create_raw_post(
source_channel_id=pseudo_channel_id,
source_message_id=msg_id,
raw_text=text_body,
tags=tags,
subject=subject,
is_duplicate=is_duplicate,
duplicate_of_id=duplicate_of_id,
similarity_reason=similarity_reason,
)
if post_id:
new_count += 1
logger.info(f"Ingested post ID {post_id} from source website '{site.name}'")
if self.on_post_received:
await self.on_post_received(post_id)
return new_count
+147
View File
@@ -0,0 +1,147 @@
import asyncio
import time
import json
from unittest.mock import AsyncMock, MagicMock, patch
from db.database import init_db
from db.repository import Repository
from db.models import TargetChannel, SourceWebsite
from core.queue import RedisQueue
from services.website_analyzer import WebsiteAnalyzer
from services.website_collector import WebsiteCollectorService, _clean_html, _extract_nested
async def test_target_dispatch_order_and_queue():
await init_db()
repo = Repository()
# 1. Target creation and dispatch_order field
unique_channel_id = -10088776655 - int(time.time() % 100000)
t_id = await repo.add_target(channel_id=unique_channel_id, title="Dispatch Test Target", username="disp_test")
assert t_id is not None
target = await repo.get_target_by_id(t_id)
assert target.dispatch_order == "order"
# Toggle to random
await repo.update_target_dispatch_order(t_id, "random")
target_updated = await repo.get_target_by_id(t_id)
assert target_updated.dispatch_order == "random"
# 2. Redis queue FIFO vs Random pop test
queue = RedisQueue()
await queue.connect()
# Clear queue for test target
key = queue._get_target_key(t_id)
await queue.client.delete(key)
# Push 3 items: Item 1, Item 2, Item 3
await queue.push_target_post(t_id, {"post_id": 1, "text": "Post 1"})
await queue.push_target_post(t_id, {"post_id": 2, "text": "Post 2"})
await queue.push_target_post(t_id, {"post_id": 3, "text": "Post 3"})
assert await queue.get_target_queue_size(t_id) == 3
# Random pop should retrieve one of the 3 items and leave 2
popped_random = await queue.pop_target_post(t_id, dispatch_order="random")
assert popped_random is not None
assert popped_random["post_id"] in (1, 2, 3)
assert await queue.get_target_queue_size(t_id) == 2
# Clean up
await queue.client.delete(key)
await repo.delete_target(t_id)
await queue.close()
async def test_source_websites_crud_and_collector():
await init_db()
repo = Repository()
# 1. Add Source Website
test_url = f"https://example.com/blog-{int(time.time())}"
site_id = await repo.add_source_website(
name="Example Tech Blog",
url=test_url,
check_interval_min=15,
auto_reanalyze_hours=12,
api_config={
"endpoint_url": f"{test_url}/wp-json/wp/v2/posts",
"parser_type": "wordpress_json",
"field_mappings": {
"title": "title.rendered",
"content": "content.rendered",
"link": "link"
}
}
)
assert site_id is not None
site = await repo.get_source_website_by_id(site_id)
assert site.name == "Example Tech Blog"
assert site.check_interval_min == 15
assert site.auto_reanalyze_hours == 12
assert site.api_config.get("parser_type") == "wordpress_json"
# 2. Update intervals and status
await repo.update_source_website_interval(site_id, 45)
await repo.update_source_website_reanalyze_hours(site_id, 48)
await repo.update_source_website_fetch_status(site_id, error="Connection timeout test")
site_err = await repo.get_source_website_by_id(site_id)
assert site_err.check_interval_min == 45
assert site_err.auto_reanalyze_hours == 48
assert site_err.last_error == "Connection timeout test"
# 3. Clean up error
await repo.update_source_website_fetch_status(site_id, error=None)
site_ok = await repo.get_source_website_by_id(site_id)
assert site_ok.last_error is None
assert site_ok.last_fetched_at is not None
# Clean up
await repo.delete_source_website(site_id)
async def test_website_analyzer_and_collector_mock():
# Helper functions test
assert _clean_html("<p>Hello <b>World</b>!</p>") == "Hello World!"
sample_dict = {"channel": {"items": [{"title": "News 1"}]}}
assert _extract_nested(sample_dict, "channel.items.0.title") == "News 1"
# Website Analyzer mock test
llm_mock = MagicMock()
llm_mock.generate_json = AsyncMock(return_value={
"status": "success",
"endpoint_url": "https://digiato.com/wp-json/wp/v2/posts?per_page=10",
"parser_type": "wordpress_json",
"field_mappings": {
"title": "title.rendered",
"content": "content.rendered",
"link": "link"
},
"summary": "سایت از وردپرس استفاده می‌کند."
})
analyzer = WebsiteAnalyzer(llm=llm_mock)
with patch("httpx.AsyncClient.get") as mock_get:
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = '<html><head><link rel="https://api.w.org/" href="https://digiato.com/wp-json/" /></head></html>'
mock_get.return_value = mock_resp
ok, cfg, summary = await analyzer.analyze_website("https://digiato.com")
assert ok is True
assert cfg["parser_type"] == "wordpress_json"
assert cfg["endpoint_url"] == "https://digiato.com/wp-json/wp/v2/posts?per_page=10"
async def main():
await test_target_dispatch_order_and_queue()
await test_source_websites_crud_and_collector()
await test_website_analyzer_and_collector_mock()
print("All Source Websites, Dispatch Order, and Markdown styling tests passed successfully!")
if __name__ == "__main__":
asyncio.run(main())