update
This commit is contained in:
parent
8020a8aff3
commit
898705ed51
109
database.py
109
database.py
@ -1,32 +1,40 @@
|
|||||||
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, Boolean, Text, ForeignKey
|
# database.py
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from sqlalchemy import (
|
||||||
|
create_engine, Column, Integer, String, Float, DateTime, Boolean, Text,
|
||||||
|
ForeignKey, UniqueConstraint, Index
|
||||||
|
)
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker, relationship
|
from sqlalchemy.orm import sessionmaker, relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
# Database URL from environment variable
|
# -----------------------------------------------------------------------------
|
||||||
|
# Database URL
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
if not DATABASE_URL:
|
if not DATABASE_URL:
|
||||||
# Fallback to constructing from individual variables
|
|
||||||
POSTGRES_USER = os.getenv("POSTGRES_USER", "fastapi_user")
|
POSTGRES_USER = os.getenv("POSTGRES_USER", "fastapi_user")
|
||||||
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "fastapi_password")
|
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "fastapi_password")
|
||||||
POSTGRES_HOST = os.getenv("POSTGRES_HOST", "postgres")
|
POSTGRES_HOST = os.getenv("POSTGRES_HOST", "postgres")
|
||||||
POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432")
|
POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432")
|
||||||
POSTGRES_DB = os.getenv("POSTGRES_DB", "fastapi_db")
|
POSTGRES_DB = os.getenv("POSTGRES_DB", "fastapi_db")
|
||||||
DATABASE_URL = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"
|
DATABASE_URL = (
|
||||||
|
f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}"
|
||||||
|
f"@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"
|
||||||
|
)
|
||||||
|
|
||||||
# Create SQLAlchemy engine
|
# -----------------------------------------------------------------------------
|
||||||
|
# Engine / Session / Base
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
engine = create_engine(DATABASE_URL)
|
engine = create_engine(DATABASE_URL)
|
||||||
|
|
||||||
# Create SessionLocal class
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
# Create Base class
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
# Basic Item model (for backward compatibility)
|
# -----------------------------------------------------------------------------
|
||||||
|
# Basic Item model (kept for backward compatibility / health checks)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
class Item(Base):
|
class Item(Base):
|
||||||
__tablename__ = "items"
|
__tablename__ = "items"
|
||||||
|
|
||||||
@ -37,37 +45,84 @@ class Item(Base):
|
|||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
# Instagram Account Model
|
# -----------------------------------------------------------------------------
|
||||||
|
# Cursor for BoxAPI pagination (persist next_max_id per username)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class InstaCursor(Base):
|
||||||
|
__tablename__ = "insta_cursors"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
username = Column(String(150), nullable=False, unique=True)
|
||||||
|
next_max_id = Column(String(255), nullable=True)
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("username", name="uq_insta_cursor_username"),
|
||||||
|
Index("ix_insta_cursors_username", "username"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Instagram Account
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
class InstagramAccount(Base):
|
class InstagramAccount(Base):
|
||||||
__tablename__ = "instagram_accounts"
|
__tablename__ = "instagram_accounts"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
seller_id = Column(UUID(as_uuid=True), nullable=False)
|
seller_id = Column(UUID(as_uuid=True), nullable=False)
|
||||||
username = Column(String(100), nullable=False, unique=True)
|
username = Column(String(100), nullable=False, unique=True, index=True)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
last_synced = Column(DateTime(timezone=True), nullable=True)
|
last_synced = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
# Relationship
|
posts = relationship(
|
||||||
posts = relationship("InstagramPost", back_populates="account", cascade="all, delete-orphan")
|
"InstagramPost",
|
||||||
|
back_populates="account",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Instagram Post Model
|
__table_args__ = (
|
||||||
|
UniqueConstraint("username", name="uq_instagram_accounts_username"),
|
||||||
|
Index("ix_instagram_accounts_username", "username"),
|
||||||
|
Index("ix_instagram_accounts_seller_id", "seller_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Instagram Post
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
class InstagramPost(Base):
|
class InstagramPost(Base):
|
||||||
__tablename__ = "instagram_posts"
|
__tablename__ = "instagram_posts"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
account_id = Column(UUID(as_uuid=True), ForeignKey("instagram_accounts.id"), nullable=False)
|
account_id = Column(
|
||||||
ig_post_id = Column(String(100), nullable=False, unique=True)
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("instagram_accounts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
ig_post_id = Column(String(100), nullable=False, unique=True, index=True)
|
||||||
media_type = Column(String(10), nullable=False) # 'image', 'video', 'carousel'
|
media_type = Column(String(10), nullable=False) # 'image', 'video', 'carousel'
|
||||||
caption = Column(Text, nullable=True)
|
caption = Column(Text, nullable=True)
|
||||||
thumbnail_url = Column(Text, nullable=True)
|
thumbnail_url = Column(Text, nullable=True)
|
||||||
local_media = Column(JSONB, nullable=True) # List of media items with type and local path
|
|
||||||
created_at = Column(DateTime(timezone=True), nullable=False) # Original post time on Instagram
|
|
||||||
fetched_at = Column(DateTime(timezone=True), server_default=func.now()) # Time when scraped
|
|
||||||
|
|
||||||
# Relationship
|
# List[str] of public S3 URLs (stored as JSON array)
|
||||||
|
local_media = Column(JSONB, nullable=True)
|
||||||
|
|
||||||
|
# Original post time on Instagram
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
# When we scraped it
|
||||||
|
fetched_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
account = relationship("InstagramAccount", back_populates="posts")
|
account = relationship("InstagramAccount", back_populates="posts")
|
||||||
|
|
||||||
# Dependency to get database session
|
__table_args__ = (
|
||||||
|
UniqueConstraint("ig_post_id", name="uq_instagram_posts_ig_post_id"),
|
||||||
|
Index("ix_instagram_posts_created_at", "created_at"),
|
||||||
|
Index("ix_instagram_posts_media_type", "media_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Session dependency
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def get_db():
|
def get_db():
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -75,6 +130,8 @@ def get_db():
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
# Create tables
|
# -----------------------------------------------------------------------------
|
||||||
|
# Table creation
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def create_tables():
|
def create_tables():
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|||||||
65
scraper.py
65
scraper.py
@ -1,7 +1,9 @@
|
|||||||
|
# scraper.py
|
||||||
|
|
||||||
# --- imports ---
|
# --- imports ---
|
||||||
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_page, BoxAPIError # ← paged client
|
||||||
from utils.parser_fast import parse_boxapi_items_fast
|
from utils.parser_fast import parse_boxapi_items_fast
|
||||||
from utils.seen_store import filter_unseen, mark_seen
|
from utils.seen_store import filter_unseen, mark_seen
|
||||||
# use optimized uploaders; keep the generic fallback as well
|
# use optimized uploaders; keep the generic fallback as well
|
||||||
@ -11,13 +13,49 @@ from utils.s3_uploader import (
|
|||||||
upload_video_from_url_optimized,
|
upload_video_from_url_optimized,
|
||||||
)
|
)
|
||||||
from schemas import ScrapeRequest, ScrapedPost
|
from schemas import ScrapeRequest, ScrapedPost
|
||||||
from typing import List
|
from typing import List, Dict, Any, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from scraper_cursor import get_cursor, set_cursor # ← new helpers
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_across_pages(username: str, target: int, seed_cursor: Optional[str]) -> tuple[list[Dict[str, Any]], Optional[str]]:
|
||||||
|
"""
|
||||||
|
1) Fetch ONE newest page (max_id=None).
|
||||||
|
2) If still short, continue with `seed_cursor` (or newest next_max_id) to backfill older pages.
|
||||||
|
Returns (raw_items, final_cursor_to_store).
|
||||||
|
"""
|
||||||
|
collected: list[Dict[str, Any]] = []
|
||||||
|
newest_next: Optional[str] = None
|
||||||
|
|
||||||
|
# Step 1: newest page first (cheap check for new content)
|
||||||
|
need = min(12, target)
|
||||||
|
try:
|
||||||
|
page, _log, newest_next = get_media_page(username=username, count=need, max_id=None)
|
||||||
|
if page:
|
||||||
|
collected.extend(page)
|
||||||
|
except Exception:
|
||||||
|
# ignore and continue with backfill-only path
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Step 2: continue older pages from stored cursor (or newest_next if none stored)
|
||||||
|
cursor = seed_cursor or newest_next
|
||||||
|
while len(collected) < target and cursor:
|
||||||
|
need = min(12, target - len(collected))
|
||||||
|
page, _log, cursor = get_media_page(username=username, count=need, max_id=cursor)
|
||||||
|
if not page:
|
||||||
|
break
|
||||||
|
collected.extend(page)
|
||||||
|
|
||||||
|
return collected, cursor # final cursor to persist
|
||||||
|
|
||||||
|
|
||||||
def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
||||||
|
target = int(req.max_count)
|
||||||
|
|
||||||
|
# 1) Collect raw items across newest + backfill pages (no request schema change)
|
||||||
|
stored_cursor = get_cursor(db, req.username) # may be None on first run
|
||||||
try:
|
try:
|
||||||
raw_items, log_path = get_media(req.username, max(req.max_count, 30))
|
raw_items, final_cursor = _collect_across_pages(req.username, target, stored_cursor)
|
||||||
except BoxAPIError as e:
|
except BoxAPIError as e:
|
||||||
print(f"❌ BoxAPI error: {e}")
|
print(f"❌ BoxAPI error: {e}")
|
||||||
return []
|
return []
|
||||||
@ -25,9 +63,10 @@ def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
|||||||
if not raw_items:
|
if not raw_items:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# 2) Normalize once on the aggregated list
|
||||||
normalized = parse_boxapi_items_fast(raw_items)
|
normalized = parse_boxapi_items_fast(raw_items)
|
||||||
|
|
||||||
# optional cursor filter
|
# 3) Optional cursor filter (kept as-is)
|
||||||
since_ts = getattr(req, "since_taken_at", None)
|
since_ts = getattr(req, "since_taken_at", None)
|
||||||
if since_ts:
|
if since_ts:
|
||||||
try:
|
try:
|
||||||
@ -36,16 +75,19 @@ def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# filter seen
|
# 4) Filter already-seen FIRST (avoid re-uploading) — ensure filter_unseen has NO hidden 5-cap
|
||||||
ids_in_order = [n["ig_post_id"] for n in normalized if n.get("ig_post_id")]
|
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_ids = set(filter_unseen(req.username, ids_in_order))
|
||||||
unseen = [n for n in normalized if n["ig_post_id"] in unseen_ids]
|
unseen = [n for n in normalized if n["ig_post_id"] in unseen_ids]
|
||||||
|
|
||||||
# pick next N
|
# 5) Clip to requested count and persist the backfill cursor (progress)
|
||||||
batch = unseen[:req.max_count]
|
batch = unseen[:target]
|
||||||
|
set_cursor(db, req.username, final_cursor)
|
||||||
|
|
||||||
|
# 6) Mark these as seen so the next run skips them
|
||||||
mark_seen(req.username, [b["ig_post_id"] for b in batch])
|
mark_seen(req.username, [b["ig_post_id"] for b in batch])
|
||||||
|
|
||||||
# ensure account
|
# 7) Ensure account row
|
||||||
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(
|
||||||
@ -61,18 +103,19 @@ def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
|||||||
result: List[ScrapedPost] = []
|
result: List[ScrapedPost] = []
|
||||||
new_post_count = 0
|
new_post_count = 0
|
||||||
|
|
||||||
|
# 8) Upload + save each post in the batch
|
||||||
for item in batch:
|
for item in batch:
|
||||||
post_id = str(item["ig_post_id"]) # ENSURE clean, no slashes
|
post_id = str(item["ig_post_id"]) # ENSURE clean, no slashes
|
||||||
seller_id = str(req.seller_id)
|
seller_id = str(req.seller_id)
|
||||||
|
|
||||||
# skip if already saved
|
# skip if already saved (race-safety)
|
||||||
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
|
||||||
|
|
||||||
# media URL list (normalized parser gives remote_urls)
|
# media URL list (normalized parser gives remote_urls)
|
||||||
media_urls = list(item.get("remote_urls", []))
|
media_urls = list(item.get("remote_urls", []))
|
||||||
|
|
||||||
# If this is a video post, keep only true video URLs
|
# If this is a video post, keep only true video URLs (avoid thumbnail duplication)
|
||||||
if item["media_type"] == "video":
|
if item["media_type"] == "video":
|
||||||
thumb = (item.get("thumbnail_url") or "").strip()
|
thumb = (item.get("thumbnail_url") or "").strip()
|
||||||
def is_video(u: str) -> bool:
|
def is_video(u: str) -> bool:
|
||||||
@ -142,7 +185,7 @@ def scrape_and_store(db: Session, req: ScrapeRequest) -> List[ScrapedPost]:
|
|||||||
))
|
))
|
||||||
|
|
||||||
new_post_count += 1
|
new_post_count += 1
|
||||||
if new_post_count >= req.max_count:
|
if new_post_count >= target:
|
||||||
break
|
break
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
17
scraper_cursor.py
Normal file
17
scraper_cursor.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# scraper_cursor.py
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from database import InstaCursor
|
||||||
|
|
||||||
|
def get_cursor(db: Session, username: str) -> Optional[str]:
|
||||||
|
row = db.query(InstaCursor).filter_by(username=username).first()
|
||||||
|
return row.next_max_id if row else None
|
||||||
|
|
||||||
|
def set_cursor(db: Session, username: str, next_max_id: Optional[str]) -> None:
|
||||||
|
row = db.query(InstaCursor).filter_by(username=username).first()
|
||||||
|
if row is None:
|
||||||
|
row = InstaCursor(username=username, next_max_id=next_max_id)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
row.next_max_id = next_max_id
|
||||||
|
db.commit()
|
||||||
@ -1,27 +1,43 @@
|
|||||||
import os, requests
|
# utils/boxapi_client.py
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
from typing import Any, Dict, List, Tuple, Optional
|
from typing import Any, Dict, List, Tuple, Optional
|
||||||
from utils.boxapi_log import log_boxapi_response
|
from utils.boxapi_log import log_boxapi_response
|
||||||
|
|
||||||
BOXAPI_USERNAME = os.environ.get("BOXAPI_USERNAME", "")
|
BOXAPI_USERNAME = os.environ.get("BOXAPI_USERNAME", "")
|
||||||
BOXAPI_PASSWORD = os.environ.get("BOXAPI_PASSWORD", "")
|
BOXAPI_PASSWORD = os.environ.get("BOXAPI_PASSWORD", "")
|
||||||
# Your .env can override to /get_media; default matches your cURL (/get_media_by_username)
|
# Default matches /get_media_by_username; override in .env if needed
|
||||||
BOXAPI_ENDPOINT = os.environ.get(
|
BOXAPI_ENDPOINT = os.environ.get(
|
||||||
"BOXAPI_ENDPOINT",
|
"BOXAPI_ENDPOINT",
|
||||||
"https://boxapi.ir/api/instagram/user/get_media_by_username"
|
"https://boxapi.ir/api/instagram/user/get_media_by_username",
|
||||||
)
|
)
|
||||||
|
|
||||||
class BoxAPIError(Exception):
|
class BoxAPIError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_media(username: str, count: int = 10) -> Tuple[List[Dict[str, Any]], str]:
|
|
||||||
|
def get_media_page(
|
||||||
|
username: str,
|
||||||
|
count: int = 10,
|
||||||
|
max_id: Optional[str] = None,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], str, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Calls BoxAPI and returns (items, log_path).
|
Perform a single BoxAPI request and return a page of results.
|
||||||
|
Returns: (items, log_path, next_max_id)
|
||||||
|
Notes:
|
||||||
|
- BoxAPI returns at most 12 items per request.
|
||||||
|
- Pass `max_id` to fetch older pages (use the previous page's next_max_id).
|
||||||
"""
|
"""
|
||||||
if not BOXAPI_USERNAME or not BOXAPI_PASSWORD:
|
if not BOXAPI_USERNAME or not BOXAPI_PASSWORD:
|
||||||
raise BoxAPIError("Missing BOXAPI_USERNAME/BOXAPI_PASSWORD in environment.")
|
raise BoxAPIError("Missing BOXAPI_USERNAME/BOXAPI_PASSWORD in environment.")
|
||||||
|
|
||||||
payload = {"username": username, "count": count}
|
# BoxAPI hard cap per request
|
||||||
resp = None
|
per_req = max(1, min(int(count or 1), 12))
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = {"username": username, "count": per_req}
|
||||||
|
if max_id:
|
||||||
|
payload["max_id"] = max_id
|
||||||
|
|
||||||
resp_json: Optional[Dict[str, Any]] = None
|
resp_json: Optional[Dict[str, Any]] = None
|
||||||
resp_text: Optional[str] = None
|
resp_text: Optional[str] = None
|
||||||
status_code: Optional[int] = None
|
status_code: Optional[int] = None
|
||||||
@ -35,29 +51,30 @@ def get_media(username: str, count: int = 10) -> Tuple[List[Dict[str, Any]], str
|
|||||||
)
|
)
|
||||||
status_code = resp.status_code
|
status_code = resp.status_code
|
||||||
resp_text = resp.text
|
resp_text = resp.text
|
||||||
# Try to parse JSON; if it fails, we'll log text and raise
|
|
||||||
|
# Try to parse JSON; if it fails, log and raise
|
||||||
try:
|
try:
|
||||||
resp_json = resp.json()
|
resp_json = resp.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
log_path = log_boxapi_response(
|
log_path = log_boxapi_response(
|
||||||
username, payload, status_code, response_text=resp_text,
|
username, payload, status_code, response_text=resp_text,
|
||||||
note="Non-JSON response"
|
note="Non-JSON response (error)"
|
||||||
)
|
)
|
||||||
raise BoxAPIError(f"BoxAPI non-JSON response. See log: {log_path}")
|
raise BoxAPIError(f"BoxAPI non-JSON response. See log: {log_path}")
|
||||||
|
|
||||||
# Expected shape: status -> response -> status_code + body.items
|
# Expected shape: status -> response -> status_code + body.items + body.next_max_id
|
||||||
top_status = resp_json.get("status")
|
top_status = resp_json.get("status")
|
||||||
inner = resp_json.get("response") or {}
|
inner = resp_json.get("response") or {}
|
||||||
inner_code = inner.get("status_code")
|
inner_code = inner.get("status_code")
|
||||||
body = inner.get("body") or {}
|
body = inner.get("body") or {}
|
||||||
items = body.get("items") or []
|
items = body.get("items") or []
|
||||||
|
next_max_id = body.get("next_max_id")
|
||||||
|
|
||||||
log_path = log_boxapi_response(
|
log_path = log_boxapi_response(
|
||||||
username, payload, inner_code, response_json=resp_json, note="OK"
|
username, payload, inner_code, response_json=resp_json, note="OK"
|
||||||
)
|
)
|
||||||
|
|
||||||
if top_status != "done" or inner_code != 200 or not isinstance(items, list):
|
if top_status != "done" or inner_code != 200 or not isinstance(items, list):
|
||||||
# Include a concise snapshot of keys to help debugging
|
|
||||||
snapshot = {
|
snapshot = {
|
||||||
"top_status": top_status,
|
"top_status": top_status,
|
||||||
"inner_code": inner_code,
|
"inner_code": inner_code,
|
||||||
@ -65,24 +82,53 @@ def get_media(username: str, count: int = 10) -> Tuple[List[Dict[str, Any]], str
|
|||||||
"items_type": str(type(items)),
|
"items_type": str(type(items)),
|
||||||
"items_len": len(items) if isinstance(items, list) else None,
|
"items_len": len(items) if isinstance(items, list) else None,
|
||||||
}
|
}
|
||||||
raise BoxAPIError(f"Unexpected response format from BoxAPI ({snapshot}). See log: {log_path}")
|
raise BoxAPIError(
|
||||||
|
f"Unexpected response format from BoxAPI ({snapshot}). See log: {log_path}"
|
||||||
|
)
|
||||||
|
|
||||||
return items, log_path
|
return items, log_path, next_max_id
|
||||||
|
|
||||||
except BoxAPIError:
|
except BoxAPIError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_path = log_boxapi_response(
|
log_path = log_boxapi_response(
|
||||||
username, payload, status_code, response_json=resp_json, response_text=resp_text,
|
username, payload, status_code,
|
||||||
note=f"HTTP/parse error: {e}"
|
response_json=resp_json, response_text=resp_text,
|
||||||
|
note=f"HTTP/parse error (error): {e}"
|
||||||
)
|
)
|
||||||
raise BoxAPIError(f"BoxAPI request failed ({e}). See log: {log_path}")
|
raise BoxAPIError(f"BoxAPI request failed ({e}). See log: {log_path}")
|
||||||
|
|
||||||
# Legacy function for backward compatibility
|
|
||||||
def fetch_boxapi_posts(username: str, count: int = 30) -> List[Dict]:
|
def get_media(username: str, count: int = 10) -> Tuple[List[Dict[str, Any]], str]:
|
||||||
|
"""
|
||||||
|
Backward-compatible wrapper that fetches ONE page (latest) without pagination.
|
||||||
|
Returns: (items, log_path)
|
||||||
|
"""
|
||||||
|
items, log_path, _next = get_media_page(username=username, count=count, max_id=None)
|
||||||
|
return items, log_path
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_boxapi_posts(username: str, count: int = 30) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Backward-compatible helper that PAGES until it collects `count` items (or exhausts).
|
||||||
|
Uses get_media_page(...) under the hood (12-per-request windows).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
items, _ = get_media(username, count)
|
target = max(1, int(count))
|
||||||
return items
|
collected: List[Dict[str, Any]] = []
|
||||||
|
next_id: Optional[str] = None
|
||||||
|
|
||||||
|
while len(collected) < target:
|
||||||
|
need = min(12, target - len(collected))
|
||||||
|
items, _log, next_id = get_media_page(username=username, count=need, max_id=next_id)
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
collected.extend(items)
|
||||||
|
if not next_id: # no more pages
|
||||||
|
break
|
||||||
|
|
||||||
|
return collected[:target]
|
||||||
|
|
||||||
except BoxAPIError as e:
|
except BoxAPIError as e:
|
||||||
print(f"❌ Failed to fetch posts from BoxAPI: {e}")
|
print(f"❌ Failed to fetch posts from BoxAPI: {e}")
|
||||||
return []
|
return []
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user