FastHOSSEIN
This commit is contained in:
parent
817d3b9c39
commit
3761de8de0
32
env.example
32
env.example
@ -1,32 +0,0 @@
|
||||
# Database Configuration
|
||||
POSTGRES_DB=fastapi_db
|
||||
POSTGRES_USER=fastapi_user
|
||||
POSTGRES_PASSWORD=your_secure_password_here
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# Database URL (constructed from above variables)
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
|
||||
# FastAPI Configuration
|
||||
FASTAPI_TITLE=FastAPI ISS Project
|
||||
FASTAPI_DESCRIPTION=A FastAPI project with Docker and PostgreSQL support
|
||||
FASTAPI_VERSION=1.0.0
|
||||
|
||||
# Application Configuration
|
||||
PYTHONPATH=/app
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# Security (for production, change these values)
|
||||
SECRET_KEY=your_super_secret_key_here_change_in_production
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# CORS Configuration
|
||||
ALLOWED_ORIGINS=["*"]
|
||||
ALLOWED_CREDENTIALS=true
|
||||
ALLOWED_METHODS=["*"]
|
||||
ALLOWED_HEADERS=["*"]
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
14
main.py
14
main.py
@ -6,6 +6,8 @@ from sqlalchemy.orm import Session
|
||||
import uvicorn
|
||||
import os
|
||||
from database import get_db, create_tables, Item as ItemModel
|
||||
from schemas import ScrapeRequest, ScrapedPost
|
||||
from scraper import scrape_and_store
|
||||
|
||||
app = FastAPI(
|
||||
title=os.getenv("FASTAPI_TITLE", "FastAPI ISS Project"),
|
||||
@ -92,5 +94,17 @@ async def delete_item(item_id: int, db: Session = Depends(get_db)):
|
||||
db.commit()
|
||||
return {"message": "Item deleted successfully"}
|
||||
|
||||
# ============================================================================
|
||||
# Instagram Scraper Endpoint
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/scrape/", response_model=List[ScrapedPost])
|
||||
async def scrape_posts(request: ScrapeRequest, db: Session = Depends(get_db)):
|
||||
"""Scrape Instagram posts for a given username"""
|
||||
try:
|
||||
return scrape_and_store(db, request)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@ -5,3 +5,7 @@ python-multipart==0.0.6
|
||||
sqlalchemy==2.0.23
|
||||
psycopg2-binary==2.9.9
|
||||
alembic==1.13.0
|
||||
instagrapi==2.0.0
|
||||
python-dotenv==1.0.0
|
||||
boto3==1.34.0
|
||||
requests==2.31.0
|
||||
25
schemas.py
Normal file
25
schemas.py
Normal file
@ -0,0 +1,25 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ScrapeRequest(BaseModel):
|
||||
username: str
|
||||
seller_id: UUID
|
||||
max_count: int = Field(default=5, ge=1, le=50)
|
||||
|
||||
|
||||
class MediaItem(BaseModel):
|
||||
media_type: str # image | video
|
||||
media_url: str # original Instagram URL
|
||||
local_media: str # full S3/MinIO URL
|
||||
|
||||
|
||||
class ScrapedPost(BaseModel):
|
||||
ig_post_id: str
|
||||
media_type: str # image | video | carousel
|
||||
caption: Optional[str]
|
||||
thumbnail_url: Optional[str]
|
||||
local_media: List[MediaItem]
|
||||
created_at: datetime
|
||||
108
scraper.py
Normal file
108
scraper.py
Normal file
@ -0,0 +1,108 @@
|
||||
from instagrapi.exceptions import LoginRequired
|
||||
from sqlalchemy.orm import Session
|
||||
from database import InstagramAccount, InstagramPost
|
||||
from utils.instagram_client import get_logged_in_client
|
||||
from utils.s3_uploader import upload_media_from_url
|
||||
from schemas import ScrapeRequest, ScrapedPost, MediaItem
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
||||
client = get_logged_in_client()
|
||||
|
||||
try:
|
||||
user_id = client.user_id_from_username(req.username)
|
||||
posts = client.user_medias_v1(user_id, amount=30) # fetch more to skip duplicates
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to fetch posts for @{req.username}: {e}")
|
||||
return []
|
||||
|
||||
# Get or create InstagramAccount
|
||||
account = db.query(InstagramAccount).filter_by(username=req.username).first()
|
||||
if not account:
|
||||
account = InstagramAccount(
|
||||
username=req.username,
|
||||
seller_id=req.seller_id,
|
||||
is_active=True,
|
||||
last_synced=None
|
||||
)
|
||||
db.add(account)
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
|
||||
result: List[ScrapedPost] = []
|
||||
new_post_count = 0
|
||||
|
||||
for post in posts:
|
||||
post_id = str(post.id)
|
||||
|
||||
# Skip if already in DB
|
||||
if db.query(InstagramPost).filter_by(ig_post_id=post_id).first():
|
||||
continue
|
||||
|
||||
media_items = []
|
||||
|
||||
try:
|
||||
# Handle carousel
|
||||
if post.media_type == 8 and hasattr(post, "resources"):
|
||||
for res in post.resources:
|
||||
media_url = res.video_url if res.media_type == 2 else res.thumbnail_url
|
||||
if not media_url:
|
||||
continue
|
||||
local_url = upload_media_from_url(media_url, str(req.seller_id), post_id)
|
||||
if local_url:
|
||||
media_items.append(MediaItem(
|
||||
media_type="video" if res.media_type == 2 else "image",
|
||||
media_url=media_url,
|
||||
local_media=local_url
|
||||
))
|
||||
else:
|
||||
media_url = post.video_url if post.media_type == 2 else post.thumbnail_url
|
||||
if not media_url:
|
||||
continue
|
||||
local_url = upload_media_from_url(media_url, str(req.seller_id), post_id)
|
||||
if local_url:
|
||||
media_items.append(MediaItem(
|
||||
media_type="video" if post.media_type == 2 else "image",
|
||||
media_url=media_url,
|
||||
local_media=local_url
|
||||
))
|
||||
except LoginRequired:
|
||||
print("⛔ Login expired or failed mid-request.")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error downloading media: {e}")
|
||||
continue
|
||||
|
||||
if not media_items:
|
||||
continue
|
||||
|
||||
# Save to DB
|
||||
post_entry = InstagramPost(
|
||||
ig_post_id=post_id,
|
||||
account_id=account.id,
|
||||
media_type="carousel" if post.media_type == 8 else ("video" if post.media_type == 2 else "image"),
|
||||
caption=post.caption_text or "",
|
||||
thumbnail_url=post.thumbnail_url,
|
||||
local_media=[m.dict() for m in media_items],
|
||||
created_at=post.taken_at or datetime.utcnow(),
|
||||
)
|
||||
db.add(post_entry)
|
||||
db.commit()
|
||||
|
||||
# Add to result for response
|
||||
result.append(ScrapedPost(
|
||||
ig_post_id=post_id,
|
||||
media_type=post_entry.media_type,
|
||||
caption=post_entry.caption,
|
||||
thumbnail_url=post_entry.thumbnail_url,
|
||||
local_media=media_items,
|
||||
created_at=post_entry.created_at
|
||||
))
|
||||
|
||||
new_post_count += 1
|
||||
if new_post_count >= req.max_count:
|
||||
break
|
||||
|
||||
return result
|
||||
1
utils/__init__.py
Normal file
1
utils/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Utils package for Instagram scraper utilities
|
||||
44
utils/instagram_client.py
Normal file
44
utils/instagram_client.py
Normal file
@ -0,0 +1,44 @@
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from instagrapi import Client
|
||||
from dotenv import dotenv_values
|
||||
|
||||
# Load IG accounts from env (expects a JSON list string)
|
||||
INSTAGRAM_ACCOUNTS = json.loads(os.getenv("INSTAGRAM_ACCOUNTS", "[]"))
|
||||
|
||||
# Path for storing session files
|
||||
SESSION_DIR = os.path.join(os.getcwd(), "instagram_sessions")
|
||||
os.makedirs(SESSION_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def get_logged_in_client() -> Client:
|
||||
for account in INSTAGRAM_ACCOUNTS:
|
||||
username = account["username"]
|
||||
password = account["password"]
|
||||
session_path = os.path.join(SESSION_DIR, f"{username}.json")
|
||||
|
||||
client = Client()
|
||||
|
||||
try:
|
||||
if os.path.exists(session_path):
|
||||
with open(session_path, "r") as f:
|
||||
settings = json.load(f)
|
||||
client.load_settings(settings)
|
||||
client.login(username, password)
|
||||
print(f"✅ Reused session: {username}")
|
||||
else:
|
||||
client.login(username, password)
|
||||
with open(session_path, "w") as f:
|
||||
json.dump(client.get_settings(), f)
|
||||
print(f"✅ Fresh login: {username}")
|
||||
return client
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Login failed for {username}: {e}")
|
||||
if os.path.exists(session_path):
|
||||
os.remove(session_path)
|
||||
print(f"🧹 Removed invalid session for {username}")
|
||||
|
||||
raise Exception("❌ All Instagram accounts failed login.")
|
||||
50
utils/s3_uploader.py
Normal file
50
utils/s3_uploader.py
Normal file
@ -0,0 +1,50 @@
|
||||
import os
|
||||
import boto3
|
||||
import uuid
|
||||
import mimetypes
|
||||
from urllib.parse import urljoin
|
||||
import requests
|
||||
|
||||
AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID")
|
||||
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY")
|
||||
AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME")
|
||||
S3_ENDPOINT_URL = os.getenv("S3_ENDPOINT_URL")
|
||||
S3_REGION = os.getenv("S3_REGION", "us-east-1")
|
||||
MEDIA_BASE_PATH = os.getenv("MEDIA_BASE_PATH", "instagram")
|
||||
|
||||
session = boto3.session.Session()
|
||||
s3 = session.client(
|
||||
service_name="s3",
|
||||
aws_access_key_id=AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
|
||||
endpoint_url=S3_ENDPOINT_URL,
|
||||
region_name=S3_REGION,
|
||||
)
|
||||
|
||||
|
||||
def upload_media_from_url(media_url: str, seller_id: str, post_id: str) -> str | None:
|
||||
try:
|
||||
response = requests.get(media_url, stream=True, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
ext = mimetypes.guess_extension(content_type) or ".jpg"
|
||||
if ext in [".jpe", ".jfif", ".bin"]:
|
||||
ext = ".jpg"
|
||||
|
||||
filename = f"{uuid.uuid4()}{ext}"
|
||||
s3_path = f"{MEDIA_BASE_PATH}/{seller_id}/{post_id}/{filename}"
|
||||
|
||||
s3.upload_fileobj(
|
||||
response.raw,
|
||||
Bucket=AWS_STORAGE_BUCKET_NAME,
|
||||
Key=s3_path,
|
||||
ExtraArgs={"ACL": "public-read", "ContentType": content_type}
|
||||
)
|
||||
|
||||
public_url = f"{S3_ENDPOINT_URL}/{AWS_STORAGE_BUCKET_NAME}/{s3_path}"
|
||||
return public_url
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to upload media from {media_url}: {e}")
|
||||
return None
|
||||
Loading…
Reference in New Issue
Block a user