update it
This commit is contained in:
parent
d524171b49
commit
5504b1160d
14
README.md
14
README.md
@ -40,9 +40,9 @@ A FastAPI application with Docker and docker-compose support.
|
|||||||
2. **Configure BoxAPI (for Instagram scraping):**
|
2. **Configure BoxAPI (for Instagram scraping):**
|
||||||
```bash
|
```bash
|
||||||
# Add your BoxAPI credentials to .env
|
# Add your BoxAPI credentials to .env
|
||||||
BOXAPI_USERNAME=your_boxapi_username
|
BOXAPI_KEY=your_boxapi_bearer_token
|
||||||
BOXAPI_PASSWORD=your_boxapi_password
|
BOXAPI_BASE=https://boxapi.ir
|
||||||
BOXAPI_ENDPOINT=https://boxapi.ir/api/instagram/user/get_media
|
BOXAPI_LOG_DIR=/app/logs/boxapi
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Build and start the application:**
|
2. **Build and start the application:**
|
||||||
@ -162,6 +162,8 @@ 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
|
||||||
|
│ ├── boxapi_log.py # BoxAPI response logging
|
||||||
│ ├── 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
|
||||||
@ -174,9 +176,9 @@ FastApi-ISS/
|
|||||||
The application uses environment variables for configuration. Copy `env.example` to `.env` and customize the values:
|
The application uses environment variables for configuration. Copy `env.example` to `.env` and customize the values:
|
||||||
|
|
||||||
### BoxAPI Configuration (Instagram Scraping)
|
### BoxAPI Configuration (Instagram Scraping)
|
||||||
- `BOXAPI_USERNAME`: Your BoxAPI username
|
- `BOXAPI_KEY`: Your BoxAPI authentication key (Bearer token)
|
||||||
- `BOXAPI_PASSWORD`: Your BoxAPI password
|
- `BOXAPI_BASE`: BoxAPI base URL (default: `https://boxapi.ir`)
|
||||||
- `BOXAPI_ENDPOINT`: BoxAPI endpoint URL (default: `https://boxapi.ir/api/instagram/user/get_media`)
|
- `BOXAPI_LOG_DIR`: Directory for BoxAPI response logs (default: `/app/logs/boxapi`)
|
||||||
|
|
||||||
### Database Configuration
|
### Database Configuration
|
||||||
- `POSTGRES_DB`: Database name (default: `fastapi_db`)
|
- `POSTGRES_DB`: Database name (default: `fastapi_db`)
|
||||||
|
|||||||
2
main.py
2
main.py
@ -118,6 +118,8 @@ async def scrape_posts(
|
|||||||
try:
|
try:
|
||||||
return scrape_and_store(db, request)
|
return scrape_and_store(db, request)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Log the error for debugging
|
||||||
|
print(f"❌ Scraping error for {request.username}: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
51
scraper.py
51
scraper.py
@ -1,6 +1,7 @@
|
|||||||
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 fetch_boxapi_posts
|
from utils.boxapi_client import get_media, BoxAPIError
|
||||||
|
from utils.boxapi_parser import parse_boxapi_items
|
||||||
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
|
||||||
@ -8,10 +9,18 @@ from datetime import datetime
|
|||||||
|
|
||||||
|
|
||||||
def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
||||||
posts = fetch_boxapi_posts(req.username, req.max_count)
|
try:
|
||||||
if not posts:
|
items, log_path = get_media(req.username, req.max_count)
|
||||||
|
except BoxAPIError as e:
|
||||||
|
print(f"❌ BoxAPI error: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Parse and normalize the items
|
||||||
|
normalized_items = parse_boxapi_items(items)
|
||||||
|
|
||||||
# Get or create InstagramAccount
|
# 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:
|
||||||
@ -28,23 +37,17 @@ 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 post in posts:
|
for item in normalized_items:
|
||||||
post_id = str(post.get("id") or post.get("pk"))
|
post_id = item["ig_post_id"]
|
||||||
caption = post.get("caption", "")
|
|
||||||
thumbnail_url = post.get("thumbnail_url")
|
|
||||||
taken_at = post.get("taken_at") or datetime.utcnow()
|
|
||||||
media_type = post.get("media_type") or "image"
|
|
||||||
|
|
||||||
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
|
||||||
media_urls: List[str] = []
|
media_urls: List[str] = []
|
||||||
for media in post.get("media", []):
|
for remote_url in item["remote_urls"]:
|
||||||
media_url = media.get("media_url")
|
|
||||||
if not media_url:
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
local_url = upload_media_from_url(media_url, str(req.seller_id), f"{post_id}/")
|
local_url = upload_media_from_url(remote_url, str(req.seller_id), f"{post_id}/")
|
||||||
if local_url:
|
if local_url:
|
||||||
media_urls.append(local_url)
|
media_urls.append(local_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -55,10 +58,10 @@ def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Upload thumbnail (optional)
|
# Upload thumbnail (optional)
|
||||||
final_thumbnail = thumbnail_url
|
final_thumbnail = item["thumbnail_url"]
|
||||||
if thumbnail_url:
|
if item["thumbnail_url"]:
|
||||||
try:
|
try:
|
||||||
thumb_uploaded = upload_media_from_url(thumbnail_url, str(req.seller_id), f"{post_id}/thumbnail")
|
thumb_uploaded = upload_media_from_url(item["thumbnail_url"], str(req.seller_id), f"{post_id}/thumbnail")
|
||||||
if thumb_uploaded:
|
if thumb_uploaded:
|
||||||
final_thumbnail = thumb_uploaded
|
final_thumbnail = thumb_uploaded
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -68,22 +71,22 @@ def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
|||||||
post_entry = InstagramPost(
|
post_entry = InstagramPost(
|
||||||
ig_post_id=post_id,
|
ig_post_id=post_id,
|
||||||
account_id=account.id,
|
account_id=account.id,
|
||||||
media_type=media_type,
|
media_type=item["media_type"],
|
||||||
caption=caption,
|
caption=item["caption"],
|
||||||
thumbnail_url=final_thumbnail,
|
thumbnail_url=final_thumbnail,
|
||||||
local_media=media_urls,
|
local_media=media_urls,
|
||||||
created_at=taken_at,
|
created_at=item["taken_at"],
|
||||||
)
|
)
|
||||||
db.add(post_entry)
|
db.add(post_entry)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
result.append(ScrapedPost(
|
result.append(ScrapedPost(
|
||||||
ig_post_id=post_id,
|
ig_post_id=post_id,
|
||||||
media_type=media_type,
|
media_type=item["media_type"],
|
||||||
caption=caption,
|
caption=item["caption"],
|
||||||
thumbnail_url=final_thumbnail,
|
thumbnail_url=final_thumbnail,
|
||||||
local_media=media_urls,
|
local_media=media_urls,
|
||||||
created_at=taken_at
|
created_at=item["taken_at"]
|
||||||
))
|
))
|
||||||
|
|
||||||
new_post_count += 1
|
new_post_count += 1
|
||||||
|
|||||||
@ -1,29 +1,67 @@
|
|||||||
import os
|
import os, requests
|
||||||
import requests
|
from typing import Any, Dict, List, Tuple, Optional
|
||||||
from typing import List, Dict
|
from utils.boxapi_log import log_boxapi_response
|
||||||
|
|
||||||
BOXAPI_USERNAME = os.getenv("BOXAPI_USERNAME")
|
BOXAPI_BASE = os.environ.get("BOXAPI_BASE", "https://boxapi.ir")
|
||||||
BOXAPI_PASSWORD = os.getenv("BOXAPI_PASSWORD")
|
BOXAPI_KEY = os.environ.get("BOXAPI_KEY", "") # put your key in .env
|
||||||
BOXAPI_ENDPOINT = os.getenv("BOXAPI_ENDPOINT", "https://boxapi.ir/api/instagram/user/get_media_by_username")
|
|
||||||
|
|
||||||
def fetch_boxapi_posts(username: str, count: int = 5) -> List[Dict]:
|
class BoxAPIError(Exception):
|
||||||
payload = {
|
pass
|
||||||
"username": username, # 👈 Do NOT replace with user ID
|
|
||||||
"count": count
|
def get_media(username: str, count: int = 5) -> Tuple[List[Dict[str, Any]], str]:
|
||||||
}
|
"""
|
||||||
|
Returns (items, log_path)
|
||||||
|
items is the array under response.body.items
|
||||||
|
"""
|
||||||
|
url = f"{BOXAPI_BASE}/api/instagram/user/get_media"
|
||||||
|
payload = {"username": username, "count": count}
|
||||||
|
headers = {"Authorization": f"Bearer {BOXAPI_KEY}"} if BOXAPI_KEY else {}
|
||||||
|
|
||||||
|
resp = None
|
||||||
|
resp_json: Optional[Dict[str, Any]] = None
|
||||||
|
status_code: Optional[int] = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
resp = requests.post(url, json=payload, headers=headers, timeout=60)
|
||||||
BOXAPI_ENDPOINT,
|
status_code = resp.status_code
|
||||||
json=payload,
|
resp.raise_for_status()
|
||||||
auth=(BOXAPI_USERNAME, BOXAPI_PASSWORD),
|
resp_json = resp.json()
|
||||||
timeout=15
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
if not isinstance(data, list):
|
|
||||||
raise ValueError("Unexpected response format from BoxAPI")
|
|
||||||
return data
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
log_path = log_boxapi_response(username, payload, resp_json, status_code, note=f"HTTP/parse error: {e}")
|
||||||
|
raise BoxAPIError(f"BoxAPI request failed ({e}). See log: {log_path}")
|
||||||
|
|
||||||
|
# ✅ Real structure according to your sample
|
||||||
|
# {
|
||||||
|
# "status": "done",
|
||||||
|
# "response": {
|
||||||
|
# "status_code": 200,
|
||||||
|
# "content_type": "application/json",
|
||||||
|
# "body": {
|
||||||
|
# "items": [ ... ]
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
try:
|
||||||
|
top_status = resp_json.get("status")
|
||||||
|
inner = resp_json.get("response") or {}
|
||||||
|
inner_code = inner.get("status_code")
|
||||||
|
body = inner.get("body") or {}
|
||||||
|
items = body.get("items") or []
|
||||||
|
|
||||||
|
log_path = log_boxapi_response(username, payload, resp_json, inner_code, note="OK")
|
||||||
|
|
||||||
|
if top_status != "done" or inner_code != 200 or not isinstance(items, list):
|
||||||
|
raise ValueError(f"Unexpected shape: status={top_status}, inner_code={inner_code}, items_type={type(items)}")
|
||||||
|
return items, log_path
|
||||||
|
except Exception as e:
|
||||||
|
log_path = log_boxapi_response(username, payload, resp_json, status_code, note=f"schema error: {e}")
|
||||||
|
raise BoxAPIError(f"Unexpected response format from BoxAPI. See log: {log_path}")
|
||||||
|
|
||||||
|
# Legacy function for backward compatibility
|
||||||
|
def fetch_boxapi_posts(username: str, count: int = 5) -> List[Dict]:
|
||||||
|
try:
|
||||||
|
items, _ = get_media(username, count)
|
||||||
|
return items
|
||||||
|
except BoxAPIError as e:
|
||||||
print(f"❌ Failed to fetch posts from BoxAPI: {e}")
|
print(f"❌ Failed to fetch posts from BoxAPI: {e}")
|
||||||
return []
|
return []
|
||||||
33
utils/boxapi_log.py
Normal file
33
utils/boxapi_log.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# utils/boxapi_log.py
|
||||||
|
import json, os, datetime
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
LOG_ROOT = os.environ.get("BOXAPI_LOG_DIR", "/app/logs/boxapi")
|
||||||
|
|
||||||
|
def _ensure_dir(path: str) -> None:
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
|
||||||
|
def log_boxapi_response(
|
||||||
|
username: str,
|
||||||
|
request_payload: Dict[str, Any],
|
||||||
|
response_json: Optional[Dict[str, Any]],
|
||||||
|
status_code: Optional[int],
|
||||||
|
note: str = ""
|
||||||
|
) -> str:
|
||||||
|
ts = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
safe_user = (username or "unknown").replace("/", "_")
|
||||||
|
dirpath = os.path.join(LOG_ROOT, safe_user)
|
||||||
|
_ensure_dir(dirpath)
|
||||||
|
filepath = os.path.join(dirpath, f"{ts}.json")
|
||||||
|
|
||||||
|
doc = {
|
||||||
|
"timestamp_utc": ts,
|
||||||
|
"username": username,
|
||||||
|
"status_code": status_code,
|
||||||
|
"note": note,
|
||||||
|
"request_payload": request_payload,
|
||||||
|
"response": response_json,
|
||||||
|
}
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(doc, f, ensure_ascii=False, indent=2)
|
||||||
|
return filepath
|
||||||
88
utils/boxapi_parser.py
Normal file
88
utils/boxapi_parser.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# utils/boxapi_parser.py
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def _ts_to_dt(ts: Optional[int]) -> datetime:
|
||||||
|
# BoxAPI/IG timestamps appear to be epoch seconds
|
||||||
|
if ts is None:
|
||||||
|
return datetime.utcnow()
|
||||||
|
return datetime.utcfromtimestamp(int(ts))
|
||||||
|
|
||||||
|
def pick_image_urls(item: Dict[str, Any]) -> List[str]:
|
||||||
|
urls: List[str] = []
|
||||||
|
image_versions2 = item.get("image_versions2") or {}
|
||||||
|
for c in image_versions2.get("candidates", []):
|
||||||
|
u = c.get("url")
|
||||||
|
if u:
|
||||||
|
urls.append(u)
|
||||||
|
# Fallback to additional_candidates.first_frame if exists
|
||||||
|
add = image_versions2.get("additional_candidates") or {}
|
||||||
|
first_frame = add.get("first_frame") or {}
|
||||||
|
if first_frame.get("url"):
|
||||||
|
urls.append(first_frame["url"])
|
||||||
|
return list(dict.fromkeys(urls)) # dedupe
|
||||||
|
|
||||||
|
def pick_video_urls(item: Dict[str, Any]) -> List[str]:
|
||||||
|
urls: List[str] = []
|
||||||
|
for v in item.get("video_versions", []) or []:
|
||||||
|
u = v.get("url")
|
||||||
|
if u:
|
||||||
|
urls.append(u)
|
||||||
|
# Fallback: progressive_download_url if present
|
||||||
|
prog = (item.get("clips_metadata") or {}).get("original_sound_info") or {}
|
||||||
|
prog_url = prog.get("progressive_download_url")
|
||||||
|
if prog_url:
|
||||||
|
urls.append(prog_url)
|
||||||
|
return list(dict.fromkeys(urls))
|
||||||
|
|
||||||
|
def parse_boxapi_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Normalized items for storage:
|
||||||
|
{
|
||||||
|
"ig_post_id": str,
|
||||||
|
"media_type": "image"|"video"|"carousel",
|
||||||
|
"caption": str,
|
||||||
|
"taken_at": datetime,
|
||||||
|
"thumbnail_url": Optional[str],
|
||||||
|
"remote_urls": List[str], # to be uploaded to S3
|
||||||
|
"code": Optional[str]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for it in items:
|
||||||
|
mt_raw = it.get("media_type")
|
||||||
|
if mt_raw == 1:
|
||||||
|
mtype = "image"
|
||||||
|
remote_urls = pick_image_urls(it)
|
||||||
|
elif mt_raw == 2:
|
||||||
|
mtype = "video"
|
||||||
|
remote_urls = pick_video_urls(it)
|
||||||
|
# Add a cover frame if available (first image candidate)
|
||||||
|
cover = pick_image_urls(it)[:1]
|
||||||
|
remote_urls = cover + remote_urls
|
||||||
|
elif mt_raw == 8: # carousel, if BoxAPI returns it
|
||||||
|
mtype = "carousel"
|
||||||
|
remote_urls = []
|
||||||
|
for child in it.get("carousel_media", []) or []:
|
||||||
|
if child.get("media_type") == 1:
|
||||||
|
remote_urls += pick_image_urls(child)
|
||||||
|
elif child.get("media_type") == 2:
|
||||||
|
# put a cover first if exists
|
||||||
|
cover = pick_image_urls(child)[:1]
|
||||||
|
remote_urls += cover + pick_video_urls(child)
|
||||||
|
else:
|
||||||
|
# Unknown, skip
|
||||||
|
continue
|
||||||
|
|
||||||
|
cap = (it.get("caption") or {}).get("text") or ""
|
||||||
|
thumb = (it.get("image_versions2") or {}).get("candidates", [{}])[0].get("url")
|
||||||
|
out.append({
|
||||||
|
"ig_post_id": str(it.get("pk") or it.get("id") or ""),
|
||||||
|
"media_type": mtype,
|
||||||
|
"caption": cap,
|
||||||
|
"taken_at": _ts_to_dt(it.get("taken_at")),
|
||||||
|
"thumbnail_url": thumb,
|
||||||
|
"remote_urls": remote_urls,
|
||||||
|
"code": it.get("code"),
|
||||||
|
})
|
||||||
|
return out
|
||||||
Loading…
Reference in New Issue
Block a user