make it faster
This commit is contained in:
parent
e31df1e3db
commit
d8c349f4fb
14
README.md
14
README.md
@ -37,13 +37,17 @@ A FastAPI application with Docker and docker-compose support.
|
|||||||
# nano .env # or use your preferred editor
|
# nano .env # or use your preferred editor
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Configure BoxAPI (for Instagram scraping):**
|
2. **Configure BoxAPI and Redis:**
|
||||||
```bash
|
```bash
|
||||||
# Add your BoxAPI credentials to .env
|
# Add your BoxAPI credentials to .env
|
||||||
BOXAPI_USERNAME=your_boxapi_username
|
BOXAPI_USERNAME=your_boxapi_username
|
||||||
BOXAPI_PASSWORD=your_boxapi_password
|
BOXAPI_PASSWORD=your_boxapi_password
|
||||||
BOXAPI_ENDPOINT=https://boxapi.ir/api/instagram/user/get_media_by_username
|
BOXAPI_ENDPOINT=https://boxapi.ir/api/instagram/user/get_media_by_username
|
||||||
BOXAPI_LOG_DIR=/app/logs/boxapi
|
BOXAPI_LOG_DIR=/app/logs/boxapi
|
||||||
|
BOXAPI_LOG_LEVEL=all # or "error" for production
|
||||||
|
|
||||||
|
# Redis for pagination (optional but recommended)
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Build and start the application:**
|
2. **Build and start the application:**
|
||||||
@ -163,8 +167,10 @@ FastApi-ISS/
|
|||||||
├── utils/ # Utility modules
|
├── utils/ # Utility modules
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── boxapi_client.py # BoxAPI client for Instagram scraping
|
│ ├── boxapi_client.py # BoxAPI client for Instagram scraping
|
||||||
│ ├── boxapi_parser.py # BoxAPI response parser
|
│ ├── parser_fast.py # Fast BoxAPI response parser
|
||||||
|
│ ├── boxapi_parser.py # Legacy BoxAPI response parser
|
||||||
│ ├── boxapi_log.py # BoxAPI response logging
|
│ ├── boxapi_log.py # BoxAPI response logging
|
||||||
|
│ ├── seen_store.py # Redis-based seen posts tracking
|
||||||
│ ├── instagram_client.py # Legacy Instagram client
|
│ ├── instagram_client.py # Legacy Instagram client
|
||||||
│ └── s3_uploader.py # S3 media upload functionality
|
│ └── s3_uploader.py # S3 media upload functionality
|
||||||
├── .dockerignore # Docker ignore file
|
├── .dockerignore # Docker ignore file
|
||||||
@ -181,6 +187,10 @@ The application uses environment variables for configuration. Copy `env.example`
|
|||||||
- `BOXAPI_PASSWORD`: Your BoxAPI password
|
- `BOXAPI_PASSWORD`: Your BoxAPI password
|
||||||
- `BOXAPI_ENDPOINT`: BoxAPI endpoint URL (default: `https://boxapi.ir/api/instagram/user/get_media_by_username`)
|
- `BOXAPI_ENDPOINT`: BoxAPI endpoint URL (default: `https://boxapi.ir/api/instagram/user/get_media_by_username`)
|
||||||
- `BOXAPI_LOG_DIR`: Directory for BoxAPI response logs (default: `/app/logs/boxapi`)
|
- `BOXAPI_LOG_DIR`: Directory for BoxAPI response logs (default: `/app/logs/boxapi`)
|
||||||
|
- `BOXAPI_LOG_LEVEL`: Logging level - "all", "error", or "none" (default: "all")
|
||||||
|
|
||||||
|
### Redis Configuration (for pagination)
|
||||||
|
- `REDIS_URL`: Redis connection URL (default: `redis://localhost:6379/0`)
|
||||||
|
|
||||||
### Database Configuration
|
### Database Configuration
|
||||||
- `POSTGRES_DB`: Database name (default: `fastapi_db`)
|
- `POSTGRES_DB`: Database name (default: `fastapi_db`)
|
||||||
|
|||||||
@ -9,4 +9,6 @@ instagrapi==2.0.0
|
|||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
boto3==1.34.0
|
boto3==1.34.0
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
Pillow>=8.1.1
|
Pillow>=8.1.1
|
||||||
|
redis==5.0.1
|
||||||
|
orjson==3.9.10
|
||||||
@ -8,6 +8,7 @@ class ScrapeRequest(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
seller_id: UUID
|
seller_id: UUID
|
||||||
max_count: int = Field(default=5, ge=1, le=50)
|
max_count: int = Field(default=5, ge=1, le=50)
|
||||||
|
since_taken_at: Optional[int] = None # epoch seconds for cursor-like behavior
|
||||||
|
|
||||||
|
|
||||||
class ScrapedPost(BaseModel):
|
class ScrapedPost(BaseModel):
|
||||||
@ -15,5 +16,6 @@ class ScrapedPost(BaseModel):
|
|||||||
media_type: str # "image", "video", or "carousel"
|
media_type: str # "image", "video", or "carousel"
|
||||||
caption: Optional[str]
|
caption: Optional[str]
|
||||||
thumbnail_url: Optional[str]
|
thumbnail_url: Optional[str]
|
||||||
local_media: List[str] # ✅ list of S3 URLs only
|
remote_urls: List[str] # remote URLs to be uploaded later
|
||||||
created_at: datetime
|
taken_at: int # epoch seconds
|
||||||
|
code: Optional[str] = None
|
||||||
|
|||||||
46
scraper.py
46
scraper.py
@ -1,7 +1,8 @@
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from database import InstagramAccount, InstagramPost
|
from database import InstagramAccount, InstagramPost
|
||||||
from utils.boxapi_client import get_media, BoxAPIError
|
from utils.boxapi_client import get_media, BoxAPIError
|
||||||
from utils.boxapi_parser import parse_boxapi_items
|
from utils.parser_fast import parse_boxapi_items_fast
|
||||||
|
from utils.seen_store import filter_unseen, mark_seen
|
||||||
from utils.s3_uploader import upload_media_from_url
|
from utils.s3_uploader import upload_media_from_url
|
||||||
from schemas import ScrapeRequest, ScrapedPost
|
from schemas import ScrapeRequest, ScrapedPost
|
||||||
from typing import List
|
from typing import List
|
||||||
@ -10,18 +11,36 @@ from datetime import datetime
|
|||||||
|
|
||||||
def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
||||||
try:
|
try:
|
||||||
items, log_path = get_media(req.username, req.max_count)
|
# 1) one BoxAPI call (fast) — we keep this single round trip
|
||||||
|
# Ask for more to offset duplicates
|
||||||
|
raw_items, log_path = get_media(req.username, max(req.max_count, 12))
|
||||||
except BoxAPIError as e:
|
except BoxAPIError as e:
|
||||||
print(f"❌ BoxAPI error: {e}")
|
print(f"❌ BoxAPI error: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not items:
|
if not raw_items:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Parse and normalize the items
|
# 2) normalize quickly
|
||||||
normalized_items = parse_boxapi_items(items)
|
normalized = parse_boxapi_items_fast(raw_items)
|
||||||
|
|
||||||
# Get or create InstagramAccount
|
# 3) apply cursor filter if provided
|
||||||
|
if req.since_taken_at:
|
||||||
|
normalized = [n for n in normalized if n["taken_at"] < req.since_taken_at]
|
||||||
|
|
||||||
|
# 4) filter already-seen
|
||||||
|
ids_in_order = [n["ig_post_id"] for n in normalized if n.get("ig_post_id")]
|
||||||
|
unseen_ids = set(filter_unseen(req.username, ids_in_order))
|
||||||
|
|
||||||
|
unseen = [n for n in normalized if n["ig_post_id"] in unseen_ids]
|
||||||
|
|
||||||
|
# 5) take the next N unseen (newest first already)
|
||||||
|
batch = unseen[:req.max_count]
|
||||||
|
|
||||||
|
# 6) mark them seen (only what we return)
|
||||||
|
mark_seen(req.username, [b["ig_post_id"] for b in batch])
|
||||||
|
|
||||||
|
# 7) Get or create InstagramAccount
|
||||||
account = db.query(InstagramAccount).filter_by(username=req.username).first()
|
account = db.query(InstagramAccount).filter_by(username=req.username).first()
|
||||||
if not account:
|
if not account:
|
||||||
account = InstagramAccount(
|
account = InstagramAccount(
|
||||||
@ -37,13 +56,14 @@ def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
|||||||
result: List[ScrapedPost] = []
|
result: List[ScrapedPost] = []
|
||||||
new_post_count = 0
|
new_post_count = 0
|
||||||
|
|
||||||
for item in normalized_items:
|
for item in batch:
|
||||||
post_id = item["ig_post_id"]
|
post_id = item["ig_post_id"]
|
||||||
|
|
||||||
|
# Check if already in database
|
||||||
if db.query(InstagramPost).filter_by(ig_post_id=post_id).first():
|
if db.query(InstagramPost).filter_by(ig_post_id=post_id).first():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Upload remote URLs to S3
|
# Upload remote URLs to S3 (optional - can be done async later)
|
||||||
media_urls: List[str] = []
|
media_urls: List[str] = []
|
||||||
for remote_url in item["remote_urls"]:
|
for remote_url in item["remote_urls"]:
|
||||||
try:
|
try:
|
||||||
@ -54,9 +74,6 @@ def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
|||||||
print(f"⚠️ Error uploading media: {e}")
|
print(f"⚠️ Error uploading media: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not media_urls:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Upload thumbnail (optional)
|
# Upload thumbnail (optional)
|
||||||
final_thumbnail = item["thumbnail_url"]
|
final_thumbnail = item["thumbnail_url"]
|
||||||
if item["thumbnail_url"]:
|
if item["thumbnail_url"]:
|
||||||
@ -75,7 +92,7 @@ def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
|||||||
caption=item["caption"],
|
caption=item["caption"],
|
||||||
thumbnail_url=final_thumbnail,
|
thumbnail_url=final_thumbnail,
|
||||||
local_media=media_urls,
|
local_media=media_urls,
|
||||||
created_at=item["taken_at"],
|
created_at=datetime.fromtimestamp(item["taken_at"]),
|
||||||
)
|
)
|
||||||
db.add(post_entry)
|
db.add(post_entry)
|
||||||
db.commit()
|
db.commit()
|
||||||
@ -85,8 +102,9 @@ def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
|||||||
media_type=item["media_type"],
|
media_type=item["media_type"],
|
||||||
caption=item["caption"],
|
caption=item["caption"],
|
||||||
thumbnail_url=final_thumbnail,
|
thumbnail_url=final_thumbnail,
|
||||||
local_media=media_urls,
|
remote_urls=item["remote_urls"], # Return remote URLs for async processing
|
||||||
created_at=item["taken_at"]
|
taken_at=item["taken_at"],
|
||||||
|
code=item.get("code")
|
||||||
))
|
))
|
||||||
|
|
||||||
new_post_count += 1
|
new_post_count += 1
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
# utils/boxapi_log.py
|
# utils/boxapi_log.py
|
||||||
import json, os, datetime
|
import orjson, os, datetime
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
LOG_ROOT = os.environ.get("BOXAPI_LOG_DIR", "/app/logs/boxapi")
|
LOG_ROOT = os.environ.get("BOXAPI_LOG_DIR", "/app/logs/boxapi")
|
||||||
|
LOG_LEVEL = os.environ.get("BOXAPI_LOG_LEVEL", "all") # "all", "error", "none"
|
||||||
|
|
||||||
def _ensure_dir(path: str) -> None:
|
def _ensure_dir(path: str) -> None:
|
||||||
os.makedirs(path, exist_ok=True)
|
os.makedirs(path, exist_ok=True)
|
||||||
@ -15,6 +16,10 @@ def log_boxapi_response(
|
|||||||
response_text: Optional[str] = None,
|
response_text: Optional[str] = None,
|
||||||
note: str = ""
|
note: str = ""
|
||||||
) -> str:
|
) -> str:
|
||||||
|
# Skip logging if level is "none" or if level is "error" and this is not an error
|
||||||
|
if LOG_LEVEL == "none" or (LOG_LEVEL == "error" and "error" not in note.lower()):
|
||||||
|
return ""
|
||||||
|
|
||||||
ts = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
|
ts = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
|
||||||
safe_user = (username or "unknown").replace("/", "_")
|
safe_user = (username or "unknown").replace("/", "_")
|
||||||
dirpath = os.path.join(LOG_ROOT, safe_user)
|
dirpath = os.path.join(LOG_ROOT, safe_user)
|
||||||
@ -30,6 +35,6 @@ def log_boxapi_response(
|
|||||||
"response_json": response_json,
|
"response_json": response_json,
|
||||||
"response_text": response_text if response_json is None else None,
|
"response_text": response_text if response_json is None else None,
|
||||||
}
|
}
|
||||||
with open(filepath, "w", encoding="utf-8") as f:
|
with open(filepath, "wb") as f:
|
||||||
json.dump(doc, f, ensure_ascii=False, indent=2)
|
f.write(orjson.dumps(doc, option=orjson.OPT_INDENT_2))
|
||||||
return filepath
|
return filepath
|
||||||
66
utils/parser_fast.py
Normal file
66
utils/parser_fast.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# utils/parser_fast.py
|
||||||
|
from typing import Any, Dict, List, Tuple, Optional
|
||||||
|
|
||||||
|
def pick_image_url(item: Dict[str, Any]) -> Optional[str]:
|
||||||
|
cands = ((item.get("image_versions2") or {}).get("candidates") or [])
|
||||||
|
return cands[0].get("url") if cands else None
|
||||||
|
|
||||||
|
def pick_video_url(item: Dict[str, Any]) -> Optional[str]:
|
||||||
|
# choose the largest height (usually last or explicitly max)
|
||||||
|
vv = item.get("video_versions") or []
|
||||||
|
if not vv:
|
||||||
|
return None
|
||||||
|
# faster than sorted: single pass to get max by height
|
||||||
|
best = vv[0]
|
||||||
|
bh = best.get("height") or 0
|
||||||
|
for v in vv[1:]:
|
||||||
|
h = v.get("height") or 0
|
||||||
|
if h > bh:
|
||||||
|
bh, best = h, v
|
||||||
|
return best.get("url")
|
||||||
|
|
||||||
|
def normalize_item(it: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
mt = it.get("media_type")
|
||||||
|
if mt == 1:
|
||||||
|
media_type = "image"
|
||||||
|
url = pick_image_url(it)
|
||||||
|
urls = [url] if url else []
|
||||||
|
elif mt == 2:
|
||||||
|
media_type = "video"
|
||||||
|
# put a cover first if exists
|
||||||
|
cover = pick_image_url(it)
|
||||||
|
vurl = pick_video_url(it)
|
||||||
|
urls = ([cover] if cover else []) + ([vurl] if vurl else [])
|
||||||
|
elif mt == 8: # carousel
|
||||||
|
media_type = "carousel"
|
||||||
|
urls = []
|
||||||
|
for child in it.get("carousel_media") or []:
|
||||||
|
if child.get("media_type") == 1:
|
||||||
|
u = pick_image_url(child)
|
||||||
|
if u: urls.append(u)
|
||||||
|
elif child.get("media_type") == 2:
|
||||||
|
cover = pick_image_url(child)
|
||||||
|
vurl = pick_video_url(child)
|
||||||
|
if cover: urls.append(cover)
|
||||||
|
if vurl: urls.append(vurl)
|
||||||
|
else:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ig_post_id": str(it.get("pk") or it.get("id") or ""),
|
||||||
|
"media_type": media_type,
|
||||||
|
"caption": ((it.get("caption") or {}).get("text") or ""),
|
||||||
|
"taken_at": int(it.get("taken_at") or 0),
|
||||||
|
"thumbnail_url": pick_image_url(it),
|
||||||
|
"remote_urls": [u for u in urls if u],
|
||||||
|
"code": it.get("code"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def parse_boxapi_items_fast(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
out = []
|
||||||
|
for it in items:
|
||||||
|
n = normalize_item(it)
|
||||||
|
if n: out.append(n)
|
||||||
|
# keep newest first
|
||||||
|
out.sort(key=lambda x: x["taken_at"], reverse=True)
|
||||||
|
return out
|
||||||
22
utils/seen_store.py
Normal file
22
utils/seen_store.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# utils/seen_store.py
|
||||||
|
import os, redis
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
r = redis.from_url(REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
def seen_key(username: str) -> str:
|
||||||
|
return f"ig:seen:{username}"
|
||||||
|
|
||||||
|
def mark_seen(username: str, ids: Iterable[str]) -> None:
|
||||||
|
if not ids: return
|
||||||
|
r.sadd(seen_key(username), *ids)
|
||||||
|
|
||||||
|
def filter_unseen(username: str, ids: Iterable[str]) -> list[str]:
|
||||||
|
# pipeline for speed
|
||||||
|
key = seen_key(username)
|
||||||
|
pipe = r.pipeline()
|
||||||
|
for i in ids:
|
||||||
|
pipe.sismember(key, i)
|
||||||
|
flags = pipe.execute()
|
||||||
|
return [i for i, seen in zip(ids, flags) if not seen]
|
||||||
Loading…
Reference in New Issue
Block a user