feat: Integrate OneUp API for social media publishing
Browse files- Implementation of OneUpClient for API interactions
- Refactor publisher.py to use OneUp for scheduling/publishing
- Update GCS utils to support signed URL generation for OneUp
- Add OneUp API specification file
social_media_publishers/oneup_api_spec.txt
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Authentication
|
| 2 |
+
Overview
|
| 3 |
+
To interact with the OneUp API, youβll need to authenticate using your personal API key. This key identifies your account and must be included in every API request.
|
| 4 |
+
|
| 5 |
+
Step 1: Generate Your API Key
|
| 6 |
+
Go to https://www.oneupapp.io/api-access.
|
| 7 |
+
Log in to your OneUp account.
|
| 8 |
+
Click Generate API Key.
|
| 9 |
+
Copy your generated API key β youβll use it in your API requests.
|
| 10 |
+
Step 2: Use Your API Key in Requests
|
| 11 |
+
Include your API key as a query parameter named apiKey in all your API calls.
|
| 12 |
+
|
| 13 |
+
Example Request:
|
| 14 |
+
|
| 15 |
+
GET https://www.oneupapp.io/api/listcategory?apiKey=YOUR_API_KEY
|
| 16 |
+
|
| 17 |
+
Example Response:
|
| 18 |
+
|
| 19 |
+
{
|
| 20 |
+
"message": "OK",
|
| 21 |
+
"error": false,
|
| 22 |
+
"data": [
|
| 23 |
+
{
|
| 24 |
+
"id": 49839,
|
| 25 |
+
"category_name": "AAA testingg",
|
| 26 |
+
"isPaused": 0,
|
| 27 |
+
"created_at": "2020-12-09 14:37:45"
|
| 28 |
+
}
|
| 29 |
+
]
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
β
Result: The API authenticates your key and returns data linked to your account.
|
| 33 |
+
|
| 34 |
+
List Categories
|
| 35 |
+
Overview
|
| 36 |
+
The List Categories endpoint allows you to retrieve all categories associated with your OneUp account. Each category is used to group and manage your connected social accounts, not posts. You can use these categories to identify which accounts belong together under the same group.
|
| 37 |
+
|
| 38 |
+
Base URL:
|
| 39 |
+
|
| 40 |
+
https://www.oneupapp.io
|
| 41 |
+
|
| 42 |
+
Endpoint
|
| 43 |
+
GET /api/listcategory?apiKey=YOUR_API_KEY
|
| 44 |
+
|
| 45 |
+
Example:
|
| 46 |
+
|
| 47 |
+
GET https://www.oneupapp.io/api/listcategory?apiKey=621544d93ffe2db52b01
|
| 48 |
+
|
| 49 |
+
Request Parameters
|
| 50 |
+
Parameter Required Description
|
| 51 |
+
apiKey Yes Your personal API key generated from the API Access page.
|
| 52 |
+
Sample Request
|
| 53 |
+
curl -X GET "https://www.oneupapp.io/api/listcategory?apiKey=YOUR_API_KEY"
|
| 54 |
+
|
| 55 |
+
Sample Response
|
| 56 |
+
{
|
| 57 |
+
"message": "OK",
|
| 58 |
+
"error": false,
|
| 59 |
+
"data": [
|
| 60 |
+
{
|
| 61 |
+
"id": 49839,
|
| 62 |
+
"category_name": "AAA testingg",
|
| 63 |
+
"isPaused": 0,
|
| 64 |
+
"created_at": "2020-12-09 14:37:45"
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"id": 63889,
|
| 68 |
+
"category_name": "Client #2",
|
| 69 |
+
"isPaused": 0,
|
| 70 |
+
"created_at": "2021-10-05 18:29:04"
|
| 71 |
+
}
|
| 72 |
+
]
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
β
Result: The response returns all categories linked to your account. Each category includes an id, category_name, and creation details β allowing you to later fetch the accounts grouped under that category.
|
| 76 |
+
|
| 77 |
+
List Category Accounts
|
| 78 |
+
Overview
|
| 79 |
+
The List Category Accounts endpoint allows you to retrieve all social accounts associated with a specific category. Each category can have one or more connected accounts, such as Facebook Pages, Twitter profiles, Google Business and more.
|
| 80 |
+
|
| 81 |
+
Base URL:
|
| 82 |
+
|
| 83 |
+
https://www.oneupapp.io
|
| 84 |
+
|
| 85 |
+
Endpoint
|
| 86 |
+
GET /api/listcategoryaccount?apiKey=YOUR_API_KEY&category_id=CATEGORY_ID
|
| 87 |
+
|
| 88 |
+
Example:
|
| 89 |
+
|
| 90 |
+
GET https://www.oneupapp.io/api/listcategoryaccount?apiKey=621544d93ffe2db52b01&category_id=49839
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
Request Parameters
|
| 94 |
+
Parameter Required Description
|
| 95 |
+
apiKey Yes Your personal API key generated from the API Access page.
|
| 96 |
+
category_id Yes The unique ID of the category whose connected accounts you want to retrieve.
|
| 97 |
+
Sample Request
|
| 98 |
+
curl -X GET "https://www.oneupapp.io/api/listcategoryaccount?apiKey=YOUR_API_KEY&category_id=CATEGORY_ID"
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
Sample Response
|
| 102 |
+
{
|
| 103 |
+
"message": "OK",
|
| 104 |
+
"error": false,
|
| 105 |
+
"data": [
|
| 106 |
+
{
|
| 107 |
+
"category_id": 49839,
|
| 108 |
+
"social_network_name": "kumarvishi",
|
| 109 |
+
"social_network_id": "pin_kumarvishi",
|
| 110 |
+
"social_network_type": "Pinterest"
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
"category_id": 49839,
|
| 114 |
+
"social_network_name": "OneUp (United States)",
|
| 115 |
+
"social_network_id": "accounts/116185162672310389659/locations/1366069594757511498",
|
| 116 |
+
"social_network_type": "GBP"
|
| 117 |
+
}
|
| 118 |
+
]
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
β
Result: The response lists all the social media accounts linked to the specified category. Each item includes:
|
| 122 |
+
|
| 123 |
+
social_network_name β The display name of the connected social account.
|
| 124 |
+
social_network_id β The unique identifier used when scheduling or publishing posts.
|
| 125 |
+
social_network_type β The type of network (e.g., Facebook, Pinterest, GBP, Twitter, etc.).
|
| 126 |
+
Usage Example
|
| 127 |
+
First, use the List Categories endpoint to get your desired category_id.
|
| 128 |
+
Then, use this endpoint to find all accounts linked to that category.
|
| 129 |
+
Finally, use the social_network_id values when creating or scheduling posts.
|
| 130 |
+
|
| 131 |
+
List Social Accounts
|
| 132 |
+
Overview
|
| 133 |
+
The List Social Accounts endpoint allows you to retrieve all social media accounts connected to your OneUp account. This endpoint lists every account, regardless of whether it belongs to a specific category or not.
|
| 134 |
+
|
| 135 |
+
Base URL:
|
| 136 |
+
|
| 137 |
+
https://www.oneupapp.io
|
| 138 |
+
|
| 139 |
+
Endpoint
|
| 140 |
+
GET /api/listsocialaccounts?apiKey=YOUR_API_KEY
|
| 141 |
+
|
| 142 |
+
Example:
|
| 143 |
+
|
| 144 |
+
GET https://www.oneupapp.io/api/listsocialaccounts?apiKey=621544d93ffe2db52b01
|
| 145 |
+
|
| 146 |
+
Request Parameters
|
| 147 |
+
Parameter Required Description
|
| 148 |
+
apiKey Yes Your personal API key generated from the API Access page.
|
| 149 |
+
Sample Request
|
| 150 |
+
curl -X GET "https://www.oneupapp.io/api/listsocialaccounts?apiKey=YOUR_API_KEY"
|
| 151 |
+
|
| 152 |
+
Sample Response
|
| 153 |
+
{
|
| 154 |
+
"message": "OK",
|
| 155 |
+
"error": false,
|
| 156 |
+
"data": [
|
| 157 |
+
{
|
| 158 |
+
"username": "SaaS_Growth_Guy",
|
| 159 |
+
"full_name": "SaaS growth tip guy",
|
| 160 |
+
"is_expired": 0,
|
| 161 |
+
"social_network_type": "X",
|
| 162 |
+
"need_refresh": false
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
"username": "Davis William",
|
| 166 |
+
"full_name": "Z Davis",
|
| 167 |
+
"is_expired": 0,
|
| 168 |
+
"social_network_type": "Facebook",
|
| 169 |
+
"need_refresh": false
|
| 170 |
+
}
|
| 171 |
+
]
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
β
Result: The response returns all the social accounts linked to your OneUp account. Each object includes:
|
| 175 |
+
|
| 176 |
+
username β The handle or username of the account.
|
| 177 |
+
full_name β The display name associated with the account.
|
| 178 |
+
is_expired β Indicates whether the account connection has expired (0 = active, 1 = expired).
|
| 179 |
+
social_network_type β The type of social network (e.g., Facebook, X, Pinterest, GBP, etc.).
|
| 180 |
+
need_refresh β Indicates if the account requires reauthorization.
|
| 181 |
+
|
| 182 |
+
Usage Example
|
| 183 |
+
This endpoint is useful when you want to:
|
| 184 |
+
|
| 185 |
+
Retrieve a full list of all connected social media accounts.
|
| 186 |
+
Identify which accounts may need to be refreshed or reconnected.
|
| 187 |
+
Use the social_network_type and username to display connected networks in your app dashboard.
|
| 188 |
+
|
| 189 |
+
Create Text Post
|
| 190 |
+
Overview
|
| 191 |
+
The Create Text Post endpoint allows you to schedule and publish text-based posts to one or multiple connected social accounts under a specific category. You can set the post content, assign it to a category, and define the date and time for scheduling.
|
| 192 |
+
|
| 193 |
+
Base URL:
|
| 194 |
+
|
| 195 |
+
https://www.oneupapp.io
|
| 196 |
+
|
| 197 |
+
Endpoint
|
| 198 |
+
POST /api/scheduletextpost
|
| 199 |
+
|
| 200 |
+
Request Parameters
|
| 201 |
+
Parameter Required Description
|
| 202 |
+
apiKey Yes Your personal API key generated from the API Access page.
|
| 203 |
+
category_id Yes The ID of the category that groups your target social accounts.
|
| 204 |
+
social_network_id Yes A JSON array of social network IDs where the post will be published. You can also set the value to ALL to publish the post across all accounts enabled for the selected category.
|
| 205 |
+
scheduled_date_time Yes The date and time (in YYYY-MM-DD HH:MM format) when the post should be published.
|
| 206 |
+
title No The title of your post.
|
| 207 |
+
content Yes The text content of your post.
|
| 208 |
+
first_comment No The first comment to be added to the post (optional and only for Facebook, Instagram, LinkedIn, and YouTube).
|
| 209 |
+
Sample Request
|
| 210 |
+
curl --location --request POST \
|
| 211 |
+
"https://www.oneupapp.io/api/scheduletextpost" \
|
| 212 |
+
--data-urlencode "apiKey=621544d93ffe2db52b01" \
|
| 213 |
+
--data-urlencode "category_id=49839" \
|
| 214 |
+
--data-urlencode 'social_network_id=["pin_kumarvishi","accounts/116185162672310389659/locations/1366069594757511498"]' \
|
| 215 |
+
--data-urlencode "scheduled_date_time=2025-10-23 14:00" \
|
| 216 |
+
--data-urlencode "title=Hello World Title" \
|
| 217 |
+
--data-urlencode "content=Hello World! This is a scheduled post."
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
Sample Response
|
| 221 |
+
{
|
| 222 |
+
"message": "1 new Posts Scheduled.",
|
| 223 |
+
"error": false,
|
| 224 |
+
"data": []
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
β
Result: Your post has been successfully scheduled! The message confirms that the post is queued for publishing at the specified date and time.
|
| 228 |
+
|
| 229 |
+
Platform Support & Limitations
|
| 230 |
+
Network Character Limit Notes
|
| 231 |
+
Facebook 10,000 Full text supported with formatting
|
| 232 |
+
X(Twitter) 280 25,000 for premium accounts
|
| 233 |
+
LinkedIn 3,000 Supports formatting like bold, italic
|
| 234 |
+
Instagram 2,200 For feed post captions
|
| 235 |
+
Google Business 1,500 For business updates
|
| 236 |
+
Pinterest 500 For pin descriptions
|
| 237 |
+
Threads 500 Maximum length
|
| 238 |
+
Bluesky 300 Maximum length
|
| 239 |
+
Tips & Notes
|
| 240 |
+
Make sure your target social accounts are active and not expired before scheduling.
|
| 241 |
+
The social_network_id must exactly match the account IDs retrieved from the List Category Accounts or List Social Accounts endpoints.
|
| 242 |
+
For immediate publishing, you can set the scheduled_date_time to the current timestamp.
|
| 243 |
+
Avoid overloading with too many simultaneous posts to prevent API rate limiting.
|
| 244 |
+
|
| 245 |
+
Create Image Post
|
| 246 |
+
Overview
|
| 247 |
+
The Create Image Post endpoint allows you to schedule and publish image-based posts to one or multiple connected social accounts under a specific category. You can set the post content, attach one or more images, assign it to a category, and define the date and time for scheduling.
|
| 248 |
+
|
| 249 |
+
Base URL:
|
| 250 |
+
|
| 251 |
+
https://www.oneupapp.io
|
| 252 |
+
|
| 253 |
+
Endpoint
|
| 254 |
+
POST /api/scheduleimagepost
|
| 255 |
+
|
| 256 |
+
Request Parameters
|
| 257 |
+
Parameter Required Description
|
| 258 |
+
apiKey Yes Your personal API key generated from the API Access page.
|
| 259 |
+
category_id Yes The ID of the category that groups your target social accounts.
|
| 260 |
+
social_network_id Yes A JSON array of social network IDs where the post will be published. You can also set the value to ALL to publish the post across all accounts enabled for the selected category.
|
| 261 |
+
scheduled_date_time Yes The date and time (in YYYY-MM-DD HH:MM format) when the post should be published.
|
| 262 |
+
title No The title of your post.
|
| 263 |
+
content Yes The text content of your post.
|
| 264 |
+
image_url Yes The URL(s) of the image(s). Separate multiple images with ~~.
|
| 265 |
+
first_comment No The first comment to be added to the post (optional and only for Facebook, Instagram, LinkedIn, and YouTube).
|
| 266 |
+
Platform-Specific Parameters
|
| 267 |
+
Some social networks support additional parameters for specific image post types:
|
| 268 |
+
|
| 269 |
+
Instagram:
|
| 270 |
+
|
| 271 |
+
instagram: A JSON object with the following option:
|
| 272 |
+
isStory (boolean): Set to true to publish as an Instagram Story. Default: false
|
| 273 |
+
Facebook:
|
| 274 |
+
|
| 275 |
+
facebook: A JSON object with the following option:
|
| 276 |
+
isStory (boolean): Set to true to publish as a Facebook Story. Default: false
|
| 277 |
+
Snapchat:
|
| 278 |
+
|
| 279 |
+
snapchat: A JSON object with the following option:
|
| 280 |
+
isSpotLight (boolean): Set to true to publish as a Snapchat Spotlight. Default: false
|
| 281 |
+
TikTok:
|
| 282 |
+
|
| 283 |
+
tiktok: A JSON object with the following option:
|
| 284 |
+
autoAddMusic (boolean): Set to true to automatically add music to the image post. Default: true
|
| 285 |
+
Example with platform-specific parameters:
|
| 286 |
+
|
| 287 |
+
curl --location --request POST \
|
| 288 |
+
"https://www.oneupapp.io/api/scheduleimagepost" \
|
| 289 |
+
--data-urlencode "apiKey=621544d93ffe2db52b01" \
|
| 290 |
+
--data-urlencode "category_id=49839" \
|
| 291 |
+
--data-urlencode 'social_network_id=["113024478527731"]' \
|
| 292 |
+
--data-urlencode "scheduled_date_time=2026-12-12 13:13" \
|
| 293 |
+
--data-urlencode "title=My Image Post" \
|
| 294 |
+
--data-urlencode "content=Image post" \
|
| 295 |
+
--data-urlencode "image_url=https://cdn.filestackcontent.com/BT933lwUSEKkmpfI9O57" \
|
| 296 |
+
--data-urlencode 'instagram={"isStory":true}'
|
| 297 |
+
|
| 298 |
+
Sample Response
|
| 299 |
+
{
|
| 300 |
+
"message": "1 new Posts Scheduled.",
|
| 301 |
+
"error": false,
|
| 302 |
+
"data": []
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
β
Result: Your image post (with one or multiple images) has been successfully scheduled! The message confirms that the post is queued for publishing at the specified date and time.
|
| 306 |
+
|
| 307 |
+
Platform Photo Upload Limits
|
| 308 |
+
Platform Max Photos Photo Size Limit Caption Support Notes
|
| 309 |
+
Facebook 20 No restriction Yes Supports alt text, no restrictions on photo size.
|
| 310 |
+
Instagram 10 8 MB Yes Minimum resolution: 320Γ320 px, automatically cropped.
|
| 311 |
+
TikTok 35 No restriction No Cannot mix photos with GIFs or videos.
|
| 312 |
+
Threads 20 8 MB No Cannot mix photos with GIFs or videos.
|
| 313 |
+
X(Twitter) 4 5 MB No Supports only up to 4 photos per post.
|
| 314 |
+
LinkedIn 9 No restriction No Photos and GIFs are supported, cannot mix with videos.
|
| 315 |
+
Bluesky 4 1 MB No Supports up to 4 images per post, alt text available.
|
| 316 |
+
Tips & Notes
|
| 317 |
+
Make sure your target social accounts are active and not expired before scheduling.
|
| 318 |
+
The social_network_id must match the account IDs retrieved from the List Category Accounts or List Social Accounts endpoints.
|
| 319 |
+
Ensure all image_urls are publicly accessible and point directly to image files.
|
| 320 |
+
Use ~~ to separate multiple images in the image_url parameter.
|
| 321 |
+
Avoid overloading with too many simultaneous posts to prevent API rate limiting.
|
| 322 |
+
When using platform-specific parameters, ensure the JSON is properly formatted and URL-encoded in the request.
|
| 323 |
+
|
| 324 |
+
Create Video Post
|
| 325 |
+
Overview
|
| 326 |
+
The Create Video Post endpoint allows you to schedule and publish video-based posts to one or multiple connected social accounts under a specific category. You can set the post content, attach a video URL, assign it to a category, and define the date and time for scheduling.
|
| 327 |
+
|
| 328 |
+
Base URL:
|
| 329 |
+
|
| 330 |
+
https://www.oneupapp.io
|
| 331 |
+
|
| 332 |
+
Endpoint
|
| 333 |
+
POST /api/schedulevideopost
|
| 334 |
+
|
| 335 |
+
Request Parameters
|
| 336 |
+
Parameter Required Description
|
| 337 |
+
apiKey Yes Your personal API key generated from the API Access page.
|
| 338 |
+
category_id Yes The ID of the category that groups your target social accounts.
|
| 339 |
+
social_network_id Yes A JSON array of social network IDs where the post will be published. You can also set the value to ALL to publish the post across all accounts enabled for the selected category.
|
| 340 |
+
scheduled_date_time Yes The date and time (in YYYY-MM-DD HH:MM format) when the post should be published.
|
| 341 |
+
title No The title of your post.
|
| 342 |
+
content Yes The text content of your post.
|
| 343 |
+
video_url Yes The URL to the video you want to attach to the post.
|
| 344 |
+
thumbnail_url No The URL of the thumbnail image for video posts (optional and only for video posts).
|
| 345 |
+
first_comment No The first comment to be added to the post (optional and only for Facebook, Instagram, LinkedIn, and YouTube).
|
| 346 |
+
Platform-Specific Parameters
|
| 347 |
+
Some social networks support additional parameters for specific video post types:
|
| 348 |
+
|
| 349 |
+
Instagram:
|
| 350 |
+
|
| 351 |
+
instagram: A JSON object with the following option:
|
| 352 |
+
isStory (boolean): Set to true to publish as an Instagram Story. Default: false
|
| 353 |
+
Facebook:
|
| 354 |
+
|
| 355 |
+
facebook: A JSON object with the following option:
|
| 356 |
+
isStory (boolean): Set to true to publish as a Facebook Story. Default: false
|
| 357 |
+
Snapchat:
|
| 358 |
+
|
| 359 |
+
snapchat: A JSON object with the following option:
|
| 360 |
+
isSpotLight (boolean): Set to true to publish as a Snapchat Spotlight. Default: false
|
| 361 |
+
Example with platform-specific parameters:
|
| 362 |
+
|
| 363 |
+
curl --location --request POST \
|
| 364 |
+
"https://www.oneupapp.io/api/schedulevideopost" \
|
| 365 |
+
--data-urlencode "apiKey=621544d93ffe2db52b01" \
|
| 366 |
+
--data-urlencode "category_id=49839" \
|
| 367 |
+
--data-urlencode 'social_network_id=["17841408823790514"]' \
|
| 368 |
+
--data-urlencode "scheduled_date_time=2026-12-12 13:13" \
|
| 369 |
+
--data-urlencode "title=My Video Post" \
|
| 370 |
+
--data-urlencode "content=Video post from API" \
|
| 371 |
+
--data-urlencode "video_url=https://cdn.filestackcontent.com/tpVlPE0qT7u4TwyPoA1M" \
|
| 372 |
+
--data-urlencode 'instagram={"isStory":true}'
|
| 373 |
+
|
| 374 |
+
Sample Response
|
| 375 |
+
{
|
| 376 |
+
"message": "1 new Posts Scheduled.",
|
| 377 |
+
"error": false,
|
| 378 |
+
"data": []
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
β
Result: Your video post has been successfully scheduled! The message confirms that the post is queued for publishing at the specified date and time.
|
| 382 |
+
|
| 383 |
+
Tips & Notes
|
| 384 |
+
Ensure your target social accounts are active and not expired before scheduling.
|
| 385 |
+
The social_network_id must exactly match the account IDs retrieved from the List Category Accounts or List Social Accounts endpoints.
|
| 386 |
+
The video_url must be publicly accessible and point directly to a video file.
|
| 387 |
+
For immediate publishing, set scheduled_date_time to the current timestamp.
|
| 388 |
+
Avoid overloading with too many simultaneous posts to prevent API rate limiting.
|
| 389 |
+
When using platform-specific parameters, ensure the JSON is properly formatted and URL-encoded in the request.
|
social_media_publishers/oneup_client.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
from typing import Dict, Any, List, Optional
|
| 3 |
+
from src.utils import logger
|
| 4 |
+
from src.config import get_config_value
|
| 5 |
+
|
| 6 |
+
class OneUpClient:
|
| 7 |
+
"""
|
| 8 |
+
Client for interacting with the OneUp API.
|
| 9 |
+
Docs: https://www.oneupapp.io/api-doc
|
| 10 |
+
"""
|
| 11 |
+
BASE_URL = "https://www.oneupapp.io/api"
|
| 12 |
+
|
| 13 |
+
def __init__(self, api_key: str = None):
|
| 14 |
+
self.api_key = api_key or get_config_value("ONEUP_API_KEY")
|
| 15 |
+
if not self.api_key:
|
| 16 |
+
raise ValueError("ONEUP_API_KEY is required but not found in config/env")
|
| 17 |
+
|
| 18 |
+
def _get_headers(self) -> Dict[str, str]:
|
| 19 |
+
"""Header not typically used for auth in OneUp (query param), but good practice."""
|
| 20 |
+
return {}
|
| 21 |
+
|
| 22 |
+
def _make_request(self, method: str, endpoint: str, params: Dict[str, Any] = None, data: Dict[str, Any] = None) -> Dict[str, Any]:
|
| 23 |
+
"""Common request handler with error checking."""
|
| 24 |
+
url = f"{self.BASE_URL}{endpoint}"
|
| 25 |
+
if params is None:
|
| 26 |
+
params = {}
|
| 27 |
+
|
| 28 |
+
# Always inject apiKey
|
| 29 |
+
params["apiKey"] = self.api_key
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
response = requests.request(method, url, params=params, data=data)
|
| 33 |
+
# Note: OneUp API often uses query params even for POSTs according to docs,
|
| 34 |
+
# or x-www-form-urlencoded. Docs say: --data-urlencode
|
| 35 |
+
# requests 'data' arg sends form-encoded by default.
|
| 36 |
+
|
| 37 |
+
response.raise_for_status()
|
| 38 |
+
result = response.json()
|
| 39 |
+
|
| 40 |
+
if result.get("error"):
|
| 41 |
+
raise Exception(f"OneUp API Error: {result.get('message', 'Unknown error')}")
|
| 42 |
+
|
| 43 |
+
return result
|
| 44 |
+
except requests.exceptions.RequestException as e:
|
| 45 |
+
logger.error(f"β OneUp API Request Failed ({endpoint}): {e}")
|
| 46 |
+
if e.response:
|
| 47 |
+
logger.error(f"Response: {e.response.text}")
|
| 48 |
+
raise
|
| 49 |
+
|
| 50 |
+
def get_categories(self) -> List[Dict[str, Any]]:
|
| 51 |
+
"""List all categories."""
|
| 52 |
+
response = self._make_request("GET", "/listcategory")
|
| 53 |
+
return response.get("data", [])
|
| 54 |
+
|
| 55 |
+
def get_accounts_in_category(self, category_id: int) -> List[Dict[str, Any]]:
|
| 56 |
+
"""List accounts in a specific category."""
|
| 57 |
+
params = {"category_id": category_id}
|
| 58 |
+
response = self._make_request("GET", "/listcategoryaccount", params=params)
|
| 59 |
+
return response.get("data", [])
|
| 60 |
+
|
| 61 |
+
def schedule_video_post(
|
| 62 |
+
self,
|
| 63 |
+
category_id: int,
|
| 64 |
+
social_network_ids: List[str],
|
| 65 |
+
video_url: str,
|
| 66 |
+
title: str = "",
|
| 67 |
+
content: str = "",
|
| 68 |
+
scheduled_date_time: str = None, # YYYY-MM-DD HH:MM
|
| 69 |
+
publish_now: bool = False
|
| 70 |
+
) -> Dict[str, Any]:
|
| 71 |
+
"""
|
| 72 |
+
Schedule a video post.
|
| 73 |
+
|
| 74 |
+
Args:
|
| 75 |
+
category_id: Target category ID
|
| 76 |
+
social_network_ids: List of account IDs or ["ALL"]
|
| 77 |
+
video_url: Public URL of the video
|
| 78 |
+
title: Post title
|
| 79 |
+
content: Post text/caption
|
| 80 |
+
scheduled_date_time: "YYYY-MM-DD HH:MM"
|
| 81 |
+
publish_now: If True, uses current time to publish immediately (overrides scheduled_date_time)
|
| 82 |
+
"""
|
| 83 |
+
from datetime import datetime
|
| 84 |
+
|
| 85 |
+
if publish_now:
|
| 86 |
+
scheduled_date_time = datetime.now().strftime("%Y-%m-%d %H:%M")
|
| 87 |
+
elif not scheduled_date_time:
|
| 88 |
+
raise ValueError("Must provide either scheduled_date_time or publish_now=True")
|
| 89 |
+
|
| 90 |
+
import json
|
| 91 |
+
|
| 92 |
+
# OneUp expects social_network_id as a JSON array string
|
| 93 |
+
# e.g. '["all"]' or '["id1", "id2"]'
|
| 94 |
+
# If the user passes ["ALL"] or ["all"], we respect that to post to all accounts in category
|
| 95 |
+
social_network_id_str = json.dumps(social_network_ids)
|
| 96 |
+
|
| 97 |
+
data = {
|
| 98 |
+
"category_id": category_id,
|
| 99 |
+
"social_network_id": social_network_id_str, # This needs to be passed in body/params
|
| 100 |
+
"scheduled_date_time": scheduled_date_time,
|
| 101 |
+
"title": title,
|
| 102 |
+
"content": content,
|
| 103 |
+
"video_url": video_url
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
# NOTE: The docs example shows data-urlencode for everything including apiKey.
|
| 107 |
+
# But we pass apiKey in query params via _make_request helper.
|
| 108 |
+
# We'll pass the rest in 'data' which requests handles as form-encoded.
|
| 109 |
+
|
| 110 |
+
return self._make_request("POST", "/schedulevideopost", data=data)
|
| 111 |
+
|
| 112 |
+
if __name__ == "__main__":
|
| 113 |
+
# Test block
|
| 114 |
+
try:
|
| 115 |
+
client = OneUpClient()
|
| 116 |
+
cats = client.get_categories()
|
| 117 |
+
print(f"Categories: {len(cats)}")
|
| 118 |
+
for c in cats:
|
| 119 |
+
print(f"- {c['id']}: {c['category_name']}")
|
| 120 |
+
accounts = client.get_accounts_in_category(c['id'])
|
| 121 |
+
print(f" Accounts: {len(accounts)}")
|
| 122 |
+
for a in accounts:
|
| 123 |
+
print(f" - {a.get('social_network_name')} ({a.get('social_network_type')})")
|
| 124 |
+
except Exception as e:
|
| 125 |
+
print(f"Test failed: {e}")
|
social_media_publishers/publisher.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
publisher.py
|
| 3 |
-
Unified social media publisher
|
| 4 |
-
Lists videos from GCS,
|
| 5 |
-
Runs 3x daily via GitHub Actions.
|
| 6 |
"""
|
| 7 |
|
| 8 |
import sys
|
|
@@ -18,38 +17,12 @@ from src.config import get_config_value
|
|
| 18 |
from src.google_src.gcs_utils import list_gcs_files, find_and_download_gcs_file, upload_file_to_gcs
|
| 19 |
from src.google_src.google_sheet import GoogleSheetReader
|
| 20 |
from src.utils import logger
|
| 21 |
-
|
| 22 |
-
# Initialize configuration
|
| 23 |
-
# Config is lazily loaded via ConfigProxy
|
| 24 |
-
|
| 25 |
-
# Available platforms to publish to (can be filtered via env var)
|
| 26 |
-
ALL_PLATFORMS = ["youtube", "tiktok", "instagram", "facebook"]
|
| 27 |
-
|
| 28 |
-
def get_active_platforms() -> List[str]:
|
| 29 |
-
"""Get list of active platforms from env var or default to all."""
|
| 30 |
-
platforms_env = get_config_value("SOCIAL_MEDIA_PUBLISH_PLATFORMS")
|
| 31 |
-
if platforms_env:
|
| 32 |
-
requested = [p.strip().lower() for p in platforms_env.split(",") if p.strip()]
|
| 33 |
-
return [p for p in requested if p in ALL_PLATFORMS]
|
| 34 |
-
return ALL_PLATFORMS
|
| 35 |
-
|
| 36 |
-
PLATFORMS = get_active_platforms()
|
| 37 |
|
| 38 |
# GSheet columns for publisher logs
|
| 39 |
LOG_HEADER = ["Timestamp", "Platform", "Published File", "Published URL", "Account", "Status", "Error"]
|
| 40 |
|
| 41 |
|
| 42 |
-
def get_upload_limit() -> Optional[int]:
|
| 43 |
-
"""Get upload limit from env var. Returns None if no limit set."""
|
| 44 |
-
limit = get_config_value("SOCIAL_MEDIA_PUBLISHER_UPLOAD_LIMIT")
|
| 45 |
-
if limit:
|
| 46 |
-
try:
|
| 47 |
-
return int(limit)
|
| 48 |
-
except ValueError:
|
| 49 |
-
return None
|
| 50 |
-
return None
|
| 51 |
-
|
| 52 |
-
|
| 53 |
def get_publisher_logs_worksheet() -> GoogleSheetReader:
|
| 54 |
"""Get GoogleSheetReader for publisher logs worksheet."""
|
| 55 |
worksheet_name = get_config_value("publisher_logs_worksheet")
|
|
@@ -77,157 +50,96 @@ def load_published_files() -> set:
|
|
| 77 |
|
| 78 |
def log_publish_result(
|
| 79 |
gcs_filename: str,
|
| 80 |
-
platform: str,
|
| 81 |
result: Dict[str, Any],
|
| 82 |
-
|
| 83 |
):
|
| 84 |
"""Log publish result to Google Sheets."""
|
| 85 |
try:
|
| 86 |
reader = get_publisher_logs_worksheet()
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
reader.create_or_update_sheet(
|
| 93 |
header=LOG_HEADER,
|
| 94 |
values=[{
|
| 95 |
"Timestamp": datetime.now().isoformat(),
|
| 96 |
-
"Platform":
|
| 97 |
"Published File": gcs_filename,
|
| 98 |
"Published URL": published_url,
|
| 99 |
-
"Account":
|
| 100 |
"Status": status,
|
| 101 |
"Error": error_msg
|
| 102 |
}]
|
| 103 |
)
|
| 104 |
-
logger.info(f"β Logged
|
| 105 |
except Exception as e:
|
| 106 |
logger.error(f"β Failed to log to GSheet: {e}")
|
| 107 |
|
| 108 |
|
| 109 |
-
def
|
| 110 |
-
"""Get list of connected accounts for a platform."""
|
| 111 |
-
try:
|
| 112 |
-
from social_media_publishers.factory import PublisherFactory
|
| 113 |
-
creator = PublisherFactory.get_auth_creator(platform)
|
| 114 |
-
return creator.list_connected_accounts()
|
| 115 |
-
except Exception as e:
|
| 116 |
-
logger.warning(f"β οΈ Could not list {platform} accounts: {e}")
|
| 117 |
-
return []
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def get_last_used_account(platform: str) -> Optional[str]:
|
| 121 |
-
"""Get the last used account for a platform from GSheet logs."""
|
| 122 |
-
try:
|
| 123 |
-
reader = get_publisher_logs_worksheet()
|
| 124 |
-
df = reader.get_dataframe()
|
| 125 |
-
|
| 126 |
-
if "Platform" in df.columns and "Account" in df.columns:
|
| 127 |
-
# Filter by platform and successful publishes
|
| 128 |
-
platform_df = df[df["Platform"] == platform]
|
| 129 |
-
if "Status" in df.columns:
|
| 130 |
-
platform_df = platform_df[platform_df["Status"] == "success"]
|
| 131 |
-
|
| 132 |
-
if not platform_df.empty:
|
| 133 |
-
# Get the most recent account used
|
| 134 |
-
last_account = platform_df.iloc[-1]["Account"]
|
| 135 |
-
return last_account if last_account else None
|
| 136 |
-
return None
|
| 137 |
-
except Exception as e:
|
| 138 |
-
logger.warning(f"β οΈ Could not get last used account for {platform}: {e}")
|
| 139 |
-
return None
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
def get_next_account_round_robin(platform: str, accounts: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 143 |
"""
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
"""
|
| 147 |
-
|
| 148 |
-
return None
|
| 149 |
-
|
| 150 |
-
if len(accounts) == 1:
|
| 151 |
-
return accounts[0]
|
| 152 |
-
|
| 153 |
-
# Get last used account
|
| 154 |
-
last_used = get_last_used_account(platform)
|
| 155 |
-
|
| 156 |
-
if not last_used:
|
| 157 |
-
# No previous publish, use first account
|
| 158 |
-
logger.info(f"π {platform}: No previous publish, using first account")
|
| 159 |
-
return accounts[0]
|
| 160 |
-
|
| 161 |
-
# Find the index of the last used account
|
| 162 |
-
account_names = [acc.get("name", "") for acc in accounts]
|
| 163 |
|
|
|
|
| 164 |
try:
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
return accounts[next_index]
|
| 170 |
-
except ValueError:
|
| 171 |
-
# Last used account not found in current list, use first
|
| 172 |
-
logger.info(f"π {platform}: Last account not found, using first account")
|
| 173 |
-
return accounts[0]
|
| 174 |
-
|
| 175 |
|
| 176 |
-
|
| 177 |
-
platform: str,
|
| 178 |
-
video_path: str,
|
| 179 |
-
gcs_filename: str,
|
| 180 |
-
account_id: str,
|
| 181 |
-
caption: str = ""
|
| 182 |
-
) -> Dict[str, Any]:
|
| 183 |
-
"""Publish video to a specific platform."""
|
| 184 |
try:
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
| 189 |
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
content_path = video_path
|
| 202 |
-
|
| 203 |
-
# Prepare metadata
|
| 204 |
-
metadata = {
|
| 205 |
-
"title": caption or "Check this out! π₯",
|
| 206 |
-
"description": caption or "#shorts #viral",
|
| 207 |
-
"caption": caption or "Check this out! π₯",
|
| 208 |
-
"privacy_status": "public" if get_config_value('video_publish_public') else "private",
|
| 209 |
-
"privacy_level": "PUBLIC_TO_EVERYONE" if get_config_value('video_publish_public') else "SELF_ONLY", # TikTok
|
| 210 |
-
}
|
| 211 |
-
|
| 212 |
-
result = publisher.publish(content_path, metadata)
|
| 213 |
-
return result
|
| 214 |
|
|
|
|
|
|
|
|
|
|
| 215 |
except Exception as e:
|
| 216 |
-
logger.
|
| 217 |
-
return {"error": str(e)}
|
| 218 |
|
|
|
|
| 219 |
|
| 220 |
-
|
| 221 |
-
"""
|
| 222 |
-
Main publisher workflow:
|
| 223 |
-
1. List videos from GCS
|
| 224 |
-
2. Check which are already published (from GSheet)
|
| 225 |
-
3. Publish unpublished videos to all platforms
|
| 226 |
-
4. Log results to GSheet
|
| 227 |
-
"""
|
| 228 |
-
logger.info("π Starting unified publisher...")
|
| 229 |
-
|
| 230 |
-
# Get all videos from GCS
|
| 231 |
gcs_files = list_gcs_files()
|
| 232 |
logger.info(f"π Found {len(gcs_files)} videos in GCS")
|
| 233 |
|
|
@@ -235,41 +147,15 @@ def run_publisher():
|
|
| 235 |
logger.info("β
No videos to publish")
|
| 236 |
return
|
| 237 |
|
| 238 |
-
# Load already published files
|
| 239 |
published = load_published_files()
|
| 240 |
-
|
| 241 |
-
# Filter to unpublished videos
|
| 242 |
unpublished = [f for f in gcs_files if f not in published]
|
| 243 |
logger.info(f"π {len(unpublished)} unpublished videos to process")
|
| 244 |
|
| 245 |
if not unpublished:
|
| 246 |
logger.info("β
All videos already published")
|
| 247 |
return
|
| 248 |
-
|
| 249 |
-
#
|
| 250 |
-
platform_accounts = {}
|
| 251 |
-
all_platform_accounts = {} # Store all accounts for round-robin
|
| 252 |
-
upload_limit = get_upload_limit()
|
| 253 |
-
platform_upload_counts = {} # Track uploads per platform (int)
|
| 254 |
-
|
| 255 |
-
for platform in PLATFORMS:
|
| 256 |
-
accounts = get_connected_accounts(platform)
|
| 257 |
-
if accounts:
|
| 258 |
-
all_platform_accounts[platform] = accounts
|
| 259 |
-
# Select next account using round-robin
|
| 260 |
-
next_account = get_next_account_round_robin(platform, accounts)
|
| 261 |
-
platform_accounts[platform] = next_account
|
| 262 |
-
platform_upload_counts[platform] = 0
|
| 263 |
-
limit_str = f"limit: {upload_limit}" if upload_limit is not None else "no limit"
|
| 264 |
-
logger.info(f"β {platform}: {next_account.get('name', 'connected')} ({limit_str})")
|
| 265 |
-
else:
|
| 266 |
-
logger.warning(f"β οΈ No connected account for {platform}")
|
| 267 |
-
|
| 268 |
-
if not platform_accounts:
|
| 269 |
-
logger.error("β No connected accounts found for any platform")
|
| 270 |
-
return
|
| 271 |
-
|
| 272 |
-
# Process each unpublished video
|
| 273 |
success_count = 0
|
| 274 |
|
| 275 |
for idx, gcs_filename in enumerate(unpublished):
|
|
@@ -277,120 +163,80 @@ def run_publisher():
|
|
| 277 |
logger.info(f"πΊ [{idx+1}/{len(unpublished)}] Processing: {gcs_filename}")
|
| 278 |
logger.info(f"{'='*50}")
|
| 279 |
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
# Publish to each platform
|
| 293 |
-
video_success = False
|
| 294 |
-
for platform, account_info in list(platform_accounts.items()):
|
| 295 |
-
# Check upload limit
|
| 296 |
-
if upload_limit is not None and platform_upload_counts.get(platform, 0) >= upload_limit:
|
| 297 |
-
logger.info(f"βοΈ {platform}: Upload limit reached ({upload_limit}), skipping")
|
| 298 |
continue
|
| 299 |
|
| 300 |
-
|
|
|
|
|
|
|
| 301 |
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
|
|
|
| 310 |
)
|
| 311 |
|
| 312 |
-
|
| 313 |
-
|
|
|
|
|
|
|
|
|
|
| 314 |
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
| 330 |
success_count += 1
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
try:
|
| 334 |
if os.path.exists(local_path):
|
| 335 |
os.remove(local_path)
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
|
| 342 |
-
|
| 343 |
-
"""Validate required environment variables defined in publisher.env."""
|
| 344 |
-
|
| 345 |
-
# Path to publisher.env (one level up from this file, then in root)
|
| 346 |
-
# publisher.py is in social_media_publishers/
|
| 347 |
-
# publisher.env is in root/
|
| 348 |
-
env_path = os.path.join(os.path.dirname(__file__), '..', 'publisher.env')
|
| 349 |
-
|
| 350 |
-
if not os.path.exists(env_path):
|
| 351 |
-
logger.warning(f"β οΈ publisher.env not found at {env_path}. Skipping dynamic validation.")
|
| 352 |
-
return
|
| 353 |
|
| 354 |
-
missing = []
|
| 355 |
-
|
| 356 |
-
try:
|
| 357 |
-
with open(env_path, 'r') as f:
|
| 358 |
-
for line in f:
|
| 359 |
-
line = line.strip()
|
| 360 |
-
if not line or line.startswith('#'):
|
| 361 |
-
continue
|
| 362 |
-
|
| 363 |
-
# Extract key (before the first =)
|
| 364 |
-
if '=' in line:
|
| 365 |
-
key = line.split('=')[0].strip()
|
| 366 |
-
# Use get_config_value because not all keys are mapped in config.py
|
| 367 |
-
if key and not get_config_value(key):
|
| 368 |
-
missing.append(key)
|
| 369 |
-
|
| 370 |
-
except Exception as e:
|
| 371 |
-
logger.error(f"β Failed to read publisher.env: {e}")
|
| 372 |
-
sys.exit(1)
|
| 373 |
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
|
| 380 |
|
| 381 |
if __name__ == "__main__":
|
| 382 |
pre_check()
|
| 383 |
-
|
| 384 |
-
# Get limit from env (workflow input)
|
| 385 |
-
limit = get_upload_limit()
|
| 386 |
-
|
| 387 |
-
if limit is not None:
|
| 388 |
-
logger.info(f"π Starting Publisher Workflow (Limit: {limit} per platform)...")
|
| 389 |
-
else:
|
| 390 |
-
logger.info("π Starting Publisher Workflow (No Limit)...")
|
| 391 |
-
|
| 392 |
-
try:
|
| 393 |
-
run_publisher()
|
| 394 |
-
except Exception as e:
|
| 395 |
-
logger.error(f"β Publisher Failed: {e}")
|
| 396 |
-
sys.exit(1)
|
|
|
|
| 1 |
"""
|
| 2 |
publisher.py
|
| 3 |
+
Unified social media publisher using OneUp API.
|
| 4 |
+
Lists videos from GCS, generates public URLs, and publishes via OneUp.
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
import sys
|
|
|
|
| 17 |
from src.google_src.gcs_utils import list_gcs_files, find_and_download_gcs_file, upload_file_to_gcs
|
| 18 |
from src.google_src.google_sheet import GoogleSheetReader
|
| 19 |
from src.utils import logger
|
| 20 |
+
from social_media_publishers.oneup_client import OneUpClient
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
# GSheet columns for publisher logs
|
| 23 |
LOG_HEADER = ["Timestamp", "Platform", "Published File", "Published URL", "Account", "Status", "Error"]
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def get_publisher_logs_worksheet() -> GoogleSheetReader:
|
| 27 |
"""Get GoogleSheetReader for publisher logs worksheet."""
|
| 28 |
worksheet_name = get_config_value("publisher_logs_worksheet")
|
|
|
|
| 50 |
|
| 51 |
def log_publish_result(
|
| 52 |
gcs_filename: str,
|
|
|
|
| 53 |
result: Dict[str, Any],
|
| 54 |
+
account_info: str = "OneUp"
|
| 55 |
):
|
| 56 |
"""Log publish result to Google Sheets."""
|
| 57 |
try:
|
| 58 |
reader = get_publisher_logs_worksheet()
|
| 59 |
|
| 60 |
+
# OneUp returns error inside the dict sometimes, checking success key if exists or manual check
|
| 61 |
+
# OneUp response: {"message": "...", "error": false, ...}
|
| 62 |
+
|
| 63 |
+
is_error = result.get("error", False)
|
| 64 |
+
status = "success" if not is_error else "error"
|
| 65 |
+
error_msg = result.get("message") if is_error else ""
|
| 66 |
+
|
| 67 |
+
# Determine published URL (OneUp doesn't return the post URL immediately always,
|
| 68 |
+
# so we log the message or generic success)
|
| 69 |
+
published_url = "Scheduled/Published via OneUp"
|
| 70 |
|
| 71 |
reader.create_or_update_sheet(
|
| 72 |
header=LOG_HEADER,
|
| 73 |
values=[{
|
| 74 |
"Timestamp": datetime.now().isoformat(),
|
| 75 |
+
"Platform": "OneUp (All)",
|
| 76 |
"Published File": gcs_filename,
|
| 77 |
"Published URL": published_url,
|
| 78 |
+
"Account": account_info,
|
| 79 |
"Status": status,
|
| 80 |
"Error": error_msg
|
| 81 |
}]
|
| 82 |
)
|
| 83 |
+
logger.info(f"β Logged result to GSheet")
|
| 84 |
except Exception as e:
|
| 85 |
logger.error(f"β Failed to log to GSheet: {e}")
|
| 86 |
|
| 87 |
|
| 88 |
+
def run_publisher():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
"""
|
| 90 |
+
Main publisher workflow using OneUp:
|
| 91 |
+
1. Initialize OneUp Client.
|
| 92 |
+
2. Get first category and target accounts ["ALL"].
|
| 93 |
+
3. List videos from GCS.
|
| 94 |
+
4. Check which are already published.
|
| 95 |
+
5. For each unpublished video:
|
| 96 |
+
- Download (to verify/process if needed, or simply get GCS public link).
|
| 97 |
+
- Ensure public URL exists (upload to GCS public bucket/path).
|
| 98 |
+
- Publish via OneUp (publish_now=True).
|
| 99 |
+
- Log results.
|
| 100 |
"""
|
| 101 |
+
logger.info("π Starting OneUp Publisher Integration...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
+
# Initialize Client
|
| 104 |
try:
|
| 105 |
+
client = OneUpClient()
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.error(f"β Failed to initialize OneUpClient: {e}")
|
| 108 |
+
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
+
# 1. Get Category (Default: First one)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
try:
|
| 112 |
+
categories = client.get_categories()
|
| 113 |
+
if not categories:
|
| 114 |
+
logger.error("β No categories found in OneUp account.")
|
| 115 |
+
return
|
| 116 |
|
| 117 |
+
target_category = categories[0]
|
| 118 |
+
category_id = target_category['id']
|
| 119 |
+
category_name = target_category['category_name']
|
| 120 |
+
logger.info(f"β
Selected Category: {category_name} (ID: {category_id})")
|
| 121 |
|
| 122 |
+
except Exception as e:
|
| 123 |
+
logger.error(f"β Failed to fetch categories: {e}")
|
| 124 |
+
return
|
| 125 |
+
|
| 126 |
+
# 2. Target Accounts
|
| 127 |
+
# Strategy: "all the accounts in the category" -> pass ["ALL"]
|
| 128 |
+
# We can fetch accounts just to log them for visibility
|
| 129 |
+
try:
|
| 130 |
+
accounts = client.get_accounts_in_category(category_id)
|
| 131 |
+
account_names = [f"{a.get('social_network_name')} ({a.get('social_network_type')})" for a in accounts]
|
| 132 |
+
logger.info(f"π₯ Target Accounts in Category ({len(accounts)}): {', '.join(account_names)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
+
if not accounts:
|
| 135 |
+
logger.warning("β οΈ No accounts found in this category. Publishing might fail.")
|
| 136 |
+
|
| 137 |
except Exception as e:
|
| 138 |
+
logger.warning(f"β οΈ Could not list accounts (proceeding with 'ALL'): {e}")
|
|
|
|
| 139 |
|
| 140 |
+
target_social_network_ids = ["ALL"] # As per requirement/spec
|
| 141 |
|
| 142 |
+
# 3. Get / Filter Videos
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
gcs_files = list_gcs_files()
|
| 144 |
logger.info(f"π Found {len(gcs_files)} videos in GCS")
|
| 145 |
|
|
|
|
| 147 |
logger.info("β
No videos to publish")
|
| 148 |
return
|
| 149 |
|
|
|
|
| 150 |
published = load_published_files()
|
|
|
|
|
|
|
| 151 |
unpublished = [f for f in gcs_files if f not in published]
|
| 152 |
logger.info(f"π {len(unpublished)} unpublished videos to process")
|
| 153 |
|
| 154 |
if not unpublished:
|
| 155 |
logger.info("β
All videos already published")
|
| 156 |
return
|
| 157 |
+
|
| 158 |
+
# 4. Publish Workflow
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
success_count = 0
|
| 160 |
|
| 161 |
for idx, gcs_filename in enumerate(unpublished):
|
|
|
|
| 163 |
logger.info(f"πΊ [{idx+1}/{len(unpublished)}] Processing: {gcs_filename}")
|
| 164 |
logger.info(f"{'='*50}")
|
| 165 |
|
| 166 |
+
try:
|
| 167 |
+
# We need a PUBLIC URL for OneUp.
|
| 168 |
+
# Existing GCS files might be private.
|
| 169 |
+
# We will download it and re-upload to a public path (or generate a signed URL if OneUp accepts it).
|
| 170 |
+
# OneUp spec says: "video_url must be publicly accessible".
|
| 171 |
+
# Signed URLs work but are temporary. Public bucket is better.
|
| 172 |
+
|
| 173 |
+
# Download first to a local temp path
|
| 174 |
+
local_path = find_and_download_gcs_file(gcs_filename)
|
| 175 |
+
if not local_path or not os.path.exists(local_path):
|
| 176 |
+
logger.error(f"β Failed to download: {gcs_filename}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
continue
|
| 178 |
|
| 179 |
+
# Generate Caption
|
| 180 |
+
basename = os.path.basename(gcs_filename)
|
| 181 |
+
caption = os.path.splitext(basename)[0].replace("_", " ")[:200]
|
| 182 |
|
| 183 |
+
# Re-upload to GCS (Public)
|
| 184 |
+
# We use a specific public bucket if configured, or same bucket but rely on public URL construction
|
| 185 |
+
# reusing existing logic for Instagram
|
| 186 |
+
logger.info("βοΈ Uploading valid public link for OneUp...")
|
| 187 |
|
| 188 |
+
uploaded = upload_file_to_gcs(
|
| 189 |
+
local_path,
|
| 190 |
+
account_name="final_data",
|
| 191 |
+
bucket_name=get_config_value("GCS_BUCKET_NAME_PUBLIC_FOR_SOCIAL_PUBLISH") or get_config_value("GCS_BUCKET_NAME"),
|
| 192 |
+
generate_signed_url=True, # User requested signed URL preference
|
| 193 |
+
fallback_to_drive=False,
|
| 194 |
+
save_in_drive_also=False
|
| 195 |
)
|
| 196 |
|
| 197 |
+
video_url = uploaded.get("signed_url")
|
| 198 |
+
if not video_url:
|
| 199 |
+
# If public_url is empty, maybe try 'url' (signed)
|
| 200 |
+
video_url = uploaded.get("url")
|
| 201 |
+
logger.warning("β οΈ Using signed URL (might expire). Ensure bucket is public for best results.")
|
| 202 |
|
| 203 |
+
logger.info(f"π Video URL: {video_url}")
|
| 204 |
+
|
| 205 |
+
# Publish
|
| 206 |
+
logger.info("π€ Sending to OneUp...")
|
| 207 |
+
result = client.schedule_video_post(
|
| 208 |
+
category_id=category_id,
|
| 209 |
+
social_network_ids=target_social_network_ids,
|
| 210 |
+
video_url=video_url,
|
| 211 |
+
title=caption,
|
| 212 |
+
content=caption,
|
| 213 |
+
publish_now=True
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
logger.info(f"β
OneUp Response: {result.get('message')}")
|
| 217 |
+
|
| 218 |
+
# Log success
|
| 219 |
+
log_publish_result(gcs_filename, result, f"Category: {category_name}")
|
| 220 |
success_count += 1
|
| 221 |
+
|
| 222 |
+
# Cleanup
|
|
|
|
| 223 |
if os.path.exists(local_path):
|
| 224 |
os.remove(local_path)
|
| 225 |
+
|
| 226 |
+
except Exception as e:
|
| 227 |
+
logger.error(f"β Failed to publish {gcs_filename}: {e}")
|
| 228 |
+
log_publish_result(gcs_filename, {"error": True, "message": str(e)}, "Error")
|
|
|
|
| 229 |
|
| 230 |
+
logger.info(f"\nπ Publishing complete. {success_count}/{len(unpublished)} videos processed.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
|
| 233 |
+
def pre_check():
|
| 234 |
+
"""Environment check."""
|
| 235 |
+
if not get_config_value("ONEUP_API_KEY"):
|
| 236 |
+
logger.error("β ONEUP_API_KEY is missing in configuration.")
|
| 237 |
+
sys.exit(1)
|
| 238 |
|
| 239 |
|
| 240 |
if __name__ == "__main__":
|
| 241 |
pre_check()
|
| 242 |
+
run_publisher()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/google_src/gcs_utils.py
CHANGED
|
@@ -176,6 +176,7 @@ def upload_file_to_gcs(
|
|
| 176 |
)
|
| 177 |
logger.info(f"β
Signed URL generated")
|
| 178 |
result["url"] = signed_url
|
|
|
|
| 179 |
except Exception as e:
|
| 180 |
logger.warning(f"β οΈ Failed to generate signed URL (using public URL): {e}")
|
| 181 |
|
|
|
|
| 176 |
)
|
| 177 |
logger.info(f"β
Signed URL generated")
|
| 178 |
result["url"] = signed_url
|
| 179 |
+
result["signed_url"] = signed_url
|
| 180 |
except Exception as e:
|
| 181 |
logger.warning(f"β οΈ Failed to generate signed URL (using public URL): {e}")
|
| 182 |
|