fix(network): Implement DoH-based DNS patch and robust Session handling for Social Publishers
Browse files- social_media_publishers/base.py +10 -0
- social_media_publishers/facebook/auth.py +4 -4
- social_media_publishers/facebook/publisher.py +8 -8
- social_media_publishers/instagram/auth.py +5 -5
- social_media_publishers/instagram/publisher.py +5 -5
- social_media_publishers/threads/auth.py +3 -3
- social_media_publishers/threads/publisher.py +5 -5
- src/network_utils.py +73 -27
social_media_publishers/base.py
CHANGED
|
@@ -45,6 +45,11 @@ class SocialAuthCreator(ABC):
|
|
| 45 |
|
| 46 |
# --- Common Implementation for Token Management ---
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
def _get_token_manager(self):
|
| 49 |
"""Initialize TokenManager from environment variables."""
|
| 50 |
encryption_key = get_config_value("encryption_key")
|
|
@@ -175,6 +180,11 @@ class SocialAuthCreator(ABC):
|
|
| 175 |
class SocialPublisher(ABC):
|
| 176 |
"""Abstract base class for publishing content to social media."""
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
@abstractmethod
|
| 179 |
def authenticate(self, account_id: Optional[str] = None) -> Any:
|
| 180 |
"""
|
|
|
|
| 45 |
|
| 46 |
# --- Common Implementation for Token Management ---
|
| 47 |
|
| 48 |
+
def _get_session(self):
|
| 49 |
+
"""Get a requests Session with resilient network configuration (retries, custom DNS)."""
|
| 50 |
+
from src.network_utils import get_resilient_session
|
| 51 |
+
return get_resilient_session()
|
| 52 |
+
|
| 53 |
def _get_token_manager(self):
|
| 54 |
"""Initialize TokenManager from environment variables."""
|
| 55 |
encryption_key = get_config_value("encryption_key")
|
|
|
|
| 180 |
class SocialPublisher(ABC):
|
| 181 |
"""Abstract base class for publishing content to social media."""
|
| 182 |
|
| 183 |
+
def _get_session(self):
|
| 184 |
+
"""Get a requests Session with resilient network configuration (retries, custom DNS)."""
|
| 185 |
+
from src.network_utils import get_resilient_session
|
| 186 |
+
return get_resilient_session()
|
| 187 |
+
|
| 188 |
@abstractmethod
|
| 189 |
def authenticate(self, account_id: Optional[str] = None) -> Any:
|
| 190 |
"""
|
social_media_publishers/facebook/auth.py
CHANGED
|
@@ -101,7 +101,7 @@ class FacebookAuthCreator(SocialAuthCreator):
|
|
| 101 |
'code': code
|
| 102 |
}
|
| 103 |
|
| 104 |
-
response =
|
| 105 |
if response.status_code != 200:
|
| 106 |
return {"success": False, "error": f"Token exchange failed: {response.text}"}
|
| 107 |
|
|
@@ -116,7 +116,7 @@ class FacebookAuthCreator(SocialAuthCreator):
|
|
| 116 |
'fb_exchange_token': short_lived_token
|
| 117 |
}
|
| 118 |
|
| 119 |
-
ll_response =
|
| 120 |
if ll_response.status_code == 200:
|
| 121 |
ll_data = ll_response.json()
|
| 122 |
user_access_token = ll_data.get('access_token', short_lived_token)
|
|
@@ -126,7 +126,7 @@ class FacebookAuthCreator(SocialAuthCreator):
|
|
| 126 |
print("⚠️ Could not get long-lived token, using short-lived")
|
| 127 |
|
| 128 |
# Get user's Facebook Pages
|
| 129 |
-
pages_response =
|
| 130 |
f"{self.GRAPH_URL}/me/accounts",
|
| 131 |
params={'access_token': user_access_token}
|
| 132 |
)
|
|
@@ -166,7 +166,7 @@ class FacebookAuthCreator(SocialAuthCreator):
|
|
| 166 |
}
|
| 167 |
|
| 168 |
try:
|
| 169 |
-
page_ll_response =
|
| 170 |
if page_ll_response.status_code == 200:
|
| 171 |
page_ll_data = page_ll_response.json()
|
| 172 |
page_access_token = page_ll_data.get('access_token', page_access_token)
|
|
|
|
| 101 |
'code': code
|
| 102 |
}
|
| 103 |
|
| 104 |
+
response = self._get_session().get(self.TOKEN_URL, params=token_params)
|
| 105 |
if response.status_code != 200:
|
| 106 |
return {"success": False, "error": f"Token exchange failed: {response.text}"}
|
| 107 |
|
|
|
|
| 116 |
'fb_exchange_token': short_lived_token
|
| 117 |
}
|
| 118 |
|
| 119 |
+
ll_response = self._get_session().get(self.TOKEN_URL, params=long_lived_params)
|
| 120 |
if ll_response.status_code == 200:
|
| 121 |
ll_data = ll_response.json()
|
| 122 |
user_access_token = ll_data.get('access_token', short_lived_token)
|
|
|
|
| 126 |
print("⚠️ Could not get long-lived token, using short-lived")
|
| 127 |
|
| 128 |
# Get user's Facebook Pages
|
| 129 |
+
pages_response = self._get_session().get(
|
| 130 |
f"{self.GRAPH_URL}/me/accounts",
|
| 131 |
params={'access_token': user_access_token}
|
| 132 |
)
|
|
|
|
| 166 |
}
|
| 167 |
|
| 168 |
try:
|
| 169 |
+
page_ll_response = self._get_session().get(self.TOKEN_URL, params=page_ll_params)
|
| 170 |
if page_ll_response.status_code == 200:
|
| 171 |
page_ll_data = page_ll_response.json()
|
| 172 |
page_access_token = page_ll_data.get('access_token', page_access_token)
|
social_media_publishers/facebook/publisher.py
CHANGED
|
@@ -141,7 +141,7 @@ class FacebookPublisher(SocialPublisher):
|
|
| 141 |
|
| 142 |
# Step 1: Start upload session
|
| 143 |
print(f"🎬 Starting Facebook video upload...")
|
| 144 |
-
start_response =
|
| 145 |
f"{self.GRAPH_URL}/{self.page_id}/videos",
|
| 146 |
params={
|
| 147 |
'access_token': self.access_token,
|
|
@@ -174,7 +174,7 @@ class FacebookPublisher(SocialPublisher):
|
|
| 174 |
progress = int((end_offset / file_size) * 100)
|
| 175 |
print(f"📤 Uploading: {progress}%")
|
| 176 |
|
| 177 |
-
transfer_response =
|
| 178 |
f"{self.GRAPH_URL}/{self.page_id}/videos",
|
| 179 |
params={
|
| 180 |
'access_token': self.access_token,
|
|
@@ -193,7 +193,7 @@ class FacebookPublisher(SocialPublisher):
|
|
| 193 |
|
| 194 |
# Step 3: Finish upload
|
| 195 |
print("🚀 Finishing upload...")
|
| 196 |
-
finish_response =
|
| 197 |
f"{self.GRAPH_URL}/{self.page_id}/videos",
|
| 198 |
params={
|
| 199 |
'access_token': self.access_token,
|
|
@@ -244,7 +244,7 @@ class FacebookPublisher(SocialPublisher):
|
|
| 244 |
params['after'] = page_token
|
| 245 |
|
| 246 |
print(f"DEBUG: Fetching videos from {endpoint} with params: {params}")
|
| 247 |
-
response =
|
| 248 |
|
| 249 |
p_text = response.text
|
| 250 |
print(f"DEBUG: Video Fetch Response: {p_text}")
|
|
@@ -263,7 +263,7 @@ class FacebookPublisher(SocialPublisher):
|
|
| 263 |
|
| 264 |
# Probe 2: Try without 'type=uploaded'
|
| 265 |
try:
|
| 266 |
-
all_vid_resp =
|
| 267 |
print(f"DEBUG: Probe /videos (no type): {all_vid_resp.text}")
|
| 268 |
except: pass
|
| 269 |
|
|
@@ -342,7 +342,7 @@ class FacebookPublisher(SocialPublisher):
|
|
| 342 |
if video['views'] > 0: continue # Skip if we already have views from fields
|
| 343 |
|
| 344 |
try:
|
| 345 |
-
insights_response =
|
| 346 |
f"{self.GRAPH_URL}/{video['id']}/video_insights",
|
| 347 |
params={
|
| 348 |
'access_token': self.access_token,
|
|
@@ -400,7 +400,7 @@ class FacebookPublisher(SocialPublisher):
|
|
| 400 |
|
| 401 |
# Try fetching Page/User info
|
| 402 |
try:
|
| 403 |
-
response =
|
| 404 |
if response.status_code == 200:
|
| 405 |
page_data = response.json()
|
| 406 |
page_name = page_data.get('name', 'Unknown')
|
|
@@ -414,7 +414,7 @@ class FacebookPublisher(SocialPublisher):
|
|
| 414 |
print(f"DEBUG: Stats - Page Info Fetch failed ({response.status_code}): {response.text}")
|
| 415 |
# Fallback for User Profile (simple fields)
|
| 416 |
params['fields'] = 'name,picture'
|
| 417 |
-
response =
|
| 418 |
if response.status_code == 200:
|
| 419 |
page_data = response.json()
|
| 420 |
page_name = page_data.get('name', 'Unknown')
|
|
|
|
| 141 |
|
| 142 |
# Step 1: Start upload session
|
| 143 |
print(f"🎬 Starting Facebook video upload...")
|
| 144 |
+
start_response = self._get_session().post(
|
| 145 |
f"{self.GRAPH_URL}/{self.page_id}/videos",
|
| 146 |
params={
|
| 147 |
'access_token': self.access_token,
|
|
|
|
| 174 |
progress = int((end_offset / file_size) * 100)
|
| 175 |
print(f"📤 Uploading: {progress}%")
|
| 176 |
|
| 177 |
+
transfer_response = self._get_session().post(
|
| 178 |
f"{self.GRAPH_URL}/{self.page_id}/videos",
|
| 179 |
params={
|
| 180 |
'access_token': self.access_token,
|
|
|
|
| 193 |
|
| 194 |
# Step 3: Finish upload
|
| 195 |
print("🚀 Finishing upload...")
|
| 196 |
+
finish_response = self._get_session().post(
|
| 197 |
f"{self.GRAPH_URL}/{self.page_id}/videos",
|
| 198 |
params={
|
| 199 |
'access_token': self.access_token,
|
|
|
|
| 244 |
params['after'] = page_token
|
| 245 |
|
| 246 |
print(f"DEBUG: Fetching videos from {endpoint} with params: {params}")
|
| 247 |
+
response = self._get_session().get(endpoint, params=params)
|
| 248 |
|
| 249 |
p_text = response.text
|
| 250 |
print(f"DEBUG: Video Fetch Response: {p_text}")
|
|
|
|
| 263 |
|
| 264 |
# Probe 2: Try without 'type=uploaded'
|
| 265 |
try:
|
| 266 |
+
all_vid_resp = self._get_session().get(f"{self.GRAPH_URL}/{self.page_id}/videos", params={'access_token': self.access_token, 'limit': 3})
|
| 267 |
print(f"DEBUG: Probe /videos (no type): {all_vid_resp.text}")
|
| 268 |
except: pass
|
| 269 |
|
|
|
|
| 342 |
if video['views'] > 0: continue # Skip if we already have views from fields
|
| 343 |
|
| 344 |
try:
|
| 345 |
+
insights_response = self._get_session().get(
|
| 346 |
f"{self.GRAPH_URL}/{video['id']}/video_insights",
|
| 347 |
params={
|
| 348 |
'access_token': self.access_token,
|
|
|
|
| 400 |
|
| 401 |
# Try fetching Page/User info
|
| 402 |
try:
|
| 403 |
+
response = self._get_session().get(endpoint, params=params)
|
| 404 |
if response.status_code == 200:
|
| 405 |
page_data = response.json()
|
| 406 |
page_name = page_data.get('name', 'Unknown')
|
|
|
|
| 414 |
print(f"DEBUG: Stats - Page Info Fetch failed ({response.status_code}): {response.text}")
|
| 415 |
# Fallback for User Profile (simple fields)
|
| 416 |
params['fields'] = 'name,picture'
|
| 417 |
+
response = self._get_session().get(endpoint, params=params)
|
| 418 |
if response.status_code == 200:
|
| 419 |
page_data = response.json()
|
| 420 |
page_name = page_data.get('name', 'Unknown')
|
social_media_publishers/instagram/auth.py
CHANGED
|
@@ -101,7 +101,7 @@ class InstagramAuthCreator(SocialAuthCreator):
|
|
| 101 |
'code': code
|
| 102 |
}
|
| 103 |
|
| 104 |
-
response =
|
| 105 |
if response.status_code != 200:
|
| 106 |
return {"success": False, "error": f"Token exchange failed: {response.text}"}
|
| 107 |
|
|
@@ -116,7 +116,7 @@ class InstagramAuthCreator(SocialAuthCreator):
|
|
| 116 |
'fb_exchange_token': short_lived_token
|
| 117 |
}
|
| 118 |
|
| 119 |
-
ll_response =
|
| 120 |
if ll_response.status_code == 200:
|
| 121 |
ll_data = ll_response.json()
|
| 122 |
access_token = ll_data.get('access_token', short_lived_token)
|
|
@@ -126,7 +126,7 @@ class InstagramAuthCreator(SocialAuthCreator):
|
|
| 126 |
print("⚠️ Could not get long-lived token, using short-lived")
|
| 127 |
|
| 128 |
# Get Facebook Pages linked to account
|
| 129 |
-
pages_response =
|
| 130 |
f"{self.GRAPH_URL}/me/accounts",
|
| 131 |
params={'access_token': access_token}
|
| 132 |
)
|
|
@@ -160,7 +160,7 @@ class InstagramAuthCreator(SocialAuthCreator):
|
|
| 160 |
|
| 161 |
# Check for linked Instagram account
|
| 162 |
try:
|
| 163 |
-
ig_response =
|
| 164 |
f"{self.GRAPH_URL}/{page_id}",
|
| 165 |
params={
|
| 166 |
'fields': 'instagram_business_account',
|
|
@@ -185,7 +185,7 @@ class InstagramAuthCreator(SocialAuthCreator):
|
|
| 185 |
ig_account_id = ig_account.get('id')
|
| 186 |
|
| 187 |
# Get Instagram username
|
| 188 |
-
ig_info_response =
|
| 189 |
f"{self.GRAPH_URL}/{ig_account_id}",
|
| 190 |
params={
|
| 191 |
'fields': 'username',
|
|
|
|
| 101 |
'code': code
|
| 102 |
}
|
| 103 |
|
| 104 |
+
response = self._get_session().get(self.TOKEN_URL, params=token_params)
|
| 105 |
if response.status_code != 200:
|
| 106 |
return {"success": False, "error": f"Token exchange failed: {response.text}"}
|
| 107 |
|
|
|
|
| 116 |
'fb_exchange_token': short_lived_token
|
| 117 |
}
|
| 118 |
|
| 119 |
+
ll_response = self._get_session().get(self.TOKEN_URL, params=long_lived_params)
|
| 120 |
if ll_response.status_code == 200:
|
| 121 |
ll_data = ll_response.json()
|
| 122 |
access_token = ll_data.get('access_token', short_lived_token)
|
|
|
|
| 126 |
print("⚠️ Could not get long-lived token, using short-lived")
|
| 127 |
|
| 128 |
# Get Facebook Pages linked to account
|
| 129 |
+
pages_response = self._get_session().get(
|
| 130 |
f"{self.GRAPH_URL}/me/accounts",
|
| 131 |
params={'access_token': access_token}
|
| 132 |
)
|
|
|
|
| 160 |
|
| 161 |
# Check for linked Instagram account
|
| 162 |
try:
|
| 163 |
+
ig_response = self._get_session().get(
|
| 164 |
f"{self.GRAPH_URL}/{page_id}",
|
| 165 |
params={
|
| 166 |
'fields': 'instagram_business_account',
|
|
|
|
| 185 |
ig_account_id = ig_account.get('id')
|
| 186 |
|
| 187 |
# Get Instagram username
|
| 188 |
+
ig_info_response = self._get_session().get(
|
| 189 |
f"{self.GRAPH_URL}/{ig_account_id}",
|
| 190 |
params={
|
| 191 |
'fields': 'username',
|
social_media_publishers/instagram/publisher.py
CHANGED
|
@@ -206,7 +206,7 @@ class InstagramPublisher(SocialPublisher):
|
|
| 206 |
"""POST with retry logic."""
|
| 207 |
for attempt in range(1, max_retries + 1):
|
| 208 |
try:
|
| 209 |
-
r =
|
| 210 |
if r.status_code == 200:
|
| 211 |
return r
|
| 212 |
print(f"⚠️ Attempt {attempt} failed ({r.status_code}): {r.text}")
|
|
@@ -222,7 +222,7 @@ class InstagramPublisher(SocialPublisher):
|
|
| 222 |
|
| 223 |
for attempt in range(1, max_attempts + 1):
|
| 224 |
params = {'access_token': self.access_token, 'fields': 'status_code'}
|
| 225 |
-
response =
|
| 226 |
result = response.json()
|
| 227 |
|
| 228 |
status = result.get('status_code')
|
|
@@ -281,7 +281,7 @@ class InstagramPublisher(SocialPublisher):
|
|
| 281 |
if page_token:
|
| 282 |
params['after'] = page_token
|
| 283 |
|
| 284 |
-
response =
|
| 285 |
if response.status_code != 200:
|
| 286 |
return {'error': f"API error: {response.text}", 'videos': []}
|
| 287 |
|
|
@@ -319,7 +319,7 @@ class InstagramPublisher(SocialPublisher):
|
|
| 319 |
if media_type in ('VIDEO', 'REELS'):
|
| 320 |
# Fetch insights for this media
|
| 321 |
try:
|
| 322 |
-
insights_resp =
|
| 323 |
f"{self.GRAPH_URL}/{media_id}/insights",
|
| 324 |
params={
|
| 325 |
'access_token': self.access_token,
|
|
@@ -383,7 +383,7 @@ class InstagramPublisher(SocialPublisher):
|
|
| 383 |
'fields': 'username,profile_picture_url,followers_count,media_count'
|
| 384 |
}
|
| 385 |
|
| 386 |
-
response =
|
| 387 |
if response.status_code != 200:
|
| 388 |
return {'error': f"API error: {response.text}"}
|
| 389 |
|
|
|
|
| 206 |
"""POST with retry logic."""
|
| 207 |
for attempt in range(1, max_retries + 1):
|
| 208 |
try:
|
| 209 |
+
r = self._get_session().post(url, params=params, timeout=30)
|
| 210 |
if r.status_code == 200:
|
| 211 |
return r
|
| 212 |
print(f"⚠️ Attempt {attempt} failed ({r.status_code}): {r.text}")
|
|
|
|
| 222 |
|
| 223 |
for attempt in range(1, max_attempts + 1):
|
| 224 |
params = {'access_token': self.access_token, 'fields': 'status_code'}
|
| 225 |
+
response = self._get_session().get(endpoint, params=params)
|
| 226 |
result = response.json()
|
| 227 |
|
| 228 |
status = result.get('status_code')
|
|
|
|
| 281 |
if page_token:
|
| 282 |
params['after'] = page_token
|
| 283 |
|
| 284 |
+
response = self._get_session().get(endpoint, params=params)
|
| 285 |
if response.status_code != 200:
|
| 286 |
return {'error': f"API error: {response.text}", 'videos': []}
|
| 287 |
|
|
|
|
| 319 |
if media_type in ('VIDEO', 'REELS'):
|
| 320 |
# Fetch insights for this media
|
| 321 |
try:
|
| 322 |
+
insights_resp = self._get_session().get(
|
| 323 |
f"{self.GRAPH_URL}/{media_id}/insights",
|
| 324 |
params={
|
| 325 |
'access_token': self.access_token,
|
|
|
|
| 383 |
'fields': 'username,profile_picture_url,followers_count,media_count'
|
| 384 |
}
|
| 385 |
|
| 386 |
+
response = self._get_session().get(endpoint, params=params)
|
| 387 |
if response.status_code != 200:
|
| 388 |
return {'error': f"API error: {response.text}"}
|
| 389 |
|
social_media_publishers/threads/auth.py
CHANGED
|
@@ -110,7 +110,7 @@ class ThreadsAuthCreator(SocialAuthCreator):
|
|
| 110 |
print(f"DEBUG: Threads Token Exchange Params: client_id={config['app_id'][:5]}..., redirect_uri={redirect_uri}")
|
| 111 |
|
| 112 |
# Threads token exchange
|
| 113 |
-
response =
|
| 114 |
if response.status_code != 200:
|
| 115 |
short_token_params = token_params.copy()
|
| 116 |
# Retry with GET or check docs? Threads API uses POST.
|
|
@@ -133,7 +133,7 @@ class ThreadsAuthCreator(SocialAuthCreator):
|
|
| 133 |
'access_token': short_lived_token
|
| 134 |
}
|
| 135 |
|
| 136 |
-
ll_response =
|
| 137 |
|
| 138 |
if ll_response.status_code == 200:
|
| 139 |
ll_data = ll_response.json()
|
|
@@ -145,7 +145,7 @@ class ThreadsAuthCreator(SocialAuthCreator):
|
|
| 145 |
|
| 146 |
# Get User Profile Info
|
| 147 |
# Endpoint: /me?fields=id,username,name,threads_profile_picture_url
|
| 148 |
-
profile_response =
|
| 149 |
f"{self.GRAPH_URL}/me",
|
| 150 |
params={
|
| 151 |
'fields': 'id,username,name,threads_profile_picture_url',
|
|
|
|
| 110 |
print(f"DEBUG: Threads Token Exchange Params: client_id={config['app_id'][:5]}..., redirect_uri={redirect_uri}")
|
| 111 |
|
| 112 |
# Threads token exchange
|
| 113 |
+
response = self._get_session().post(self.TOKEN_URL, data=token_data_body, params=token_url_params)
|
| 114 |
if response.status_code != 200:
|
| 115 |
short_token_params = token_params.copy()
|
| 116 |
# Retry with GET or check docs? Threads API uses POST.
|
|
|
|
| 133 |
'access_token': short_lived_token
|
| 134 |
}
|
| 135 |
|
| 136 |
+
ll_response = self._get_session().get(long_lived_url, params=long_lived_params)
|
| 137 |
|
| 138 |
if ll_response.status_code == 200:
|
| 139 |
ll_data = ll_response.json()
|
|
|
|
| 145 |
|
| 146 |
# Get User Profile Info
|
| 147 |
# Endpoint: /me?fields=id,username,name,threads_profile_picture_url
|
| 148 |
+
profile_response = self._get_session().get(
|
| 149 |
f"{self.GRAPH_URL}/me",
|
| 150 |
params={
|
| 151 |
'fields': 'id,username,name,threads_profile_picture_url',
|
social_media_publishers/threads/publisher.py
CHANGED
|
@@ -226,7 +226,7 @@ class ThreadsPublisher(SocialPublisher):
|
|
| 226 |
"""POST with retry logic."""
|
| 227 |
for attempt in range(1, max_retries + 1):
|
| 228 |
try:
|
| 229 |
-
r =
|
| 230 |
if r.status_code == 200:
|
| 231 |
return r
|
| 232 |
print(f"⚠️ Attempt {attempt} failed ({r.status_code}): {r.text}")
|
|
@@ -242,7 +242,7 @@ class ThreadsPublisher(SocialPublisher):
|
|
| 242 |
|
| 243 |
for attempt in range(1, max_attempts + 1):
|
| 244 |
params = {'access_token': self.access_token, 'fields': 'status'}
|
| 245 |
-
response =
|
| 246 |
result = response.json()
|
| 247 |
|
| 248 |
status = result.get('status')
|
|
@@ -271,7 +271,7 @@ class ThreadsPublisher(SocialPublisher):
|
|
| 271 |
'access_token': self.access_token,
|
| 272 |
'fields': 'id,username,name,threads_profile_picture_url,followers_count' # followers_count might not be available on all versions, but let's try
|
| 273 |
}
|
| 274 |
-
me_resp =
|
| 275 |
me_data = me_resp.json()
|
| 276 |
|
| 277 |
username = me_data.get('username', 'unknown')
|
|
@@ -294,7 +294,7 @@ class ThreadsPublisher(SocialPublisher):
|
|
| 294 |
'limit': 50
|
| 295 |
}
|
| 296 |
|
| 297 |
-
media_resp =
|
| 298 |
media_data = media_resp.json().get('data', [])
|
| 299 |
|
| 300 |
total_likes = 0
|
|
@@ -349,7 +349,7 @@ class ThreadsPublisher(SocialPublisher):
|
|
| 349 |
if page_token:
|
| 350 |
params['after'] = page_token
|
| 351 |
|
| 352 |
-
response =
|
| 353 |
data = response.json()
|
| 354 |
|
| 355 |
if 'error' in data:
|
|
|
|
| 226 |
"""POST with retry logic."""
|
| 227 |
for attempt in range(1, max_retries + 1):
|
| 228 |
try:
|
| 229 |
+
r = self._get_session().post(url, params=params, timeout=30)
|
| 230 |
if r.status_code == 200:
|
| 231 |
return r
|
| 232 |
print(f"⚠️ Attempt {attempt} failed ({r.status_code}): {r.text}")
|
|
|
|
| 242 |
|
| 243 |
for attempt in range(1, max_attempts + 1):
|
| 244 |
params = {'access_token': self.access_token, 'fields': 'status'}
|
| 245 |
+
response = self._get_session().get(endpoint, params=params)
|
| 246 |
result = response.json()
|
| 247 |
|
| 248 |
status = result.get('status')
|
|
|
|
| 271 |
'access_token': self.access_token,
|
| 272 |
'fields': 'id,username,name,threads_profile_picture_url,followers_count' # followers_count might not be available on all versions, but let's try
|
| 273 |
}
|
| 274 |
+
me_resp = self._get_session().get(me_endpoint, params=me_params)
|
| 275 |
me_data = me_resp.json()
|
| 276 |
|
| 277 |
username = me_data.get('username', 'unknown')
|
|
|
|
| 294 |
'limit': 50
|
| 295 |
}
|
| 296 |
|
| 297 |
+
media_resp = self._get_session().get(media_endpoint, params=media_params)
|
| 298 |
media_data = media_resp.json().get('data', [])
|
| 299 |
|
| 300 |
total_likes = 0
|
|
|
|
| 349 |
if page_token:
|
| 350 |
params['after'] = page_token
|
| 351 |
|
| 352 |
+
response = self._get_session().get(endpoint, params=params)
|
| 353 |
data = response.json()
|
| 354 |
|
| 355 |
if 'error' in data:
|
src/network_utils.py
CHANGED
|
@@ -1,39 +1,85 @@
|
|
| 1 |
-
import socket
|
| 2 |
import logging
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
logger = logging.getLogger(__name__)
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
-
|
| 9 |
"""
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
def force_ipv4_requests():
|
| 14 |
"""
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
"""
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
#
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
patched = True
|
| 25 |
-
logger.info("✅ Patched urllib3 for IPv4-only requests")
|
| 26 |
-
except ImportError:
|
| 27 |
-
pass
|
| 28 |
|
| 29 |
-
# Try patching requests vendored urllib3
|
| 30 |
-
try:
|
| 31 |
-
import requests.packages.urllib3.util.connection as requests_urllib3_cn
|
| 32 |
-
requests_urllib3_cn.allowed_gai_family = allowed_gai_family
|
| 33 |
-
patched = True
|
| 34 |
-
logger.info("✅ Patched requests.packages.urllib3 for IPv4-only requests")
|
| 35 |
-
except ImportError:
|
| 36 |
-
pass
|
| 37 |
-
|
| 38 |
-
if not patched:
|
| 39 |
-
logger.warning("⚠️ Could not patch urllib3 for IPv4 (modules not found)")
|
|
|
|
|
|
|
| 1 |
import logging
|
| 2 |
+
import socket
|
| 3 |
+
import json
|
| 4 |
+
import requests
|
| 5 |
+
from requests.adapters import HTTPAdapter
|
| 6 |
+
from requests.packages.urllib3.util.retry import Retry
|
| 7 |
|
| 8 |
logger = logging.getLogger(__name__)
|
| 9 |
|
| 10 |
+
# Cache for resolved IPs to avoid spamming DoH
|
| 11 |
+
_DNS_CACHE = {}
|
| 12 |
+
_ORIGINAL_GETADDRINFO = socket.getaddrinfo
|
| 13 |
+
|
| 14 |
+
def resolve_via_doh(hostname):
|
| 15 |
+
"""
|
| 16 |
+
Resolve hostname using Google DNS-over-HTTPS.
|
| 17 |
+
Bypasses local system resolver (/etc/resolv.conf).
|
| 18 |
+
"""
|
| 19 |
+
if hostname in _DNS_CACHE:
|
| 20 |
+
return _DNS_CACHE[hostname]
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
# We must use an IP for the DoH server to avoid DNS loop!
|
| 24 |
+
# 8.8.8.8 is Google DNS.
|
| 25 |
+
doh_url = f"https://8.8.8.8/resolve?name={hostname}&type=A"
|
| 26 |
+
|
| 27 |
+
# Use a fresh simple session without adapters to avoid recursion
|
| 28 |
+
# But wait, requests might try to resolve 8.8.8.8? No, it's an IP.
|
| 29 |
+
resp = requests.get(doh_url, timeout=5, verify=False) # Skip verify for DoH to be fast/safe
|
| 30 |
+
data = resp.json()
|
| 31 |
+
|
| 32 |
+
if 'Answer' in data:
|
| 33 |
+
for answer in data['Answer']:
|
| 34 |
+
if answer['type'] == 1: # A Record
|
| 35 |
+
ip = answer['data']
|
| 36 |
+
logger.info(f"🔍 DoH Resolved {hostname} -> {ip}")
|
| 37 |
+
_DNS_CACHE[hostname] = ip
|
| 38 |
+
return ip
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.error(f"⚠️ DoH Resolution failed for {hostname}: {e}")
|
| 41 |
+
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
def patched_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
|
| 45 |
"""
|
| 46 |
+
Custom getaddrinfo that attempts DoH if system resolution fails or for specific hosts.
|
| 47 |
"""
|
| 48 |
+
# Only intervene for Facebook graph API which is failing
|
| 49 |
+
if host == 'graph.facebook.com' or host == 'www.facebook.com':
|
| 50 |
+
ip = resolve_via_doh(host)
|
| 51 |
+
if ip:
|
| 52 |
+
# Call original with IP instead of hostname
|
| 53 |
+
# Log usage for debugging
|
| 54 |
+
# logger.debug(f"Redirecting {host} to {ip}")
|
| 55 |
+
return _ORIGINAL_GETADDRINFO(ip, port, family, type, proto, flags)
|
| 56 |
+
|
| 57 |
+
# Fallback to system resolver
|
| 58 |
+
return _ORIGINAL_GETADDRINFO(host, port, family, type, proto, flags)
|
| 59 |
|
| 60 |
def force_ipv4_requests():
|
| 61 |
"""
|
| 62 |
+
Installs the DNS patch.
|
| 63 |
+
Named 'force_ipv4_requests' for backward compatibility with existing calls.
|
| 64 |
+
"""
|
| 65 |
+
if socket.getaddrinfo != patched_getaddrinfo:
|
| 66 |
+
socket.getaddrinfo = patched_getaddrinfo
|
| 67 |
+
logger.info("🛡️ Installed DoH-based DNS patch for Facebook API")
|
| 68 |
+
else:
|
| 69 |
+
logger.info("🛡️ DNS patch already active")
|
| 70 |
+
|
| 71 |
+
def get_resilient_session():
|
| 72 |
+
"""
|
| 73 |
+
Returns a session with retry logic.
|
| 74 |
"""
|
| 75 |
+
session = requests.Session()
|
| 76 |
+
retry = Retry(total=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504])
|
| 77 |
+
adapter = HTTPAdapter(max_retries=retry)
|
| 78 |
+
session.mount('https://', adapter)
|
| 79 |
+
session.mount('http://', adapter)
|
| 80 |
|
| 81 |
+
# Ensure patch is applied whenever we get a session
|
| 82 |
+
force_ipv4_requests()
|
| 83 |
+
|
| 84 |
+
return session
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|