6 changed files with 399 additions and 2 deletions
-
20backend/ads/migrations/0002_alter_adevaluation_crawl_task.py
-
2backend/ads/models.py
-
160backend/ads/tasks.py
-
19backend/crawler/migrations/0002_alter_crawlrun_crawl_task.py
-
2backend/crawler/models.py
-
198backend/crawler/tasks.py
@ -0,0 +1,20 @@ |
|||
# Generated by Django 6.0.8 on 2026-08-08 10:04 |
|||
|
|||
import django.db.models.deletion |
|||
from django.db import migrations, models |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('ads', '0001_initial'), |
|||
('crawler', '0002_alter_crawlrun_crawl_task'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AlterField( |
|||
model_name='adevaluation', |
|||
name='crawl_task', |
|||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='crawler.crawltask'), |
|||
), |
|||
] |
|||
@ -0,0 +1,160 @@ |
|||
import os |
|||
import html |
|||
import requests |
|||
from openai import OpenAI, AuthenticationError |
|||
from pydantic import BaseModel, Field |
|||
from celery import shared_task |
|||
from django.utils import timezone |
|||
from ads.models import Ad, AdEvaluation, NotificationLog |
|||
|
|||
# Pydantic model for strict OpenAI structured output format |
|||
class AdEvaluationResult(BaseModel): |
|||
is_flagged: bool = Field(description="True if the ad matches the criteria in the prompt, otherwise False") |
|||
reason: str = Field(description="A concise explanation in Persian describing why the ad matches or doesn't match the criteria") |
|||
confidence: float = Field(description="Confidence score between 0.0 and 1.0") |
|||
|
|||
@shared_task(bind=True, max_retries=3, default_retry_delay=10) |
|||
def evaluate_ad_with_ai(self, evaluation_id): |
|||
""" |
|||
Asynchronously evaluates a single ad using OpenAI's gpt-4o-mini model |
|||
against the specific CrawlTask search criteria. |
|||
""" |
|||
try: |
|||
evaluation = AdEvaluation.objects.get(pk=evaluation_id) |
|||
except AdEvaluation.DoesNotExist: |
|||
return |
|||
|
|||
ad = evaluation.ad |
|||
task = evaluation.crawl_task |
|||
api_key = os.getenv('OPENAI_API_KEY') |
|||
|
|||
# Fallback if OpenAI API Key is missing |
|||
if not api_key or 'placeholder' in api_key or 'your_openai_api_key' in api_key or 'your-openai-api-key-here' in api_key: |
|||
evaluation.is_flagged = False |
|||
evaluation.reason = "AI Evaluation skipped: OpenAI API key is not configured." |
|||
evaluation.confidence = 0.0 |
|||
evaluation.save() |
|||
return |
|||
|
|||
try: |
|||
client = OpenAI(api_key=api_key) |
|||
|
|||
system_instruction = ( |
|||
"You are an expert Iranian market analyst. Your job is to read listing descriptions " |
|||
"and decide if they match specific target criteria. You MUST reply using the structured JSON response format " |
|||
"with Persian strings." |
|||
) |
|||
|
|||
user_content = ( |
|||
f"User Search Criteria: {task.detection_prompt}\n\n" |
|||
f"Ad Title: {ad.title}\n" |
|||
f"Ad Price: {ad.price or 'Not specified'}\n" |
|||
f"Ad Category: {ad.category or 'Not specified'}\n" |
|||
f"Ad Description:\n{ad.description}" |
|||
) |
|||
|
|||
completion = client.beta.chat.completions.parse( |
|||
model="gpt-4o-mini", |
|||
messages=[ |
|||
{"role": "system", "content": system_instruction}, |
|||
{"role": "user", "content": user_content} |
|||
], |
|||
response_format=AdEvaluationResult, |
|||
timeout=25 |
|||
) |
|||
|
|||
result = completion.choices[0].message.parsed |
|||
evaluation.is_flagged = result.is_flagged |
|||
evaluation.reason = result.reason |
|||
evaluation.confidence = result.confidence |
|||
evaluation.save() |
|||
|
|||
# Update run stats on successful AI flagging |
|||
if result.is_flagged: |
|||
from crawler.models import CrawlRun |
|||
from django.db.models import F |
|||
latest_run = CrawlRun.objects.filter(crawl_task=task).order_by('-started_at').first() |
|||
if latest_run: |
|||
latest_run.ads_flagged_count = F('ads_flagged_count') + 1 |
|||
latest_run.save(update_fields=['ads_flagged_count']) |
|||
|
|||
# Send telegram channel notification if configured |
|||
if task.telegram_channel_id: |
|||
send_telegram_notification.delay(evaluation.id) |
|||
|
|||
except AuthenticationError as auth_err: |
|||
evaluation.is_flagged = False |
|||
evaluation.reason = "AI Evaluation failed: Invalid or incorrect OpenAI API key." |
|||
evaluation.confidence = 0.0 |
|||
evaluation.save() |
|||
except Exception as e: |
|||
# Retry in case of API rate limits or network issues |
|||
try: |
|||
self.retry(exc=e) |
|||
except self.MaxRetriesExceededError: |
|||
evaluation.is_flagged = False |
|||
evaluation.reason = f"AI Evaluation failed: {str(e)}" |
|||
evaluation.confidence = 0.0 |
|||
evaluation.save() |
|||
|
|||
@shared_task(bind=True, max_retries=3, default_retry_delay=15) |
|||
def send_telegram_notification(self, evaluation_id): |
|||
""" |
|||
Sends an HTML formatted alert message to the target Telegram Channel |
|||
notifying them of a flagged ad. |
|||
""" |
|||
try: |
|||
evaluation = AdEvaluation.objects.get(pk=evaluation_id) |
|||
except AdEvaluation.DoesNotExist: |
|||
return |
|||
|
|||
ad = evaluation.ad |
|||
task = evaluation.crawl_task |
|||
token = os.getenv('TELEGRAM_BOT_TOKEN') |
|||
channel = task.telegram_channel_id |
|||
|
|||
if not token or not channel: |
|||
return |
|||
|
|||
# Escape HTML to prevent telegram parsing errors |
|||
title_esc = html.escape(ad.title) |
|||
price_esc = html.escape(ad.price or 'مشخص نشده') |
|||
cat_esc = html.escape(ad.category or 'مشخص نشده') |
|||
reason_esc = html.escape(evaluation.reason or '') |
|||
|
|||
message_html = ( |
|||
f"🔔 <b>آگهی پرچمگذاری شده دیوار</b>\n\n" |
|||
f"📌 <b>عنوان:</b> {title_esc}\n" |
|||
f"💰 <b>قیمت:</b> {price_esc}\n" |
|||
f"🗂 <b>دستهبندی:</b> {cat_esc}\n\n" |
|||
f"🤖 <b>علت انتخاب AI:</b>\n{reason_esc}\n\n" |
|||
f"🔗 <a href='{ad.url}'>مشاهده آگهی در دیوار</a>" |
|||
) |
|||
|
|||
url = f"https://api.telegram.org/bot{token}/sendMessage" |
|||
payload = { |
|||
'chat_id': channel, |
|||
'text': message_html, |
|||
'parse_mode': 'HTML' |
|||
} |
|||
|
|||
try: |
|||
res = requests.post(url, json=payload, timeout=10) |
|||
if res.status_code == 200: |
|||
NotificationLog.objects.create( |
|||
evaluation=evaluation, |
|||
channel_id=channel, |
|||
status='SENT' |
|||
) |
|||
else: |
|||
raise Exception(f"Telegram API responded with code {res.status_code}: {res.text}") |
|||
except Exception as e: |
|||
try: |
|||
self.retry(exc=e) |
|||
except self.MaxRetriesExceededError: |
|||
NotificationLog.objects.create( |
|||
evaluation=evaluation, |
|||
channel_id=channel, |
|||
status='FAILED', |
|||
error_message=str(e) |
|||
) |
|||
@ -0,0 +1,19 @@ |
|||
# Generated by Django 6.0.8 on 2026-08-08 10:04 |
|||
|
|||
import django.db.models.deletion |
|||
from django.db import migrations, models |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('crawler', '0001_initial'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AlterField( |
|||
model_name='crawlrun', |
|||
name='crawl_task', |
|||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='runs', to='crawler.crawltask'), |
|||
), |
|||
] |
|||
@ -0,0 +1,198 @@ |
|||
import re |
|||
import json |
|||
import requests |
|||
import urllib.parse |
|||
from urllib.parse import urlparse |
|||
from django.utils import timezone |
|||
from celery import shared_task |
|||
from crawler.models import CrawlRun, CrawlTask |
|||
from ads.models import Ad, AdEvaluation |
|||
|
|||
def parse_divar_url(divar_url): |
|||
""" |
|||
Parses a public Divar search link to extract city, category path, |
|||
and query parameters. |
|||
""" |
|||
parsed = urlparse(divar_url) |
|||
path_parts = [p for p in parsed.path.split('/') if p] |
|||
|
|||
city = 'tehran' |
|||
category = '' |
|||
|
|||
if len(path_parts) >= 2 and path_parts[0] == 's': |
|||
city = path_parts[1] |
|||
if len(path_parts) > 2: |
|||
category = '/'.join(path_parts[2:]) |
|||
elif len(path_parts) >= 1: |
|||
city = path_parts[0] |
|||
if len(path_parts) > 1: |
|||
category = '/'.join(path_parts[1:]) |
|||
|
|||
query_params = dict(urllib.parse.parse_qsl(parsed.query)) |
|||
return city, category, query_params |
|||
|
|||
def extract_widgets_from_dict(d, found_ads): |
|||
""" |
|||
Recursively searches a JSON structure for dictionaries matching |
|||
the properties of an ad widget, extracting token, title, description, |
|||
price, and image details. |
|||
""" |
|||
if isinstance(d, dict): |
|||
# Retrieve token |
|||
token = d.get('token') |
|||
if not token: |
|||
token = d.get('action', {}).get('payload', {}).get('token') |
|||
if not token: |
|||
token = d.get('data', {}).get('token') |
|||
|
|||
# Retrieve title |
|||
title = d.get('title') or d.get('data', {}).get('title') |
|||
|
|||
# Verify it represents an ad widget (token length >= 6 and has a title) |
|||
if token and title and isinstance(token, str) and len(token) >= 6: |
|||
desc = ( |
|||
d.get('description') or |
|||
d.get('data', {}).get('description') or |
|||
d.get('data', {}).get('top_description_text') or |
|||
d.get('data', {}).get('middle_description_text') or |
|||
d.get('data', {}).get('bottom_description_text') or |
|||
'' |
|||
) |
|||
|
|||
price = ( |
|||
d.get('price') or |
|||
d.get('data', {}).get('price_text') or |
|||
d.get('data', {}).get('price') or |
|||
'' |
|||
) |
|||
|
|||
image_url = '' |
|||
image_data = d.get('image') or d.get('data', {}).get('image') or d.get('data', {}).get('image_url') |
|||
if isinstance(image_data, str): |
|||
image_url = image_data |
|||
elif isinstance(image_data, dict): |
|||
image_url = image_data.get('url') or image_data.get('src') or '' |
|||
|
|||
ad_info = { |
|||
'token': token, |
|||
'title': title, |
|||
'description': desc, |
|||
'price': price, |
|||
'image_url': image_url, |
|||
'category': d.get('category') or d.get('data', {}).get('category') or '' |
|||
} |
|||
found_ads[token] = ad_info |
|||
|
|||
for v in d.values(): |
|||
extract_widgets_from_dict(v, found_ads) |
|||
|
|||
elif isinstance(d, list): |
|||
for item in d: |
|||
extract_widgets_from_dict(item, found_ads) |
|||
|
|||
@shared_task |
|||
def run_crawl_pipeline(run_id): |
|||
""" |
|||
Background Celery task that executes a crawling run. |
|||
Parses Divar listing data, registers new Ads, and queues AI evaluations. |
|||
""" |
|||
try: |
|||
run = CrawlRun.objects.get(pk=run_id) |
|||
except CrawlRun.DoesNotExist: |
|||
return |
|||
|
|||
task = run.crawl_task |
|||
|
|||
# 1. Verification checks |
|||
if not task.is_active: |
|||
run.status = 'FAILED' |
|||
run.finished_at = timezone.now() |
|||
run.error_log = "CrawlTask is inactive." |
|||
run.save() |
|||
return |
|||
|
|||
now = timezone.localtime(timezone.now()).time() |
|||
if not (task.start_hour <= now <= task.end_hour): |
|||
run.status = 'SUCCESS' |
|||
run.finished_at = timezone.now() |
|||
run.error_log = f"Skipped: current time {now.strftime('%H:%M')} is outside allowed window {task.start_hour.strftime('%H:%M')} to {task.end_hour.strftime('%H:%M')}" |
|||
run.save() |
|||
return |
|||
|
|||
# Update run status to RUNNING |
|||
run.status = 'RUNNING' |
|||
run.save() |
|||
|
|||
headers = { |
|||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36' |
|||
} |
|||
|
|||
city, category, query_params = parse_divar_url(task.divar_url) |
|||
found_ads = {} |
|||
|
|||
# 2. Try fetching from Divar public web search API |
|||
api_url = f"https://api.divar.ir/v8/web-search/{city}/{category}" |
|||
try: |
|||
res = requests.get(api_url, params=query_params, headers=headers, timeout=15) |
|||
if res.status_code == 200: |
|||
extract_widgets_from_dict(res.json(), found_ads) |
|||
except Exception as api_err: |
|||
# Log error but proceed to HTML scraper fallback |
|||
pass |
|||
|
|||
# 3. Fallback: Parse HTML state if API failed/returned nothing |
|||
if not found_ads: |
|||
try: |
|||
res = requests.get(task.divar_url, headers=headers, timeout=15) |
|||
if res.status_code == 200: |
|||
match = re.search(r'window\.__PRELOADED_STATE__\s*=\s*(\{[\s\S]*?\});', res.text) |
|||
if not match: |
|||
match = re.search(r'window\.__INITIAL_STATE__\s*=\s*(\{[\s\S]*?\});', res.text) |
|||
if match: |
|||
state_data = json.loads(match.group(1)) |
|||
extract_widgets_from_dict(state_data, found_ads) |
|||
except Exception as html_err: |
|||
run.status = 'FAILED' |
|||
run.finished_at = timezone.now() |
|||
run.error_log = f"Scraping failed. API and HTML fallbacks both failed.\nAPI Error: {str(api_err) if 'api_err' in locals() else 'None'}\nHTML Error: {str(html_err)}" |
|||
run.save() |
|||
return |
|||
|
|||
# 4. Save listings & trigger AI evaluations |
|||
ads_fetched = len(found_ads) |
|||
ads_evaluated = 0 |
|||
|
|||
for token, ad_info in found_ads.items(): |
|||
try: |
|||
# Create or get Ad metadata |
|||
ad, created = Ad.objects.get_or_create( |
|||
divar_token=token, |
|||
defaults={ |
|||
'title': ad_info['title'], |
|||
'description': ad_info['description'], |
|||
'price': ad_info['price'], |
|||
'category': ad_info['category'], |
|||
'images': [ad_info['image_url']] if ad_info['image_url'] else [], |
|||
'url': f"https://divar.ir/v/{token}" |
|||
} |
|||
) |
|||
|
|||
# Check if an evaluation already exists for this crawler on this ad |
|||
eval_exists = AdEvaluation.objects.filter(ad=ad, crawl_task=task).exists() |
|||
if not eval_exists: |
|||
eval_obj = AdEvaluation.objects.create(ad=ad, crawl_task=task) |
|||
ads_evaluated += 1 |
|||
|
|||
# Trigger Celery AI evaluation task |
|||
from ads.tasks import evaluate_ad_with_ai |
|||
evaluate_ad_with_ai.delay(eval_obj.id) |
|||
except Exception: |
|||
# If a single ad save fails, continue processing other ads |
|||
pass |
|||
|
|||
# Update runs counters and status |
|||
run.status = 'SUCCESS' |
|||
run.finished_at = timezone.now() |
|||
run.ads_fetched_count = ads_fetched |
|||
run.ads_evaluated_count = ads_evaluated |
|||
run.save() |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue