webscout 8.2.9__py3-none-any.whl → 2026.1.19__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (413) hide show
  1. webscout/AIauto.py +524 -251
  2. webscout/AIbase.py +247 -319
  3. webscout/AIutel.py +68 -703
  4. webscout/Bard.py +1072 -1026
  5. webscout/Extra/GitToolkit/__init__.py +10 -10
  6. webscout/Extra/GitToolkit/gitapi/__init__.py +20 -12
  7. webscout/Extra/GitToolkit/gitapi/gist.py +142 -0
  8. webscout/Extra/GitToolkit/gitapi/organization.py +91 -0
  9. webscout/Extra/GitToolkit/gitapi/repository.py +308 -195
  10. webscout/Extra/GitToolkit/gitapi/search.py +162 -0
  11. webscout/Extra/GitToolkit/gitapi/trending.py +236 -0
  12. webscout/Extra/GitToolkit/gitapi/user.py +128 -96
  13. webscout/Extra/GitToolkit/gitapi/utils.py +82 -62
  14. webscout/Extra/YTToolkit/README.md +443 -375
  15. webscout/Extra/YTToolkit/YTdownloader.py +953 -957
  16. webscout/Extra/YTToolkit/__init__.py +3 -3
  17. webscout/Extra/YTToolkit/transcriber.py +595 -476
  18. webscout/Extra/YTToolkit/ytapi/README.md +230 -44
  19. webscout/Extra/YTToolkit/ytapi/__init__.py +22 -6
  20. webscout/Extra/YTToolkit/ytapi/captions.py +190 -0
  21. webscout/Extra/YTToolkit/ytapi/channel.py +302 -307
  22. webscout/Extra/YTToolkit/ytapi/errors.py +13 -13
  23. webscout/Extra/YTToolkit/ytapi/extras.py +178 -118
  24. webscout/Extra/YTToolkit/ytapi/hashtag.py +120 -0
  25. webscout/Extra/YTToolkit/ytapi/https.py +89 -88
  26. webscout/Extra/YTToolkit/ytapi/patterns.py +61 -61
  27. webscout/Extra/YTToolkit/ytapi/playlist.py +59 -59
  28. webscout/Extra/YTToolkit/ytapi/pool.py +8 -8
  29. webscout/Extra/YTToolkit/ytapi/query.py +143 -40
  30. webscout/Extra/YTToolkit/ytapi/shorts.py +122 -0
  31. webscout/Extra/YTToolkit/ytapi/stream.py +68 -63
  32. webscout/Extra/YTToolkit/ytapi/suggestions.py +97 -0
  33. webscout/Extra/YTToolkit/ytapi/utils.py +66 -62
  34. webscout/Extra/YTToolkit/ytapi/video.py +403 -232
  35. webscout/Extra/__init__.py +2 -3
  36. webscout/Extra/gguf.py +1298 -684
  37. webscout/Extra/tempmail/README.md +487 -487
  38. webscout/Extra/tempmail/__init__.py +28 -28
  39. webscout/Extra/tempmail/async_utils.py +143 -141
  40. webscout/Extra/tempmail/base.py +172 -161
  41. webscout/Extra/tempmail/cli.py +191 -187
  42. webscout/Extra/tempmail/emailnator.py +88 -84
  43. webscout/Extra/tempmail/mail_tm.py +378 -361
  44. webscout/Extra/tempmail/temp_mail_io.py +304 -292
  45. webscout/Extra/weather.py +196 -194
  46. webscout/Extra/weather_ascii.py +17 -15
  47. webscout/Provider/AISEARCH/PERPLEXED_search.py +175 -0
  48. webscout/Provider/AISEARCH/Perplexity.py +292 -333
  49. webscout/Provider/AISEARCH/README.md +106 -279
  50. webscout/Provider/AISEARCH/__init__.py +16 -9
  51. webscout/Provider/AISEARCH/brave_search.py +298 -0
  52. webscout/Provider/AISEARCH/iask_search.py +357 -410
  53. webscout/Provider/AISEARCH/monica_search.py +200 -220
  54. webscout/Provider/AISEARCH/webpilotai_search.py +242 -255
  55. webscout/Provider/Algion.py +413 -0
  56. webscout/Provider/Andi.py +74 -69
  57. webscout/Provider/Apriel.py +313 -0
  58. webscout/Provider/Ayle.py +323 -0
  59. webscout/Provider/ChatSandbox.py +329 -342
  60. webscout/Provider/ClaudeOnline.py +365 -0
  61. webscout/Provider/Cohere.py +232 -208
  62. webscout/Provider/DeepAI.py +367 -0
  63. webscout/Provider/Deepinfra.py +467 -340
  64. webscout/Provider/EssentialAI.py +217 -0
  65. webscout/Provider/ExaAI.py +274 -261
  66. webscout/Provider/Gemini.py +175 -169
  67. webscout/Provider/GithubChat.py +385 -369
  68. webscout/Provider/Gradient.py +286 -0
  69. webscout/Provider/Groq.py +556 -801
  70. webscout/Provider/HadadXYZ.py +323 -0
  71. webscout/Provider/HeckAI.py +392 -375
  72. webscout/Provider/HuggingFace.py +387 -0
  73. webscout/Provider/IBM.py +340 -0
  74. webscout/Provider/Jadve.py +317 -291
  75. webscout/Provider/K2Think.py +306 -0
  76. webscout/Provider/Koboldai.py +221 -384
  77. webscout/Provider/Netwrck.py +273 -270
  78. webscout/Provider/Nvidia.py +310 -0
  79. webscout/Provider/OPENAI/DeepAI.py +489 -0
  80. webscout/Provider/OPENAI/K2Think.py +423 -0
  81. webscout/Provider/OPENAI/PI.py +463 -0
  82. webscout/Provider/OPENAI/README.md +890 -952
  83. webscout/Provider/OPENAI/TogetherAI.py +405 -0
  84. webscout/Provider/OPENAI/TwoAI.py +255 -357
  85. webscout/Provider/OPENAI/__init__.py +148 -40
  86. webscout/Provider/OPENAI/ai4chat.py +348 -293
  87. webscout/Provider/OPENAI/akashgpt.py +436 -0
  88. webscout/Provider/OPENAI/algion.py +303 -0
  89. webscout/Provider/OPENAI/{exachat.py → ayle.py} +365 -444
  90. webscout/Provider/OPENAI/base.py +253 -249
  91. webscout/Provider/OPENAI/cerebras.py +296 -0
  92. webscout/Provider/OPENAI/chatgpt.py +870 -556
  93. webscout/Provider/OPENAI/chatsandbox.py +233 -173
  94. webscout/Provider/OPENAI/deepinfra.py +403 -322
  95. webscout/Provider/OPENAI/e2b.py +2370 -1414
  96. webscout/Provider/OPENAI/elmo.py +278 -0
  97. webscout/Provider/OPENAI/exaai.py +452 -417
  98. webscout/Provider/OPENAI/freeassist.py +446 -0
  99. webscout/Provider/OPENAI/gradient.py +448 -0
  100. webscout/Provider/OPENAI/groq.py +380 -364
  101. webscout/Provider/OPENAI/hadadxyz.py +292 -0
  102. webscout/Provider/OPENAI/heckai.py +333 -308
  103. webscout/Provider/OPENAI/huggingface.py +321 -0
  104. webscout/Provider/OPENAI/ibm.py +425 -0
  105. webscout/Provider/OPENAI/llmchat.py +253 -0
  106. webscout/Provider/OPENAI/llmchatco.py +378 -335
  107. webscout/Provider/OPENAI/meta.py +541 -0
  108. webscout/Provider/OPENAI/netwrck.py +374 -357
  109. webscout/Provider/OPENAI/nvidia.py +317 -0
  110. webscout/Provider/OPENAI/oivscode.py +348 -287
  111. webscout/Provider/OPENAI/openrouter.py +328 -0
  112. webscout/Provider/OPENAI/pydantic_imports.py +1 -172
  113. webscout/Provider/OPENAI/sambanova.py +397 -0
  114. webscout/Provider/OPENAI/sonus.py +305 -304
  115. webscout/Provider/OPENAI/textpollinations.py +370 -339
  116. webscout/Provider/OPENAI/toolbaz.py +375 -413
  117. webscout/Provider/OPENAI/typefully.py +419 -355
  118. webscout/Provider/OPENAI/typliai.py +279 -0
  119. webscout/Provider/OPENAI/utils.py +314 -318
  120. webscout/Provider/OPENAI/wisecat.py +359 -387
  121. webscout/Provider/OPENAI/writecream.py +185 -163
  122. webscout/Provider/OPENAI/x0gpt.py +462 -365
  123. webscout/Provider/OPENAI/zenmux.py +380 -0
  124. webscout/Provider/OpenRouter.py +386 -0
  125. webscout/Provider/Openai.py +337 -496
  126. webscout/Provider/PI.py +443 -429
  127. webscout/Provider/QwenLM.py +346 -254
  128. webscout/Provider/STT/__init__.py +28 -0
  129. webscout/Provider/STT/base.py +303 -0
  130. webscout/Provider/STT/elevenlabs.py +264 -0
  131. webscout/Provider/Sambanova.py +317 -0
  132. webscout/Provider/TTI/README.md +69 -82
  133. webscout/Provider/TTI/__init__.py +37 -7
  134. webscout/Provider/TTI/base.py +147 -64
  135. webscout/Provider/TTI/claudeonline.py +393 -0
  136. webscout/Provider/TTI/magicstudio.py +292 -201
  137. webscout/Provider/TTI/miragic.py +180 -0
  138. webscout/Provider/TTI/pollinations.py +331 -221
  139. webscout/Provider/TTI/together.py +334 -0
  140. webscout/Provider/TTI/utils.py +14 -11
  141. webscout/Provider/TTS/README.md +186 -192
  142. webscout/Provider/TTS/__init__.py +43 -10
  143. webscout/Provider/TTS/base.py +523 -159
  144. webscout/Provider/TTS/deepgram.py +286 -156
  145. webscout/Provider/TTS/elevenlabs.py +189 -111
  146. webscout/Provider/TTS/freetts.py +218 -0
  147. webscout/Provider/TTS/murfai.py +288 -113
  148. webscout/Provider/TTS/openai_fm.py +364 -129
  149. webscout/Provider/TTS/parler.py +203 -111
  150. webscout/Provider/TTS/qwen.py +334 -0
  151. webscout/Provider/TTS/sherpa.py +286 -0
  152. webscout/Provider/TTS/speechma.py +693 -580
  153. webscout/Provider/TTS/streamElements.py +275 -333
  154. webscout/Provider/TTS/utils.py +280 -280
  155. webscout/Provider/TextPollinationsAI.py +331 -308
  156. webscout/Provider/TogetherAI.py +450 -0
  157. webscout/Provider/TwoAI.py +309 -475
  158. webscout/Provider/TypliAI.py +311 -305
  159. webscout/Provider/UNFINISHED/ChatHub.py +219 -209
  160. webscout/Provider/{OPENAI/glider.py → UNFINISHED/ChutesAI.py} +331 -326
  161. webscout/Provider/{GizAI.py → UNFINISHED/GizAI.py} +300 -295
  162. webscout/Provider/{Marcus.py → UNFINISHED/Marcus.py} +218 -198
  163. webscout/Provider/UNFINISHED/Qodo.py +481 -0
  164. webscout/Provider/{MCPCore.py → UNFINISHED/XenAI.py} +330 -315
  165. webscout/Provider/UNFINISHED/Youchat.py +347 -330
  166. webscout/Provider/UNFINISHED/aihumanizer.py +41 -0
  167. webscout/Provider/UNFINISHED/grammerchecker.py +37 -0
  168. webscout/Provider/UNFINISHED/liner.py +342 -0
  169. webscout/Provider/UNFINISHED/liner_api_request.py +246 -263
  170. webscout/Provider/{samurai.py → UNFINISHED/samurai.py} +231 -224
  171. webscout/Provider/WiseCat.py +256 -233
  172. webscout/Provider/WrDoChat.py +390 -370
  173. webscout/Provider/__init__.py +115 -174
  174. webscout/Provider/ai4chat.py +181 -174
  175. webscout/Provider/akashgpt.py +330 -335
  176. webscout/Provider/cerebras.py +397 -290
  177. webscout/Provider/cleeai.py +236 -213
  178. webscout/Provider/elmo.py +291 -283
  179. webscout/Provider/geminiapi.py +343 -208
  180. webscout/Provider/julius.py +245 -223
  181. webscout/Provider/learnfastai.py +333 -325
  182. webscout/Provider/llama3mitril.py +230 -215
  183. webscout/Provider/llmchat.py +308 -258
  184. webscout/Provider/llmchatco.py +321 -306
  185. webscout/Provider/meta.py +996 -801
  186. webscout/Provider/oivscode.py +332 -309
  187. webscout/Provider/searchchat.py +316 -292
  188. webscout/Provider/sonus.py +264 -258
  189. webscout/Provider/toolbaz.py +359 -353
  190. webscout/Provider/turboseek.py +332 -266
  191. webscout/Provider/typefully.py +262 -202
  192. webscout/Provider/x0gpt.py +332 -299
  193. webscout/__init__.py +31 -39
  194. webscout/__main__.py +5 -5
  195. webscout/cli.py +585 -524
  196. webscout/client.py +1497 -70
  197. webscout/conversation.py +140 -436
  198. webscout/exceptions.py +383 -362
  199. webscout/litagent/__init__.py +29 -29
  200. webscout/litagent/agent.py +492 -455
  201. webscout/litagent/constants.py +60 -60
  202. webscout/models.py +505 -181
  203. webscout/optimizers.py +74 -420
  204. webscout/prompt_manager.py +376 -288
  205. webscout/sanitize.py +1514 -0
  206. webscout/scout/README.md +452 -404
  207. webscout/scout/__init__.py +8 -8
  208. webscout/scout/core/__init__.py +7 -7
  209. webscout/scout/core/crawler.py +330 -210
  210. webscout/scout/core/scout.py +800 -607
  211. webscout/scout/core/search_result.py +51 -96
  212. webscout/scout/core/text_analyzer.py +64 -63
  213. webscout/scout/core/text_utils.py +412 -277
  214. webscout/scout/core/web_analyzer.py +54 -52
  215. webscout/scout/element.py +872 -478
  216. webscout/scout/parsers/__init__.py +70 -69
  217. webscout/scout/parsers/html5lib_parser.py +182 -172
  218. webscout/scout/parsers/html_parser.py +238 -236
  219. webscout/scout/parsers/lxml_parser.py +203 -178
  220. webscout/scout/utils.py +38 -37
  221. webscout/search/__init__.py +47 -0
  222. webscout/search/base.py +201 -0
  223. webscout/search/bing_main.py +45 -0
  224. webscout/search/brave_main.py +92 -0
  225. webscout/search/duckduckgo_main.py +57 -0
  226. webscout/search/engines/__init__.py +127 -0
  227. webscout/search/engines/bing/__init__.py +15 -0
  228. webscout/search/engines/bing/base.py +35 -0
  229. webscout/search/engines/bing/images.py +114 -0
  230. webscout/search/engines/bing/news.py +96 -0
  231. webscout/search/engines/bing/suggestions.py +36 -0
  232. webscout/search/engines/bing/text.py +109 -0
  233. webscout/search/engines/brave/__init__.py +19 -0
  234. webscout/search/engines/brave/base.py +47 -0
  235. webscout/search/engines/brave/images.py +213 -0
  236. webscout/search/engines/brave/news.py +353 -0
  237. webscout/search/engines/brave/suggestions.py +318 -0
  238. webscout/search/engines/brave/text.py +167 -0
  239. webscout/search/engines/brave/videos.py +364 -0
  240. webscout/search/engines/duckduckgo/__init__.py +25 -0
  241. webscout/search/engines/duckduckgo/answers.py +80 -0
  242. webscout/search/engines/duckduckgo/base.py +189 -0
  243. webscout/search/engines/duckduckgo/images.py +100 -0
  244. webscout/search/engines/duckduckgo/maps.py +183 -0
  245. webscout/search/engines/duckduckgo/news.py +70 -0
  246. webscout/search/engines/duckduckgo/suggestions.py +22 -0
  247. webscout/search/engines/duckduckgo/text.py +221 -0
  248. webscout/search/engines/duckduckgo/translate.py +48 -0
  249. webscout/search/engines/duckduckgo/videos.py +80 -0
  250. webscout/search/engines/duckduckgo/weather.py +84 -0
  251. webscout/search/engines/mojeek.py +61 -0
  252. webscout/search/engines/wikipedia.py +77 -0
  253. webscout/search/engines/yahoo/__init__.py +41 -0
  254. webscout/search/engines/yahoo/answers.py +19 -0
  255. webscout/search/engines/yahoo/base.py +34 -0
  256. webscout/search/engines/yahoo/images.py +323 -0
  257. webscout/search/engines/yahoo/maps.py +19 -0
  258. webscout/search/engines/yahoo/news.py +258 -0
  259. webscout/search/engines/yahoo/suggestions.py +140 -0
  260. webscout/search/engines/yahoo/text.py +273 -0
  261. webscout/search/engines/yahoo/translate.py +19 -0
  262. webscout/search/engines/yahoo/videos.py +302 -0
  263. webscout/search/engines/yahoo/weather.py +220 -0
  264. webscout/search/engines/yandex.py +67 -0
  265. webscout/search/engines/yep/__init__.py +13 -0
  266. webscout/search/engines/yep/base.py +34 -0
  267. webscout/search/engines/yep/images.py +101 -0
  268. webscout/search/engines/yep/suggestions.py +38 -0
  269. webscout/search/engines/yep/text.py +99 -0
  270. webscout/search/http_client.py +172 -0
  271. webscout/search/results.py +141 -0
  272. webscout/search/yahoo_main.py +57 -0
  273. webscout/search/yep_main.py +48 -0
  274. webscout/server/__init__.py +48 -0
  275. webscout/server/config.py +78 -0
  276. webscout/server/exceptions.py +69 -0
  277. webscout/server/providers.py +286 -0
  278. webscout/server/request_models.py +131 -0
  279. webscout/server/request_processing.py +404 -0
  280. webscout/server/routes.py +642 -0
  281. webscout/server/server.py +351 -0
  282. webscout/server/ui_templates.py +1171 -0
  283. webscout/swiftcli/__init__.py +79 -95
  284. webscout/swiftcli/core/__init__.py +7 -7
  285. webscout/swiftcli/core/cli.py +574 -297
  286. webscout/swiftcli/core/context.py +98 -104
  287. webscout/swiftcli/core/group.py +268 -241
  288. webscout/swiftcli/decorators/__init__.py +28 -28
  289. webscout/swiftcli/decorators/command.py +243 -221
  290. webscout/swiftcli/decorators/options.py +247 -220
  291. webscout/swiftcli/decorators/output.py +392 -252
  292. webscout/swiftcli/exceptions.py +21 -21
  293. webscout/swiftcli/plugins/__init__.py +9 -9
  294. webscout/swiftcli/plugins/base.py +134 -135
  295. webscout/swiftcli/plugins/manager.py +269 -269
  296. webscout/swiftcli/utils/__init__.py +58 -59
  297. webscout/swiftcli/utils/formatting.py +251 -252
  298. webscout/swiftcli/utils/parsing.py +368 -267
  299. webscout/update_checker.py +280 -136
  300. webscout/utils.py +28 -14
  301. webscout/version.py +2 -1
  302. webscout/version.py.bak +3 -0
  303. webscout/zeroart/__init__.py +218 -135
  304. webscout/zeroart/base.py +70 -66
  305. webscout/zeroart/effects.py +155 -101
  306. webscout/zeroart/fonts.py +1799 -1239
  307. webscout-2026.1.19.dist-info/METADATA +638 -0
  308. webscout-2026.1.19.dist-info/RECORD +312 -0
  309. {webscout-8.2.9.dist-info → webscout-2026.1.19.dist-info}/WHEEL +1 -1
  310. {webscout-8.2.9.dist-info → webscout-2026.1.19.dist-info}/entry_points.txt +1 -1
  311. webscout/DWEBS.py +0 -520
  312. webscout/Extra/Act.md +0 -309
  313. webscout/Extra/GitToolkit/gitapi/README.md +0 -110
  314. webscout/Extra/autocoder/__init__.py +0 -9
  315. webscout/Extra/autocoder/autocoder.py +0 -1105
  316. webscout/Extra/autocoder/autocoder_utiles.py +0 -332
  317. webscout/Extra/gguf.md +0 -430
  318. webscout/Extra/weather.md +0 -281
  319. webscout/Litlogger/README.md +0 -10
  320. webscout/Litlogger/__init__.py +0 -15
  321. webscout/Litlogger/formats.py +0 -4
  322. webscout/Litlogger/handlers.py +0 -103
  323. webscout/Litlogger/levels.py +0 -13
  324. webscout/Litlogger/logger.py +0 -92
  325. webscout/Provider/AI21.py +0 -177
  326. webscout/Provider/AISEARCH/DeepFind.py +0 -254
  327. webscout/Provider/AISEARCH/felo_search.py +0 -202
  328. webscout/Provider/AISEARCH/genspark_search.py +0 -324
  329. webscout/Provider/AISEARCH/hika_search.py +0 -186
  330. webscout/Provider/AISEARCH/scira_search.py +0 -298
  331. webscout/Provider/Aitopia.py +0 -316
  332. webscout/Provider/AllenAI.py +0 -440
  333. webscout/Provider/Blackboxai.py +0 -791
  334. webscout/Provider/ChatGPTClone.py +0 -237
  335. webscout/Provider/ChatGPTGratis.py +0 -194
  336. webscout/Provider/Cloudflare.py +0 -324
  337. webscout/Provider/ExaChat.py +0 -358
  338. webscout/Provider/Flowith.py +0 -217
  339. webscout/Provider/FreeGemini.py +0 -250
  340. webscout/Provider/Glider.py +0 -225
  341. webscout/Provider/HF_space/__init__.py +0 -0
  342. webscout/Provider/HF_space/qwen_qwen2.py +0 -206
  343. webscout/Provider/HuggingFaceChat.py +0 -469
  344. webscout/Provider/Hunyuan.py +0 -283
  345. webscout/Provider/LambdaChat.py +0 -411
  346. webscout/Provider/Llama3.py +0 -259
  347. webscout/Provider/Nemotron.py +0 -218
  348. webscout/Provider/OLLAMA.py +0 -396
  349. webscout/Provider/OPENAI/BLACKBOXAI.py +0 -766
  350. webscout/Provider/OPENAI/Cloudflare.py +0 -378
  351. webscout/Provider/OPENAI/FreeGemini.py +0 -283
  352. webscout/Provider/OPENAI/NEMOTRON.py +0 -232
  353. webscout/Provider/OPENAI/Qwen3.py +0 -283
  354. webscout/Provider/OPENAI/api.py +0 -969
  355. webscout/Provider/OPENAI/c4ai.py +0 -373
  356. webscout/Provider/OPENAI/chatgptclone.py +0 -494
  357. webscout/Provider/OPENAI/copilot.py +0 -242
  358. webscout/Provider/OPENAI/flowith.py +0 -162
  359. webscout/Provider/OPENAI/freeaichat.py +0 -359
  360. webscout/Provider/OPENAI/mcpcore.py +0 -389
  361. webscout/Provider/OPENAI/multichat.py +0 -376
  362. webscout/Provider/OPENAI/opkfc.py +0 -496
  363. webscout/Provider/OPENAI/scirachat.py +0 -477
  364. webscout/Provider/OPENAI/standardinput.py +0 -433
  365. webscout/Provider/OPENAI/typegpt.py +0 -364
  366. webscout/Provider/OPENAI/uncovrAI.py +0 -463
  367. webscout/Provider/OPENAI/venice.py +0 -431
  368. webscout/Provider/OPENAI/yep.py +0 -382
  369. webscout/Provider/OpenGPT.py +0 -209
  370. webscout/Provider/Perplexitylabs.py +0 -415
  371. webscout/Provider/Reka.py +0 -214
  372. webscout/Provider/StandardInput.py +0 -290
  373. webscout/Provider/TTI/aiarta.py +0 -365
  374. webscout/Provider/TTI/artbit.py +0 -0
  375. webscout/Provider/TTI/fastflux.py +0 -200
  376. webscout/Provider/TTI/piclumen.py +0 -203
  377. webscout/Provider/TTI/pixelmuse.py +0 -225
  378. webscout/Provider/TTS/gesserit.py +0 -128
  379. webscout/Provider/TTS/sthir.py +0 -94
  380. webscout/Provider/TeachAnything.py +0 -229
  381. webscout/Provider/UNFINISHED/puterjs.py +0 -635
  382. webscout/Provider/UNFINISHED/test_lmarena.py +0 -119
  383. webscout/Provider/Venice.py +0 -258
  384. webscout/Provider/VercelAI.py +0 -253
  385. webscout/Provider/Writecream.py +0 -246
  386. webscout/Provider/WritingMate.py +0 -269
  387. webscout/Provider/asksteve.py +0 -220
  388. webscout/Provider/chatglm.py +0 -215
  389. webscout/Provider/copilot.py +0 -425
  390. webscout/Provider/freeaichat.py +0 -285
  391. webscout/Provider/granite.py +0 -235
  392. webscout/Provider/hermes.py +0 -266
  393. webscout/Provider/koala.py +0 -170
  394. webscout/Provider/lmarena.py +0 -198
  395. webscout/Provider/multichat.py +0 -364
  396. webscout/Provider/scira_chat.py +0 -299
  397. webscout/Provider/scnet.py +0 -243
  398. webscout/Provider/talkai.py +0 -194
  399. webscout/Provider/typegpt.py +0 -289
  400. webscout/Provider/uncovr.py +0 -368
  401. webscout/Provider/yep.py +0 -389
  402. webscout/litagent/Readme.md +0 -276
  403. webscout/litprinter/__init__.py +0 -59
  404. webscout/swiftcli/Readme.md +0 -323
  405. webscout/tempid.py +0 -128
  406. webscout/webscout_search.py +0 -1184
  407. webscout/webscout_search_async.py +0 -654
  408. webscout/yep_search.py +0 -347
  409. webscout/zeroart/README.md +0 -89
  410. webscout-8.2.9.dist-info/METADATA +0 -1033
  411. webscout-8.2.9.dist-info/RECORD +0 -289
  412. {webscout-8.2.9.dist-info → webscout-2026.1.19.dist-info}/licenses/LICENSE.md +0 -0
  413. {webscout-8.2.9.dist-info → webscout-2026.1.19.dist-info}/top_level.txt +0 -0
webscout/Bard.py CHANGED
@@ -1,1026 +1,1072 @@
1
- # -*- coding: utf-8 -*-
2
- #########################################
3
- # Code Modified to use curl_cffi
4
- #########################################
5
- import asyncio
6
- import json
7
- import os
8
- import random
9
- import re
10
- import string
11
- from enum import Enum
12
- from pathlib import Path
13
- from datetime import datetime
14
- from typing import Dict, List, Tuple, Union, Optional
15
-
16
- # Use curl_cffi for requests
17
- from curl_cffi import CurlError
18
- from curl_cffi.requests import AsyncSession
19
- # Import common request exceptions (curl_cffi often wraps these)
20
- from requests.exceptions import RequestException, Timeout, HTTPError
21
-
22
- # For image models using validation. Adjust based on organization internal pydantic.
23
- # Updated import for Pydantic V2
24
- from pydantic import BaseModel, field_validator
25
-
26
- # Rich is retained for logging within image methods.
27
- from rich.console import Console
28
- from rich.markdown import Markdown
29
-
30
- console = Console()
31
-
32
- #########################################
33
- # New Enums and functions for endpoints,
34
- # headers, models, file upload and images.
35
- #########################################
36
-
37
- class Endpoint(Enum):
38
- """
39
- Enum for Google Gemini API endpoints.
40
-
41
- Attributes:
42
- INIT (str): URL for initializing the Gemini session.
43
- GENERATE (str): URL for generating chat responses.
44
- ROTATE_COOKIES (str): URL for rotating authentication cookies.
45
- UPLOAD (str): URL for uploading files/images.
46
- """
47
- INIT = "https://gemini.google.com/app"
48
- GENERATE = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
49
- ROTATE_COOKIES = "https://accounts.google.com/RotateCookies"
50
- UPLOAD = "https://content-push.googleapis.com/upload"
51
-
52
- class Headers(Enum):
53
- """
54
- Enum for HTTP headers used in Gemini API requests.
55
-
56
- Attributes:
57
- GEMINI (dict): Headers for Gemini chat requests.
58
- ROTATE_COOKIES (dict): Headers for rotating cookies.
59
- UPLOAD (dict): Headers for file/image upload.
60
- """
61
- GEMINI = {
62
- "Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
63
- "Host": "gemini.google.com",
64
- "Origin": "https://gemini.google.com",
65
- "Referer": "https://gemini.google.com/",
66
- # User-Agent will be handled by curl_cffi impersonate
67
- # "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
68
- "X-Same-Domain": "1",
69
- }
70
- ROTATE_COOKIES = {
71
- "Content-Type": "application/json",
72
- }
73
- UPLOAD = {"Push-ID": "feeds/mcudyrk2a4khkz"}
74
-
75
- class Model(Enum):
76
- """
77
- Enum for available Gemini model configurations.
78
-
79
- Attributes:
80
- model_name (str): Name of the model.
81
- model_header (dict): Additional headers required for the model.
82
- advanced_only (bool): Whether the model is available only for advanced users.
83
- """
84
- # Updated model definitions based on reference implementation
85
- UNSPECIFIED = ("unspecified", {}, False)
86
- G_2_0_FLASH = (
87
- "gemini-2.0-flash",
88
- {"x-goog-ext-525001261-jspb": '[1,null,null,null,"f299729663a2343f"]'},
89
- False,
90
- )
91
- G_2_0_FLASH_THINKING = (
92
- "gemini-2.0-flash-thinking",
93
- {"x-goog-ext-525001261-jspb": '[null,null,null,null,"7ca48d02d802f20a"]'},
94
- False,
95
- )
96
- G_2_5_FLASH = (
97
- "gemini-2.5-flash",
98
- {"x-goog-ext-525001261-jspb": '[1,null,null,null,"35609594dbe934d8"]'},
99
- False,
100
- )
101
- G_2_5_PRO = (
102
- "gemini-2.5-pro",
103
- {"x-goog-ext-525001261-jspb": '[1,null,null,null,"2525e3954d185b3c"]'},
104
- False,
105
- )
106
- G_2_0_EXP_ADVANCED = (
107
- "gemini-2.0-exp-advanced",
108
- {"x-goog-ext-525001261-jspb": '[null,null,null,null,"b1e46a6037e6aa9f"]'},
109
- True,
110
- )
111
- G_2_5_EXP_ADVANCED = (
112
- "gemini-2.5-exp-advanced",
113
- {"x-goog-ext-525001261-jspb": '[null,null,null,null,"203e6bb81620bcfe"]'},
114
- True,
115
- )
116
-
117
- def __init__(self, name, header, advanced_only):
118
- """
119
- Initialize a Model enum member.
120
-
121
- Args:
122
- name (str): Model name.
123
- header (dict): Model-specific headers.
124
- advanced_only (bool): If True, model is for advanced users only.
125
- """
126
- self.model_name = name
127
- self.model_header = header
128
- self.advanced_only = advanced_only
129
-
130
- @classmethod
131
- def from_name(cls, name: str):
132
- """
133
- Get a Model enum member by its model name.
134
-
135
- Args:
136
- name (str): Name of the model.
137
-
138
- Returns:
139
- Model: Corresponding Model enum member.
140
-
141
- Raises:
142
- ValueError: If the model name is not found.
143
- """
144
- for model in cls:
145
- if model.model_name == name:
146
- return model
147
- raise ValueError(
148
- f"Unknown model name: {name}. Available models: {', '.join([model.model_name for model in cls])}"
149
- )
150
-
151
- async def upload_file(
152
- file: Union[bytes, str, Path],
153
- proxy: Optional[Union[str, Dict[str, str]]] = None,
154
- impersonate: str = "chrome110"
155
- ) -> str:
156
- """
157
- Uploads a file to Google's Gemini server using curl_cffi and returns its identifier.
158
-
159
- Args:
160
- file (bytes | str | Path): File data in bytes or path to the file to be uploaded.
161
- proxy (str | dict, optional): Proxy URL or dictionary for the request.
162
- impersonate (str, optional): Browser profile for curl_cffi to impersonate. Defaults to "chrome110".
163
-
164
- Returns:
165
- str: Identifier of the uploaded file.
166
-
167
- Raises:
168
- HTTPError: If the upload request fails.
169
- RequestException: For other network-related errors.
170
- FileNotFoundError: If the file path does not exist.
171
- """
172
- # Handle file input
173
- if not isinstance(file, bytes):
174
- file_path = Path(file)
175
- if not file_path.is_file():
176
- raise FileNotFoundError(f"File not found at path: {file}")
177
- with open(file_path, "rb") as f:
178
- file_content = f.read()
179
- else:
180
- file_content = file
181
-
182
- # Prepare proxy dictionary for curl_cffi
183
- proxies_dict = None
184
- if isinstance(proxy, str):
185
- proxies_dict = {"http": proxy, "https": proxy} # curl_cffi uses http/https keys
186
- elif isinstance(proxy, dict):
187
- proxies_dict = proxy # Assume it's already in the correct format
188
-
189
- try:
190
- # Use AsyncSession from curl_cffi
191
- async with AsyncSession(
192
- proxies=proxies_dict,
193
- impersonate=impersonate,
194
- headers=Headers.UPLOAD.value # Pass headers directly
195
- # follow_redirects is handled automatically by curl_cffi
196
- ) as client:
197
- response = await client.post(
198
- url=Endpoint.UPLOAD.value,
199
- files={"file": file_content},
200
- )
201
- response.raise_for_status() # Raises HTTPError for bad responses
202
- return response.text
203
- except HTTPError as e:
204
- console.log(f"[red]HTTP error during file upload: {e.response.status_code} {e}[/red]")
205
- raise # Re-raise HTTPError
206
- except (RequestException, CurlError) as e:
207
- console.log(f"[red]Network error during file upload: {e}[/red]")
208
- raise # Re-raise other request errors
209
-
210
- #########################################
211
- # Cookie loading and Chatbot classes
212
- #########################################
213
-
214
- def load_cookies(cookie_path: str) -> Tuple[str, str]:
215
- """
216
- Loads authentication cookies from a JSON file.
217
-
218
- Args:
219
- cookie_path (str): Path to the JSON file containing cookies.
220
-
221
- Returns:
222
- tuple[str, str]: Tuple containing __Secure-1PSID and __Secure-1PSIDTS cookie values.
223
-
224
- Raises:
225
- Exception: If the file is not found, invalid, or required cookies are missing.
226
- """
227
- try:
228
- with open(cookie_path, 'r', encoding='utf-8') as file: # Added encoding
229
- cookies = json.load(file)
230
- # Handle potential variations in cookie names (case-insensitivity)
231
- session_auth1 = next((item['value'] for item in cookies if item['name'].upper() == '__SECURE-1PSID'), None)
232
- session_auth2 = next((item['value'] for item in cookies if item['name'].upper() == '__SECURE-1PSIDTS'), None)
233
-
234
- if not session_auth1 or not session_auth2:
235
- raise StopIteration("Required cookies (__Secure-1PSID or __Secure-1PSIDTS) not found.")
236
-
237
- return session_auth1, session_auth2
238
- except FileNotFoundError:
239
- raise Exception(f"Cookie file not found at path: {cookie_path}")
240
- except json.JSONDecodeError:
241
- raise Exception("Invalid JSON format in the cookie file.")
242
- except StopIteration as e:
243
- raise Exception(f"{e} Check the cookie file format and content.")
244
- except Exception as e: # Catch other potential errors
245
- raise Exception(f"An unexpected error occurred while loading cookies: {e}")
246
-
247
-
248
- class Chatbot:
249
- """
250
- Synchronous wrapper for the AsyncChatbot class.
251
-
252
- This class provides a synchronous interface to interact with Google Gemini,
253
- handling authentication, conversation management, and message sending.
254
-
255
- Attributes:
256
- loop (asyncio.AbstractEventLoop): Event loop for running async tasks.
257
- secure_1psid (str): Authentication cookie.
258
- secure_1psidts (str): Authentication cookie.
259
- async_chatbot (AsyncChatbot): Underlying asynchronous chatbot instance.
260
- """
261
- def __init__(
262
- self,
263
- cookie_path: str,
264
- proxy: Optional[Union[str, Dict[str, str]]] = None, # Allow string or dict proxy
265
- timeout: int = 20,
266
- model: Model = Model.UNSPECIFIED,
267
- impersonate: str = "chrome110" # Added impersonate
268
- ):
269
- # Use asyncio.run() for cleaner async execution in sync context
270
- # Handle potential RuntimeError if an event loop is already running
271
- try:
272
- self.loop = asyncio.get_running_loop()
273
- except RuntimeError:
274
- self.loop = asyncio.new_event_loop()
275
- asyncio.set_event_loop(self.loop)
276
-
277
- self.secure_1psid, self.secure_1psidts = load_cookies(cookie_path)
278
- self.async_chatbot = self.loop.run_until_complete(
279
- AsyncChatbot.create(self.secure_1psid, self.secure_1psidts, proxy, timeout, model, impersonate) # Pass impersonate
280
- )
281
-
282
- def save_conversation(self, file_path: str, conversation_name: str):
283
- return self.loop.run_until_complete(
284
- self.async_chatbot.save_conversation(file_path, conversation_name)
285
- )
286
-
287
- def load_conversations(self, file_path: str) -> List[Dict]:
288
- return self.loop.run_until_complete(
289
- self.async_chatbot.load_conversations(file_path)
290
- )
291
-
292
- def load_conversation(self, file_path: str, conversation_name: str) -> bool:
293
- return self.loop.run_until_complete(
294
- self.async_chatbot.load_conversation(file_path, conversation_name)
295
- )
296
-
297
- def ask(self, message: str, image: Optional[Union[bytes, str, Path]] = None) -> dict: # Added image param
298
- # Pass image to async ask method
299
- return self.loop.run_until_complete(self.async_chatbot.ask(message, image=image))
300
-
301
- class AsyncChatbot:
302
- """
303
- Asynchronous chatbot client for interacting with Google Gemini using curl_cffi.
304
-
305
- This class manages authentication, session state, conversation history,
306
- and sending/receiving messages (including images) asynchronously.
307
-
308
- Attributes:
309
- headers (dict): HTTP headers for requests.
310
- _reqid (int): Request identifier for Gemini API.
311
- SNlM0e (str): Session token required for API requests.
312
- conversation_id (str): Current conversation ID.
313
- response_id (str): Current response ID.
314
- choice_id (str): Current choice ID.
315
- proxy (str | dict | None): Proxy configuration.
316
- proxies_dict (dict | None): Proxy dictionary for curl_cffi.
317
- secure_1psid (str): Authentication cookie.
318
- secure_1psidts (str): Authentication cookie.
319
- session (AsyncSession): curl_cffi session for HTTP requests.
320
- timeout (int): Request timeout in seconds.
321
- model (Model): Selected Gemini model.
322
- impersonate (str): Browser profile for curl_cffi to impersonate.
323
- """
324
- __slots__ = [
325
- "headers",
326
- "_reqid",
327
- "SNlM0e",
328
- "conversation_id",
329
- "response_id",
330
- "choice_id",
331
- "proxy", # Store the original proxy config
332
- "proxies_dict", # Store the curl_cffi-compatible proxy dict
333
- "secure_1psidts",
334
- "secure_1psid",
335
- "session",
336
- "timeout",
337
- "model",
338
- "impersonate", # Store impersonate setting
339
- ]
340
-
341
- def __init__(
342
- self,
343
- secure_1psid: str,
344
- secure_1psidts: str,
345
- proxy: Optional[Union[str, Dict[str, str]]] = None, # Allow string or dict proxy
346
- timeout: int = 20,
347
- model: Model = Model.UNSPECIFIED,
348
- impersonate: str = "chrome110", # Added impersonate
349
- ):
350
- headers = Headers.GEMINI.value.copy()
351
- if model != Model.UNSPECIFIED:
352
- headers.update(model.model_header)
353
- self._reqid = int("".join(random.choices(string.digits, k=7))) # Increased length for less collision chance
354
- self.proxy = proxy # Store original proxy setting
355
- self.impersonate = impersonate # Store impersonate setting
356
-
357
- # Prepare proxy dictionary for curl_cffi
358
- self.proxies_dict = None
359
- if isinstance(proxy, str):
360
- self.proxies_dict = {"http": proxy, "https": proxy} # curl_cffi uses http/https keys
361
- elif isinstance(proxy, dict):
362
- self.proxies_dict = proxy # Assume it's already in the correct format
363
-
364
- self.conversation_id = ""
365
- self.response_id = ""
366
- self.choice_id = ""
367
- self.secure_1psid = secure_1psid
368
- self.secure_1psidts = secure_1psidts
369
-
370
- # Initialize curl_cffi AsyncSession
371
- self.session = AsyncSession(
372
- headers=headers,
373
- cookies={"__Secure-1PSID": secure_1psid, "__Secure-1PSIDTS": secure_1psidts},
374
- proxies=self.proxies_dict,
375
- timeout=timeout,
376
- impersonate=self.impersonate
377
- # verify and http2 are handled automatically by curl_cffi
378
- )
379
- # No need to set proxies/headers/cookies again, done in constructor
380
-
381
- self.timeout = timeout # Store timeout for potential direct use in requests
382
- self.model = model
383
- self.SNlM0e = None # Initialize SNlM0e
384
-
385
- @classmethod
386
- async def create(
387
- cls,
388
- secure_1psid: str,
389
- secure_1psidts: str,
390
- proxy: Optional[Union[str, Dict[str, str]]] = None, # Allow string or dict proxy
391
- timeout: int = 20,
392
- model: Model = Model.UNSPECIFIED,
393
- impersonate: str = "chrome110", # Added impersonate
394
- ) -> "AsyncChatbot":
395
- """
396
- Factory method to create and initialize an AsyncChatbot instance.
397
- Fetches the necessary SNlM0e value asynchronously.
398
- """
399
- instance = cls(secure_1psid, secure_1psidts, proxy, timeout, model, impersonate) # Pass impersonate
400
- try:
401
- instance.SNlM0e = await instance.__get_snlm0e()
402
- except Exception as e:
403
- # Log the error and re-raise or handle appropriately
404
- console.log(f"[red]Error during AsyncChatbot initialization (__get_snlm0e): {e}[/red]", style="bold red")
405
- # Optionally close the session if initialization fails critically
406
- await instance.session.close() # Use close() for AsyncSession
407
- raise # Re-raise the exception to signal failure
408
- return instance
409
-
410
- async def save_conversation(self, file_path: str, conversation_name: str) -> None:
411
- # Logic remains the same
412
- conversations = await self.load_conversations(file_path)
413
- conversation_data = {
414
- "conversation_name": conversation_name,
415
- "_reqid": self._reqid,
416
- "conversation_id": self.conversation_id,
417
- "response_id": self.response_id,
418
- "choice_id": self.choice_id,
419
- "SNlM0e": self.SNlM0e,
420
- "model_name": self.model.model_name, # Save the model used
421
- "timestamp": datetime.now().isoformat(), # Add timestamp
422
- }
423
-
424
- found = False
425
- for i, conv in enumerate(conversations):
426
- if conv.get("conversation_name") == conversation_name:
427
- conversations[i] = conversation_data # Update existing
428
- found = True
429
- break
430
- if not found:
431
- conversations.append(conversation_data) # Add new
432
-
433
- try:
434
- # Ensure directory exists
435
- Path(file_path).parent.mkdir(parents=True, exist_ok=True)
436
- with open(file_path, "w", encoding="utf-8") as f:
437
- json.dump(conversations, f, indent=4, ensure_ascii=False)
438
- except IOError as e:
439
- console.log(f"[red]Error saving conversation to {file_path}: {e}[/red]")
440
- raise
441
-
442
- async def load_conversations(self, file_path: str) -> List[Dict]:
443
- # Logic remains the same
444
- if not os.path.isfile(file_path):
445
- return []
446
- try:
447
- with open(file_path, 'r', encoding="utf-8") as f:
448
- return json.load(f)
449
- except (json.JSONDecodeError, IOError) as e:
450
- console.log(f"[red]Error loading conversations from {file_path}: {e}[/red]")
451
- return []
452
-
453
- async def load_conversation(self, file_path: str, conversation_name: str) -> bool:
454
- # Logic remains the same, but update headers on the session
455
- conversations = await self.load_conversations(file_path)
456
- for conversation in conversations:
457
- if conversation.get("conversation_name") == conversation_name:
458
- try:
459
- self._reqid = conversation["_reqid"]
460
- self.conversation_id = conversation["conversation_id"]
461
- self.response_id = conversation["response_id"]
462
- self.choice_id = conversation["choice_id"]
463
- self.SNlM0e = conversation["SNlM0e"]
464
- if "model_name" in conversation:
465
- try:
466
- self.model = Model.from_name(conversation["model_name"])
467
- # Update headers in the session if model changed
468
- self.session.headers.update(self.model.model_header)
469
- except ValueError as e:
470
- console.log(f"[yellow]Warning: Model '{conversation['model_name']}' from saved conversation not found. Using current model '{self.model.model_name}'. Error: {e}[/yellow]")
471
-
472
- console.log(f"Loaded conversation '{conversation_name}'")
473
- return True
474
- except KeyError as e:
475
- console.log(f"[red]Error loading conversation '{conversation_name}': Missing key {e}[/red]")
476
- return False
477
- console.log(f"[yellow]Conversation '{conversation_name}' not found in {file_path}[/yellow]")
478
- return False
479
-
480
- async def __get_snlm0e(self):
481
- """Fetches the SNlM0e value required for API requests using curl_cffi."""
482
- if not self.secure_1psid:
483
- raise ValueError("__Secure-1PSID cookie is required.")
484
-
485
- try:
486
- # Use the session's get method
487
- resp = await self.session.get(
488
- Endpoint.INIT.value,
489
- timeout=self.timeout # Timeout is already set in session, but can override
490
- # follow_redirects is handled automatically by curl_cffi
491
- )
492
- resp.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
493
-
494
- # Check for authentication issues
495
- if "Sign in to continue" in resp.text or "accounts.google.com" in str(resp.url):
496
- raise PermissionError("Authentication failed. Cookies might be invalid or expired. Please update them.")
497
-
498
- # Regex to find the SNlM0e value
499
- snlm0e_match = re.search(r'["\']SNlM0e["\']\s*:\s*["\'](.*?)["\']', resp.text)
500
- if not snlm0e_match:
501
- error_message = "SNlM0e value not found in response."
502
- if resp.status_code == 429:
503
- error_message += " Rate limit likely exceeded."
504
- else:
505
- error_message += f" Response status: {resp.status_code}. Check cookie validity and network."
506
- raise ValueError(error_message)
507
-
508
- # Try to refresh PSIDTS if needed
509
- if not self.secure_1psidts and "PSIDTS" not in self.session.cookies:
510
- try:
511
- # Attempt to rotate cookies to get a fresh PSIDTS
512
- await self.__rotate_cookies()
513
- except Exception as e:
514
- console.log(f"[yellow]Warning: Could not refresh PSIDTS cookie: {e}[/yellow]")
515
- # Continue anyway as some accounts don't need PSIDTS
516
-
517
- return snlm0e_match.group(1)
518
-
519
- except Timeout as e: # Catch requests.exceptions.Timeout
520
- raise TimeoutError(f"Request timed out while fetching SNlM0e: {e}") from e
521
- except (RequestException, CurlError) as e: # Catch general request errors and Curl specific errors
522
- raise ConnectionError(f"Network error while fetching SNlM0e: {e}") from e
523
- except HTTPError as e: # Catch requests.exceptions.HTTPError
524
- if e.response.status_code == 401 or e.response.status_code == 403:
525
- raise PermissionError(f"Authentication failed (status {e.response.status_code}). Check cookies. {e}") from e
526
- else:
527
- raise Exception(f"HTTP error {e.response.status_code} while fetching SNlM0e: {e}") from e
528
-
529
- async def __rotate_cookies(self):
530
- """Rotates the __Secure-1PSIDTS cookie."""
531
- try:
532
- response = await self.session.post(
533
- Endpoint.ROTATE_COOKIES.value,
534
- headers=Headers.ROTATE_COOKIES.value,
535
- data='[000,"-0000000000000000000"]',
536
- timeout=self.timeout
537
- )
538
- response.raise_for_status()
539
-
540
- if new_1psidts := response.cookies.get("__Secure-1PSIDTS"):
541
- self.secure_1psidts = new_1psidts
542
- self.session.cookies.set("__Secure-1PSIDTS", new_1psidts)
543
- return new_1psidts
544
- except Exception as e:
545
- console.log(f"[yellow]Cookie rotation failed: {e}[/yellow]")
546
- raise
547
-
548
-
549
- async def ask(self, message: str, image: Optional[Union[bytes, str, Path]] = None) -> dict:
550
- """
551
- Sends a message to Google Gemini and returns the response using curl_cffi.
552
-
553
- Parameters:
554
- message: str
555
- The message to send.
556
- image: Optional[Union[bytes, str, Path]]
557
- Optional image data (bytes) or path to an image file to include.
558
-
559
- Returns:
560
- dict: A dictionary containing the response content and metadata.
561
- """
562
- if self.SNlM0e is None:
563
- raise RuntimeError("AsyncChatbot not properly initialized. Call AsyncChatbot.create()")
564
-
565
- params = {
566
- "bl": "boq_assistant-bard-web-server_20240625.13_p0",
567
- "_reqid": str(self._reqid),
568
- "rt": "c",
569
- }
570
-
571
- # Handle image upload if provided
572
- image_upload_id = None
573
- if image:
574
- try:
575
- # Pass proxy and impersonate settings to upload_file
576
- image_upload_id = await upload_file(image, proxy=self.proxies_dict, impersonate=self.impersonate)
577
- console.log(f"Image uploaded successfully. ID: {image_upload_id}")
578
- except Exception as e:
579
- console.log(f"[red]Error uploading image: {e}[/red]")
580
- return {"content": f"Error uploading image: {e}", "error": True}
581
-
582
- # Prepare message structure
583
- if image_upload_id:
584
- message_struct = [
585
- [message],
586
- [[[image_upload_id, 1]]],
587
- [self.conversation_id, self.response_id, self.choice_id],
588
- ]
589
- else:
590
- message_struct = [
591
- [message],
592
- None,
593
- [self.conversation_id, self.response_id, self.choice_id],
594
- ]
595
-
596
- # Prepare request data
597
- data = {
598
- "f.req": json.dumps([None, json.dumps(message_struct, ensure_ascii=False)], ensure_ascii=False),
599
- "at": self.SNlM0e,
600
- }
601
-
602
- try:
603
- # Send request
604
- resp = await self.session.post(
605
- Endpoint.GENERATE.value,
606
- params=params,
607
- data=data,
608
- timeout=self.timeout,
609
- )
610
- resp.raise_for_status()
611
-
612
- # Process response
613
- lines = resp.text.splitlines()
614
- if len(lines) < 3:
615
- raise ValueError(f"Unexpected response format. Status: {resp.status_code}. Content: {resp.text[:200]}...")
616
-
617
- # Find the line with the response data
618
- chat_data_line = None
619
- for line in lines:
620
- if line.startswith(")]}'"):
621
- chat_data_line = line[4:].strip()
622
- break
623
- elif line.startswith("["):
624
- chat_data_line = line
625
- break
626
-
627
- if not chat_data_line:
628
- chat_data_line = lines[3] if len(lines) > 3 else lines[-1]
629
- if chat_data_line.startswith(")]}'"):
630
- chat_data_line = chat_data_line[4:].strip()
631
-
632
- # Parse the response JSON
633
- response_json = json.loads(chat_data_line)
634
-
635
- # Find the main response body
636
- body = None
637
- body_index = 0
638
-
639
- for part_index, part in enumerate(response_json):
640
- try:
641
- if isinstance(part, list) and len(part) > 2:
642
- main_part = json.loads(part[2])
643
- if main_part and len(main_part) > 4 and main_part[4]:
644
- body = main_part
645
- body_index = part_index
646
- break
647
- except (IndexError, TypeError, json.JSONDecodeError):
648
- continue
649
-
650
- if not body:
651
- return {"content": "Failed to parse response body. No valid data found.", "error": True}
652
-
653
- # Extract data from the response
654
- try:
655
- # Extract main content
656
- content = ""
657
- if len(body) > 4 and len(body[4]) > 0 and len(body[4][0]) > 1:
658
- content = body[4][0][1][0] if len(body[4][0][1]) > 0 else ""
659
-
660
- # Extract conversation metadata
661
- conversation_id = body[1][0] if len(body) > 1 and len(body[1]) > 0 else self.conversation_id
662
- response_id = body[1][1] if len(body) > 1 and len(body[1]) > 1 else self.response_id
663
-
664
- # Extract additional data
665
- factualityQueries = body[3] if len(body) > 3 else None
666
- textQuery = body[2][0] if len(body) > 2 and body[2] else ""
667
-
668
- # Extract choices
669
- choices = []
670
- if len(body) > 4:
671
- for candidate in body[4]:
672
- if len(candidate) > 1 and isinstance(candidate[1], list) and len(candidate[1]) > 0:
673
- choices.append({"id": candidate[0], "content": candidate[1][0]})
674
-
675
- choice_id = choices[0]["id"] if choices else self.choice_id
676
-
677
- # Extract images - multiple possible formats
678
- images = []
679
-
680
- # Format 1: Regular web images
681
- if len(body) > 4 and len(body[4]) > 0 and len(body[4][0]) > 4 and body[4][0][4]:
682
- for img_data in body[4][0][4]:
683
- try:
684
- img_url = img_data[0][0][0]
685
- img_alt = img_data[2] if len(img_data) > 2 else ""
686
- img_title = img_data[1] if len(img_data) > 1 else "[Image]"
687
- images.append({"url": img_url, "alt": img_alt, "title": img_title})
688
- except (IndexError, TypeError):
689
- console.log("[yellow]Warning: Could not parse image data structure (format 1).[/yellow]")
690
- continue
691
-
692
- # Format 2: Generated images in standard location
693
- generated_images = []
694
- if len(body) > 4 and len(body[4]) > 0 and len(body[4][0]) > 12 and body[4][0][12]:
695
- try:
696
- # Path 1: Check for images in [12][7][0]
697
- if body[4][0][12][7] and body[4][0][12][7][0]:
698
- # This is the standard path for generated images
699
- for img_index, img_data in enumerate(body[4][0][12][7][0]):
700
- try:
701
- img_url = img_data[0][3][3]
702
- img_title = f"[Generated Image {img_index+1}]"
703
- img_alt = img_data[3][5][0] if len(img_data[3]) > 5 and len(img_data[3][5]) > 0 else ""
704
- generated_images.append({"url": img_url, "alt": img_alt, "title": img_title})
705
- except (IndexError, TypeError):
706
- continue
707
-
708
- # If we found images, but they might be in a different part of the response
709
- if not generated_images:
710
- # Look for image generation data in other response parts
711
- for part_index, part in enumerate(response_json):
712
- if part_index <= body_index:
713
- continue
714
- try:
715
- img_part = json.loads(part[2])
716
- if img_part[4][0][12][7][0]:
717
- for img_index, img_data in enumerate(img_part[4][0][12][7][0]):
718
- try:
719
- img_url = img_data[0][3][3]
720
- img_title = f"[Generated Image {img_index+1}]"
721
- img_alt = img_data[3][5][0] if len(img_data[3]) > 5 and len(img_data[3][5]) > 0 else ""
722
- generated_images.append({"url": img_url, "alt": img_alt, "title": img_title})
723
- except (IndexError, TypeError):
724
- continue
725
- break
726
- except (IndexError, TypeError, json.JSONDecodeError):
727
- continue
728
- except (IndexError, TypeError):
729
- pass
730
-
731
- # Format 3: Alternative location for generated images
732
- if len(generated_images) == 0 and len(body) > 4 and len(body[4]) > 0:
733
- try:
734
- # Try to find images in candidate[4] structure
735
- candidate = body[4][0]
736
- if len(candidate) > 22 and candidate[22]:
737
- # Look for URLs in the candidate[22] field
738
- import re
739
- content = candidate[22][0] if isinstance(candidate[22], list) and len(candidate[22]) > 0 else str(candidate[22])
740
- urls = re.findall(r'https?://[^\s]+', content)
741
- for i, url in enumerate(urls):
742
- # Clean up URL if it ends with punctuation
743
- if url[-1] in ['.', ',', ')', ']', '}', '"', "'"]:
744
- url = url[:-1]
745
- generated_images.append({
746
- "url": url,
747
- "title": f"[Generated Image {i+1}]",
748
- "alt": ""
749
- })
750
- except (IndexError, TypeError) as e:
751
- console.log(f"[yellow]Warning: Could not parse alternative image structure: {e}[/yellow]")
752
-
753
- # Format 4: Look for image URLs in the text content
754
- if len(images) == 0 and len(generated_images) == 0 and content:
755
- try:
756
- import re
757
- # Look for image URLs in the content - try multiple patterns
758
-
759
- # Pattern 1: Standard image URLs
760
- urls = re.findall(r'(https?://[^\s]+\.(jpg|jpeg|png|gif|webp))', content.lower())
761
-
762
- # Pattern 2: Google image URLs (which might not have extensions)
763
- google_urls = re.findall(r'(https?://lh\d+\.googleusercontent\.com/[^\s]+)', content)
764
-
765
- # Pattern 3: General URLs that might be images
766
- general_urls = re.findall(r'(https?://[^\s]+)', content)
767
-
768
- # Combine all found URLs
769
- all_urls = []
770
- if urls:
771
- all_urls.extend([url_tuple[0] for url_tuple in urls])
772
- if google_urls:
773
- all_urls.extend(google_urls)
774
-
775
- # Add general URLs only if we didn't find any specific image URLs
776
- if not all_urls and general_urls:
777
- all_urls = general_urls
778
-
779
- # Process all found URLs
780
- if all_urls:
781
- for i, url in enumerate(all_urls):
782
- # Clean up URL if it ends with punctuation
783
- if url[-1] in ['.', ',', ')', ']', '}', '"', "'"]:
784
- url = url[:-1]
785
- images.append({
786
- "url": url,
787
- "title": f"[Image in Content {i+1}]",
788
- "alt": ""
789
- })
790
- console.log(f"[green]Found {len(all_urls)} potential image URLs in content.[/green]")
791
- except Exception as e:
792
- console.log(f"[yellow]Warning: Error extracting URLs from content: {e}[/yellow]")
793
-
794
- # Combine all images
795
- all_images = images + generated_images
796
-
797
- # Prepare results
798
- results = {
799
- "content": content,
800
- "conversation_id": conversation_id,
801
- "response_id": response_id,
802
- "factualityQueries": factualityQueries,
803
- "textQuery": textQuery,
804
- "choices": choices,
805
- "images": all_images,
806
- "error": False,
807
- }
808
-
809
- # Update state
810
- self.conversation_id = conversation_id
811
- self.response_id = response_id
812
- self.choice_id = choice_id
813
- self._reqid += random.randint(1000, 9000)
814
-
815
- return results
816
-
817
- except (IndexError, TypeError) as e:
818
- console.log(f"[red]Error extracting data from response: {e}[/red]")
819
- return {"content": f"Error extracting data from response: {e}", "error": True}
820
-
821
- except json.JSONDecodeError as e:
822
- console.log(f"[red]Error parsing JSON response: {e}[/red]")
823
- return {"content": f"Error parsing JSON response: {e}. Response: {resp.text[:200]}...", "error": True}
824
- except Timeout as e:
825
- console.log(f"[red]Request timed out: {e}[/red]")
826
- return {"content": f"Request timed out: {e}", "error": True}
827
- except (RequestException, CurlError) as e:
828
- console.log(f"[red]Network error: {e}[/red]")
829
- return {"content": f"Network error: {e}", "error": True}
830
- except HTTPError as e:
831
- console.log(f"[red]HTTP error {e.response.status_code}: {e}[/red]")
832
- return {"content": f"HTTP error {e.response.status_code}: {e}", "error": True}
833
- except Exception as e:
834
- console.log(f"[red]An unexpected error occurred during ask: {e}[/red]", style="bold red")
835
- return {"content": f"An unexpected error occurred: {e}", "error": True}
836
-
837
-
838
- #########################################
839
- # New Image classes
840
- #########################################
841
-
842
- class Image(BaseModel):
843
- """
844
- Represents a single image object returned from Gemini.
845
-
846
- Attributes:
847
- url (str): URL of the image.
848
- title (str): Title of the image (default: "[Image]").
849
- alt (str): Optional description of the image.
850
- proxy (str | dict | None): Proxy used when saving the image.
851
- impersonate (str): Browser profile for curl_cffi to impersonate.
852
- """
853
- url: str
854
- title: str = "[Image]"
855
- alt: str = ""
856
- proxy: Optional[Union[str, Dict[str, str]]] = None
857
- impersonate: str = "chrome110"
858
-
859
- def __str__(self):
860
- return f"{self.title}({self.url}) - {self.alt}"
861
-
862
- def __repr__(self):
863
- short_url = self.url if len(self.url) <= 50 else self.url[:20] + "..." + self.url[-20:]
864
- short_alt = self.alt[:30] + "..." if len(self.alt) > 30 else self.alt
865
- return f"Image(title='{self.title}', url='{short_url}', alt='{short_alt}')"
866
-
867
- async def save(
868
- self,
869
- path: str = "downloaded_images",
870
- filename: Optional[str] = None,
871
- cookies: Optional[dict] = None,
872
- verbose: bool = False,
873
- skip_invalid_filename: bool = True,
874
- ) -> Optional[str]:
875
- """
876
- Save the image to disk using curl_cffi.
877
- Parameters:
878
- path: str, optional
879
- Directory to save the image (default "downloaded_images").
880
- filename: str, optional
881
- Filename to use; if not provided, inferred from URL.
882
- cookies: dict, optional
883
- Cookies used for the image request.
884
- verbose: bool, optional
885
- If True, outputs status messages (default False).
886
- skip_invalid_filename: bool, optional
887
- If True, skips saving if the filename is invalid.
888
- Returns:
889
- Absolute path of the saved image if successful; None if skipped.
890
- Raises:
891
- HTTPError if the network request fails.
892
- RequestException/CurlError for other network errors.
893
- IOError if file writing fails.
894
- """
895
- # Generate filename from URL if not provided
896
- if not filename:
897
- try:
898
- from urllib.parse import urlparse, unquote
899
- parsed_url = urlparse(self.url)
900
- base_filename = os.path.basename(unquote(parsed_url.path))
901
- # Remove invalid characters for filenames
902
- safe_filename = re.sub(r'[<>:"/\\|?*]', '_', base_filename)
903
- if safe_filename and len(safe_filename) > 0:
904
- filename = safe_filename
905
- else:
906
- filename = f"image_{random.randint(1000, 9999)}.jpg"
907
- except Exception:
908
- filename = f"image_{random.randint(1000, 9999)}.jpg"
909
-
910
- # Validate filename length
911
- try:
912
- _ = Path(filename)
913
- max_len = 255
914
- if len(filename) > max_len:
915
- name, ext = os.path.splitext(filename)
916
- filename = name[:max_len - len(ext) - 1] + ext
917
- except (OSError, ValueError):
918
- if verbose:
919
- console.log(f"[yellow]Invalid filename generated: {filename}[/yellow]")
920
- if skip_invalid_filename:
921
- if verbose:
922
- console.log("[yellow]Skipping save due to invalid filename.[/yellow]")
923
- return None
924
- filename = f"image_{random.randint(1000, 9999)}.jpg"
925
- if verbose:
926
- console.log(f"[yellow]Using fallback filename: {filename}[/yellow]")
927
-
928
- # Prepare proxy dictionary for curl_cffi
929
- proxies_dict = None
930
- if isinstance(self.proxy, str):
931
- proxies_dict = {"http": self.proxy, "https": self.proxy}
932
- elif isinstance(self.proxy, dict):
933
- proxies_dict = self.proxy
934
-
935
- try:
936
- # Use AsyncSession from curl_cffi
937
- async with AsyncSession(
938
- cookies=cookies,
939
- proxies=proxies_dict,
940
- impersonate=self.impersonate
941
- # follow_redirects is handled automatically by curl_cffi
942
- ) as client:
943
- if verbose:
944
- console.log(f"Attempting to download image from: {self.url}")
945
-
946
- response = await client.get(self.url)
947
- response.raise_for_status()
948
-
949
- # Check content type
950
- content_type = response.headers.get("content-type", "").lower()
951
- if "image" not in content_type and verbose:
952
- console.log(f"[yellow]Warning: Content type is '{content_type}', not an image. Saving anyway.[/yellow]")
953
-
954
- # Create directory and save file
955
- dest_path = Path(path)
956
- dest_path.mkdir(parents=True, exist_ok=True)
957
- dest = dest_path / filename
958
-
959
- # Write image data to file
960
- dest.write_bytes(response.content)
961
-
962
- if verbose:
963
- console.log(f"Image saved successfully as {dest.resolve()}")
964
-
965
- return str(dest.resolve())
966
-
967
- except HTTPError as e:
968
- console.log(f"[red]Error downloading image {self.url}: {e.response.status_code} {e}[/red]")
969
- raise
970
- except (RequestException, CurlError) as e:
971
- console.log(f"[red]Network error downloading image {self.url}: {e}[/red]")
972
- raise
973
- except IOError as e:
974
- console.log(f"[red]Error writing image file to {dest}: {e}[/red]")
975
- raise
976
- except Exception as e:
977
- console.log(f"[red]An unexpected error occurred during image save: {e}[/red]")
978
- raise
979
-
980
-
981
- class WebImage(Image):
982
- """
983
- Represents an image retrieved from web search results.
984
-
985
- Returned when asking Gemini to "SEND an image of [something]".
986
- """
987
- pass
988
-
989
- class GeneratedImage(Image):
990
- """
991
- Represents an image generated by Google's AI image generator (e.g., ImageFX).
992
-
993
- Attributes:
994
- cookies (dict[str, str]): Cookies required for accessing the generated image URL,
995
- typically from the GeminiClient/Chatbot instance.
996
- """
997
- cookies: Dict[str, str]
998
-
999
- # Updated validator for Pydantic V2
1000
- @field_validator("cookies")
1001
- @classmethod
1002
- def validate_cookies(cls, v: Dict[str, str]) -> Dict[str, str]:
1003
- """Ensures cookies are provided for generated images."""
1004
- if not v or not isinstance(v, dict):
1005
- raise ValueError("GeneratedImage requires a dictionary of cookies from the client.")
1006
- return v
1007
-
1008
- async def save(self, **kwargs) -> Optional[str]:
1009
- """
1010
- Save the generated image to disk.
1011
- Parameters:
1012
- filename: str, optional
1013
- Filename to use. If not provided, a default name including
1014
- a timestamp and part of the URL is used. Generated images
1015
- are often in .png or .jpg format.
1016
- Additional arguments are passed to Image.save.
1017
- Returns:
1018
- Absolute path of the saved image if successful, None if skipped.
1019
- """
1020
- if "filename" not in kwargs:
1021
- ext = ".jpg" if ".jpg" in self.url.lower() else ".png"
1022
- url_part = self.url.split('/')[-1][:10]
1023
- kwargs["filename"] = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{url_part}{ext}"
1024
-
1025
- # Pass the required cookies and other args (like impersonate) to the parent save method
1026
- return await super().save(cookies=self.cookies, **kwargs)
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import random
5
+ import re
6
+ import string
7
+ from datetime import datetime
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union, cast
11
+
12
+ try:
13
+ import trio # type: ignore # noqa: F401
14
+ except ImportError:
15
+ pass
16
+ from curl_cffi import CurlError
17
+ from curl_cffi.requests import AsyncSession
18
+ from pydantic import BaseModel, field_validator
19
+ from requests.exceptions import HTTPError, RequestException, Timeout
20
+ from rich.console import Console
21
+
22
+ console = Console()
23
+
24
+
25
+ class AskResponse(TypedDict):
26
+ content: str
27
+ conversation_id: str
28
+ response_id: str
29
+ factualityQueries: Optional[List[Any]]
30
+ textQuery: str
31
+ choices: List[Dict[str, Union[str, List[str]]]]
32
+ images: List[Dict[str, str]]
33
+ error: bool
34
+
35
+
36
+ class Endpoint(Enum):
37
+ """
38
+ Enum for Google Gemini API endpoints.
39
+
40
+ Attributes:
41
+ INIT (str): URL for initializing the Gemini session.
42
+ GENERATE (str): URL for generating chat responses.
43
+ ROTATE_COOKIES (str): URL for rotating authentication cookies.
44
+ UPLOAD (str): URL for uploading files/images.
45
+ """
46
+
47
+ INIT = "https://gemini.google.com/app"
48
+ GENERATE = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
49
+ ROTATE_COOKIES = "https://accounts.google.com/RotateCookies"
50
+ UPLOAD = "https://content-push.googleapis.com/upload"
51
+
52
+
53
+ class Headers(Enum):
54
+ """
55
+ Enum for HTTP headers used in Gemini API requests.
56
+
57
+ Attributes:
58
+ GEMINI (dict): Headers for Gemini chat requests.
59
+ ROTATE_COOKIES (dict): Headers for rotating cookies.
60
+ UPLOAD (dict): Headers for file/image upload.
61
+ """
62
+
63
+ GEMINI = {
64
+ "Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
65
+ "Host": "gemini.google.com",
66
+ "Origin": "https://gemini.google.com",
67
+ "Referer": "https://gemini.google.com/",
68
+ "X-Same-Domain": "1",
69
+ }
70
+ ROTATE_COOKIES = {
71
+ "Content-Type": "application/json",
72
+ }
73
+ UPLOAD = {"Push-ID": "feeds/mcudyrk2a4khkz"}
74
+
75
+
76
+ class Model(Enum):
77
+ """
78
+ Enum for available Gemini model configurations.
79
+
80
+ Attributes:
81
+ model_name (str): Name of the model.
82
+ model_header (dict): Additional headers required for the model.
83
+ advanced_only (bool): Whether the model is available only for advanced users.
84
+ """
85
+
86
+ UNSPECIFIED = ("unspecified", {}, False)
87
+ GEMINI_3_0_PRO = (
88
+ "gemini-3.0-pro",
89
+ {
90
+ "x-goog-ext-525001261-jspb": '[1,null,null,null,"e6fa609c3fa255c0",null,null,0,[4],null,null,2]'
91
+ },
92
+ False,
93
+ )
94
+ GEMINI_3_0_FLASH = (
95
+ "gemini-3.0-flash",
96
+ {
97
+ "x-goog-ext-525001261-jspb": '[1,null,null,null,"56fdd199312815e2",null,null,0,[4],null,null,2]'
98
+ },
99
+ False,
100
+ )
101
+ GEMINI_3_0_FLASH_THINKING = (
102
+ "gemini-3.0-flash-thinking",
103
+ {
104
+ "x-goog-ext-525001261-jspb": '[1,null,null,null,"e051ce1aa80aa576",null,null,0,[4],null,null,2]'
105
+ },
106
+ False,
107
+ )
108
+
109
+ def __init__(self, name: str, header: Dict[str, str], advanced_only: bool):
110
+ """
111
+ Initialize a Model enum member.
112
+
113
+ Args:
114
+ name (str): Model name.
115
+ header (dict): Model-specific headers.
116
+ advanced_only (bool): If True, model is for advanced users only.
117
+ """
118
+ self.model_name = name
119
+ self.model_header = header
120
+ self.advanced_only = advanced_only
121
+
122
+ @classmethod
123
+ def from_name(cls, name: str):
124
+ """
125
+ Get a Model enum member by its model name.
126
+
127
+ Args:
128
+ name (str): Name of the model.
129
+
130
+ Returns:
131
+ Model: Corresponding Model enum member.
132
+
133
+ Raises:
134
+ ValueError: If the model name is not found.
135
+ """
136
+ for model in cls:
137
+ if model.model_name == name:
138
+ return model
139
+ raise ValueError(
140
+ f"Unknown model name: {name}. Available models: {', '.join([model.model_name for model in cls])}"
141
+ )
142
+
143
+
144
+ async def upload_file(
145
+ file: Union[bytes, str, Path],
146
+ proxy: Optional[Union[str, Dict[str, str]]] = None,
147
+ impersonate: str = "chrome110",
148
+ ) -> str:
149
+ """
150
+ Uploads a file to Google's Gemini server using curl_cffi and returns its identifier.
151
+
152
+ Args:
153
+ file (bytes | str | Path): File data in bytes or path to the file to be uploaded.
154
+ proxy (str | dict, optional): Proxy URL or dictionary for the request.
155
+ impersonate (str, optional): Browser profile for curl_cffi to impersonate. Defaults to "chrome110".
156
+
157
+ Returns:
158
+ str: Identifier of the uploaded file.
159
+
160
+ Raises:
161
+ HTTPError: If the upload request fails.
162
+ RequestException: For other network-related errors.
163
+ FileNotFoundError: If the file path does not exist.
164
+ """
165
+ if not isinstance(file, bytes):
166
+ file_path = Path(file)
167
+ if not file_path.is_file():
168
+ raise FileNotFoundError(f"File not found at path: {file}")
169
+ with open(file_path, "rb") as f:
170
+ file_content = f.read()
171
+ else:
172
+ file_content = file
173
+
174
+ proxies_dict = None
175
+ if isinstance(proxy, str):
176
+ proxies_dict = {"http": proxy, "https": proxy}
177
+ elif isinstance(proxy, dict):
178
+ proxies_dict = proxy
179
+
180
+ try:
181
+ async with AsyncSession(
182
+ proxies=cast(Any, proxies_dict),
183
+ impersonate=cast(Any, impersonate),
184
+ headers=Headers.UPLOAD.value,
185
+ ) as client:
186
+ response = await client.post(
187
+ url=Endpoint.UPLOAD.value,
188
+ files={"file": file_content},
189
+ )
190
+ response.raise_for_status()
191
+ return response.text
192
+ except HTTPError as e:
193
+ console.log(f"[red]HTTP error during file upload: {e.response.status_code} {e}[/red]")
194
+ raise
195
+ except (RequestException, CurlError) as e:
196
+ console.log(f"[red]Network error during file upload: {e}[/red]")
197
+ raise
198
+
199
+
200
+ def load_cookies(cookie_path: str) -> Tuple[str, str]:
201
+ """
202
+ Loads authentication cookies from a JSON file.
203
+
204
+ Args:
205
+ cookie_path (str): Path to the JSON file containing cookies.
206
+
207
+ Returns:
208
+ tuple[str, str]: Tuple containing __Secure-1PSID and __Secure-1PSIDTS cookie values.
209
+
210
+ Raises:
211
+ Exception: If the file is not found, invalid, or required cookies are missing.
212
+ """
213
+ try:
214
+ with open(cookie_path, "r", encoding="utf-8") as file:
215
+ cookies = json.load(file)
216
+ session_auth1 = next(
217
+ (item["value"] for item in cookies if item["name"].upper() == "__SECURE-1PSID"), None
218
+ )
219
+ session_auth2 = next(
220
+ (item["value"] for item in cookies if item["name"].upper() == "__SECURE-1PSIDTS"), None
221
+ )
222
+
223
+ if not session_auth1 or not session_auth2:
224
+ raise ValueError("Required cookies (__Secure-1PSID or __Secure-1PSIDTS) not found.")
225
+
226
+ return session_auth1, session_auth2
227
+ except FileNotFoundError:
228
+ raise Exception(f"Cookie file not found at path: {cookie_path}")
229
+ except json.JSONDecodeError:
230
+ raise Exception("Invalid JSON format in the cookie file.")
231
+ except StopIteration as e:
232
+ raise Exception(f"{e} Check the cookie file format and content.")
233
+ except Exception as e:
234
+ raise Exception(f"An unexpected error occurred while loading cookies: {e}")
235
+
236
+
237
+ class Chatbot:
238
+ """
239
+ Synchronous wrapper for the AsyncChatbot class.
240
+
241
+ This class provides a synchronous interface to interact with Google Gemini,
242
+ handling authentication, conversation management, and message sending.
243
+
244
+ Attributes:
245
+ loop (asyncio.AbstractEventLoop): Event loop for running async tasks.
246
+ secure_1psid (str): Authentication cookie.
247
+ secure_1psidts (str): Authentication cookie.
248
+ async_chatbot (AsyncChatbot): Underlying asynchronous chatbot instance.
249
+ """
250
+
251
+ def __init__(
252
+ self,
253
+ cookie_path: str,
254
+ proxy: Optional[Union[str, Dict[str, str]]] = None,
255
+ timeout: int = 20,
256
+ model: Model = Model.UNSPECIFIED,
257
+ impersonate: str = "chrome110",
258
+ ):
259
+ try:
260
+ self.loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
261
+ except RuntimeError:
262
+ self.loop = asyncio.new_event_loop()
263
+ asyncio.set_event_loop(self.loop)
264
+
265
+ self.secure_1psid, self.secure_1psidts = load_cookies(cookie_path)
266
+ self.async_chatbot = self.loop.run_until_complete(
267
+ AsyncChatbot.create(
268
+ self.secure_1psid, self.secure_1psidts, proxy, timeout, model, impersonate
269
+ )
270
+ )
271
+
272
+ def save_conversation(self, file_path: str, conversation_name: str):
273
+ return self.loop.run_until_complete(
274
+ self.async_chatbot.save_conversation(file_path, conversation_name)
275
+ )
276
+
277
+ def load_conversations(self, file_path: str) -> List[Dict]:
278
+ return self.loop.run_until_complete(self.async_chatbot.load_conversations(file_path))
279
+
280
+ def load_conversation(self, file_path: str, conversation_name: str) -> bool:
281
+ return self.loop.run_until_complete(
282
+ self.async_chatbot.load_conversation(file_path, conversation_name)
283
+ )
284
+
285
+ def ask(self, message: str, image: Optional[Union[bytes, str, Path]] = None) -> AskResponse:
286
+ return self.loop.run_until_complete(self.async_chatbot.ask(message, image=image))
287
+
288
+
289
+ class AsyncChatbot:
290
+ """
291
+ Asynchronous chatbot client for interacting with Google Gemini using curl_cffi.
292
+
293
+ This class manages authentication, session state, conversation history,
294
+ and sending/receiving messages (including images) asynchronously.
295
+
296
+ Attributes:
297
+ headers (dict): HTTP headers for requests.
298
+ _reqid (int): Request identifier for Gemini API.
299
+ SNlM0e (str): Session token required for API requests.
300
+ conversation_id (str): Current conversation ID.
301
+ response_id (str): Current response ID.
302
+ choice_id (str): Current choice ID.
303
+ proxy (str | dict | None): Proxy configuration.
304
+ proxies_dict (dict | None): Proxy dictionary for curl_cffi.
305
+ secure_1psid (str): Authentication cookie.
306
+ secure_1psidts (str): Authentication cookie.
307
+ session (AsyncSession): curl_cffi session for HTTP requests.
308
+ timeout (int): Request timeout in seconds.
309
+ model (Model): Selected Gemini model.
310
+ impersonate (str): Browser profile for curl_cffi to impersonate.
311
+ """
312
+
313
+ __slots__ = [
314
+ "headers",
315
+ "_reqid",
316
+ "SNlM0e",
317
+ "conversation_id",
318
+ "response_id",
319
+ "choice_id",
320
+ "proxy",
321
+ "proxies_dict",
322
+ "secure_1psidts",
323
+ "secure_1psid",
324
+ "session",
325
+ "timeout",
326
+ "model",
327
+ "impersonate",
328
+ ]
329
+
330
+ def __init__(
331
+ self,
332
+ secure_1psid: str,
333
+ secure_1psidts: str,
334
+ proxy: Optional[Union[str, Dict[str, str]]] = None,
335
+ timeout: int = 20,
336
+ model: Model = Model.UNSPECIFIED,
337
+ impersonate: str = "chrome110",
338
+ ):
339
+ headers = Headers.GEMINI.value.copy()
340
+ if model != Model.UNSPECIFIED:
341
+ headers.update(model.model_header)
342
+ self._reqid = int("".join(random.choices(string.digits, k=7)))
343
+ self.proxy = proxy
344
+ self.impersonate = impersonate
345
+
346
+ self.proxies_dict = None
347
+ if isinstance(proxy, str):
348
+ self.proxies_dict = {"http": proxy, "https": proxy}
349
+ elif isinstance(proxy, dict):
350
+ self.proxies_dict = proxy
351
+
352
+ self.conversation_id = ""
353
+ self.response_id = ""
354
+ self.choice_id = ""
355
+ self.secure_1psid = secure_1psid
356
+ self.secure_1psidts = secure_1psidts
357
+
358
+ self.session: AsyncSession = AsyncSession(
359
+ headers=headers,
360
+ cookies={"__Secure-1PSID": secure_1psid, "__Secure-1PSIDTS": secure_1psidts},
361
+ proxies=cast(Any, self.proxies_dict if self.proxies_dict else None),
362
+ timeout=timeout,
363
+ impersonate=cast(Any, self.impersonate if self.impersonate else None),
364
+ )
365
+
366
+ self.timeout = timeout
367
+ self.model = model
368
+ self.SNlM0e = None
369
+
370
+ @classmethod
371
+ async def create(
372
+ cls,
373
+ secure_1psid: str,
374
+ secure_1psidts: str,
375
+ proxy: Optional[Union[str, Dict[str, str]]] = None,
376
+ timeout: int = 20,
377
+ model: Model = Model.UNSPECIFIED,
378
+ impersonate: str = "chrome110",
379
+ ) -> "AsyncChatbot":
380
+ """
381
+ Factory method to create and initialize an AsyncChatbot instance.
382
+ Fetches the necessary SNlM0e value asynchronously.
383
+ """
384
+ instance = cls(secure_1psid, secure_1psidts, proxy, timeout, model, impersonate)
385
+ try:
386
+ instance.SNlM0e = await instance.__get_snlm0e()
387
+ except Exception as e:
388
+ console.log(
389
+ f"[red]Error during AsyncChatbot initialization (__get_snlm0e): {e}[/red]",
390
+ style="bold red",
391
+ )
392
+ await instance.session.close()
393
+ raise
394
+ return instance
395
+
396
+ def _error_response(self, message: str) -> AskResponse:
397
+ """Helper to create a consistent error response."""
398
+ return {
399
+ "content": message,
400
+ "conversation_id": getattr(self, "conversation_id", ""),
401
+ "response_id": getattr(self, "response_id", ""),
402
+ "factualityQueries": [],
403
+ "textQuery": "",
404
+ "choices": [],
405
+ "images": [],
406
+ "error": True,
407
+ }
408
+
409
+ async def save_conversation(self, file_path: str, conversation_name: str) -> None:
410
+ conversations = await self.load_conversations(file_path)
411
+ conversation_data = {
412
+ "conversation_name": conversation_name,
413
+ "_reqid": self._reqid,
414
+ "conversation_id": self.conversation_id,
415
+ "response_id": self.response_id,
416
+ "choice_id": self.choice_id,
417
+ "SNlM0e": self.SNlM0e,
418
+ "model_name": self.model.model_name,
419
+ "timestamp": datetime.now().isoformat(),
420
+ }
421
+
422
+ found = False
423
+ for i, conv in enumerate(conversations):
424
+ if conv.get("conversation_name") == conversation_name:
425
+ conversations[i] = conversation_data
426
+ found = True
427
+ break
428
+ if not found:
429
+ conversations.append(conversation_data)
430
+
431
+ try:
432
+ Path(file_path).parent.mkdir(parents=True, exist_ok=True)
433
+ with open(file_path, "w", encoding="utf-8") as f:
434
+ json.dump(conversations, f, indent=4, ensure_ascii=False)
435
+ except IOError as e:
436
+ console.log(f"[red]Error saving conversation to {file_path}: {e}[/red]")
437
+ raise
438
+
439
+ async def load_conversations(self, file_path: str) -> List[Dict]:
440
+ if not os.path.isfile(file_path):
441
+ return []
442
+ try:
443
+ with open(file_path, "r", encoding="utf-8") as f:
444
+ return json.load(f)
445
+ except (json.JSONDecodeError, IOError) as e:
446
+ console.log(f"[red]Error loading conversations from {file_path}: {e}[/red]")
447
+ return []
448
+
449
+ async def load_conversation(self, file_path: str, conversation_name: str) -> bool:
450
+ conversations = await self.load_conversations(file_path)
451
+ for conversation in conversations:
452
+ if conversation.get("conversation_name") == conversation_name:
453
+ try:
454
+ self._reqid = conversation["_reqid"]
455
+ self.conversation_id = conversation["conversation_id"]
456
+ self.response_id = conversation["response_id"]
457
+ self.choice_id = conversation["choice_id"]
458
+ self.SNlM0e = conversation["SNlM0e"]
459
+ if "model_name" in conversation:
460
+ try:
461
+ self.model = Model.from_name(conversation["model_name"])
462
+ self.session.headers.update(self.model.model_header)
463
+ except ValueError as e:
464
+ console.log(
465
+ f"[yellow]Warning: Model '{conversation['model_name']}' from saved conversation not found. Using current model '{self.model.model_name}'. Error: {e}[/yellow]"
466
+ )
467
+
468
+ console.log(f"Loaded conversation '{conversation_name}'")
469
+ return True
470
+ except KeyError as e:
471
+ console.log(
472
+ f"[red]Error loading conversation '{conversation_name}': Missing key {e}[/red]"
473
+ )
474
+ return False
475
+ console.log(f"[yellow]Conversation '{conversation_name}' not found in {file_path}[/yellow]")
476
+ return False
477
+
478
+ async def __get_snlm0e(self) -> str:
479
+ """Fetches the SNlM0e value required for API requests using curl_cffi."""
480
+ if not self.secure_1psid:
481
+ raise ValueError("__Secure-1PSID cookie is required.")
482
+
483
+ try:
484
+ resp = await self.session.get(Endpoint.INIT.value, timeout=self.timeout)
485
+ resp.raise_for_status()
486
+
487
+ if "Sign in to continue" in resp.text or "accounts.google.com" in str(resp.url):
488
+ raise PermissionError(
489
+ "Authentication failed. Cookies might be invalid or expired. Please update them."
490
+ )
491
+
492
+ snlm0e_match = re.search(r'["\']SNlM0e["\']\s*:\s*["\'](.*?)["\']', resp.text)
493
+ if not snlm0e_match:
494
+ error_message = "SNlM0e value not found in response."
495
+ if resp.status_code == 429:
496
+ error_message += " Rate limit likely exceeded."
497
+ else:
498
+ error_message += (
499
+ f" Response status: {resp.status_code}. Check cookie validity and network."
500
+ )
501
+ raise ValueError(error_message)
502
+
503
+ if not self.secure_1psidts and "PSIDTS" not in self.session.cookies:
504
+ try:
505
+ await self.__rotate_cookies()
506
+ except Exception as e:
507
+ console.log(f"[yellow]Warning: Could not refresh PSIDTS cookie: {e}[/yellow]")
508
+
509
+ return snlm0e_match.group(1)
510
+
511
+ except Timeout as e:
512
+ raise TimeoutError(f"Request timed out while fetching SNlM0e: {e}") from e
513
+ except (RequestException, CurlError) as e:
514
+ raise ConnectionError(f"Network error while fetching SNlM0e: {e}") from e
515
+ except Exception as e:
516
+ if isinstance(e, HTTPError) and (
517
+ e.response.status_code == 401 or e.response.status_code == 403
518
+ ):
519
+ raise PermissionError(
520
+ f"Authentication failed (status {e.response.status_code}). Check cookies. {e}"
521
+ ) from e
522
+ else:
523
+ raise Exception(f"Error while fetching SNlM0e: {e}") from e
524
+
525
+ async def __rotate_cookies(self) -> Optional[str]:
526
+ """Rotates the __Secure-1PSIDTS cookie."""
527
+ try:
528
+ response = await self.session.post(
529
+ Endpoint.ROTATE_COOKIES.value,
530
+ headers=Headers.ROTATE_COOKIES.value,
531
+ data='[000,"-0000000000000000000"]',
532
+ timeout=self.timeout,
533
+ )
534
+ response.raise_for_status()
535
+
536
+ if new_1psidts := response.cookies.get("__Secure-1PSIDTS"):
537
+ self.secure_1psidts = new_1psidts
538
+ self.session.cookies.set("__Secure-1PSIDTS", new_1psidts)
539
+ return new_1psidts
540
+ except Exception as e:
541
+ console.log(f"[yellow]Cookie rotation failed: {e}[/yellow]")
542
+ raise
543
+
544
+ async def ask(
545
+ self, message: str, image: Optional[Union[bytes, str, Path]] = None
546
+ ) -> AskResponse:
547
+ """
548
+ Sends a message to Google Gemini and returns the response using curl_cffi.
549
+
550
+ Parameters:
551
+ message: str
552
+ The message to send.
553
+ image: Optional[Union[bytes, str, Path]]
554
+ Optional image data (bytes) or path to an image file to include.
555
+
556
+ Returns:
557
+ dict: A dictionary containing the response content and metadata.
558
+ """
559
+ if self.SNlM0e is None:
560
+ raise RuntimeError("AsyncChatbot not properly initialized. Call AsyncChatbot.create()")
561
+
562
+ params = {
563
+ "bl": "boq_assistant-bard-web-server_20240625.13_p0",
564
+ "_reqid": str(self._reqid),
565
+ "rt": "c",
566
+ }
567
+
568
+ image_upload_id = None
569
+ if image:
570
+ try:
571
+ image_upload_id = await upload_file(
572
+ image, proxy=self.proxies_dict, impersonate=self.impersonate
573
+ )
574
+ console.log(f"Image uploaded successfully. ID: {image_upload_id}")
575
+ except Exception as e:
576
+ console.log(f"[red]Error uploading image: {e}[/red]")
577
+ return self._error_response(f"Error uploading image: {e}")
578
+
579
+ if image_upload_id:
580
+ message_struct = [
581
+ [message],
582
+ [[[image_upload_id, 1]]],
583
+ [self.conversation_id, self.response_id, self.choice_id],
584
+ ]
585
+ else:
586
+ message_struct = [
587
+ [message],
588
+ None,
589
+ [self.conversation_id, self.response_id, self.choice_id],
590
+ ]
591
+
592
+ data = {
593
+ "f.req": json.dumps(
594
+ [None, json.dumps(message_struct, ensure_ascii=False)], ensure_ascii=False
595
+ ),
596
+ "at": self.SNlM0e,
597
+ }
598
+
599
+ resp = None
600
+ try:
601
+ resp = await self.session.post(
602
+ Endpoint.GENERATE.value,
603
+ params=params,
604
+ data=data,
605
+ timeout=self.timeout,
606
+ )
607
+ resp.raise_for_status()
608
+
609
+ if resp is None:
610
+ raise ValueError("Failed to get response from Gemini API")
611
+
612
+ lines = resp.text.splitlines()
613
+ if len(lines) < 3:
614
+ raise ValueError(
615
+ f"Unexpected response format. Status: {resp.status_code}. Content: {resp.text[:200]}..."
616
+ )
617
+
618
+ chat_data_line = None
619
+ for line in lines:
620
+ if line.startswith(")]}'"):
621
+ chat_data_line = line[4:].strip()
622
+ break
623
+ elif line.startswith("["):
624
+ chat_data_line = line
625
+ break
626
+
627
+ if not chat_data_line:
628
+ chat_data_line = lines[3] if len(lines) > 3 else lines[-1]
629
+ if chat_data_line.startswith(")]}'"):
630
+ chat_data_line = chat_data_line[4:].strip()
631
+
632
+ response_json = json.loads(chat_data_line)
633
+
634
+ body = None
635
+ body_index = 0
636
+
637
+ for part_index, part in enumerate(response_json):
638
+ try:
639
+ if isinstance(part, list) and len(part) > 2:
640
+ main_part = json.loads(part[2])
641
+ if main_part and len(main_part) > 4 and main_part[4]:
642
+ body = main_part
643
+ body_index = part_index
644
+ break
645
+ except (IndexError, TypeError, json.JSONDecodeError):
646
+ continue
647
+
648
+ if not body:
649
+ return self._error_response("Failed to parse response body. No valid data found.")
650
+
651
+ try:
652
+ content = ""
653
+ if len(body) > 4 and len(body[4]) > 0 and len(body[4][0]) > 1:
654
+ content = body[4][0][1][0] if len(body[4][0][1]) > 0 else ""
655
+
656
+ conversation_id = (
657
+ body[1][0] if len(body) > 1 and len(body[1]) > 0 else self.conversation_id
658
+ )
659
+ response_id = body[1][1] if len(body) > 1 and len(body[1]) > 1 else self.response_id
660
+
661
+ factualityQueries = body[3] if len(body) > 3 else None
662
+ textQuery = body[2][0] if len(body) > 2 and body[2] else ""
663
+
664
+ choices = []
665
+ if len(body) > 4:
666
+ for candidate in body[4]:
667
+ if (
668
+ len(candidate) > 1
669
+ and isinstance(candidate[1], list)
670
+ and len(candidate[1]) > 0
671
+ ):
672
+ choices.append({"id": candidate[0], "content": candidate[1][0]})
673
+
674
+ choice_id = choices[0]["id"] if choices else self.choice_id
675
+
676
+ images = []
677
+
678
+ if len(body) > 4 and len(body[4]) > 0 and len(body[4][0]) > 4 and body[4][0][4]:
679
+ for img_data in body[4][0][4]:
680
+ try:
681
+ img_url = img_data[0][0][0]
682
+ img_alt = img_data[2] if len(img_data) > 2 else ""
683
+ img_title = img_data[1] if len(img_data) > 1 else "[Image]"
684
+ images.append({"url": img_url, "alt": img_alt, "title": img_title})
685
+ except (IndexError, TypeError):
686
+ console.log(
687
+ "[yellow]Warning: Could not parse image data structure (format 1).[/yellow]"
688
+ )
689
+ continue
690
+
691
+ generated_images = []
692
+ if len(body) > 4 and len(body[4]) > 0 and len(body[4][0]) > 12 and body[4][0][12]:
693
+ try:
694
+ if body[4][0][12][7] and body[4][0][12][7][0]:
695
+ for img_index, img_data in enumerate(body[4][0][12][7][0]):
696
+ try:
697
+ img_url = img_data[0][3][3]
698
+ img_title = f"[Generated Image {img_index + 1}]"
699
+ img_alt = (
700
+ img_data[3][5][0]
701
+ if len(img_data[3]) > 5 and len(img_data[3][5]) > 0
702
+ else ""
703
+ )
704
+ generated_images.append(
705
+ {"url": img_url, "alt": img_alt, "title": img_title}
706
+ )
707
+ except (IndexError, TypeError):
708
+ continue
709
+
710
+ if not generated_images:
711
+ for part_index, part in enumerate(response_json):
712
+ if part_index <= body_index:
713
+ continue
714
+ try:
715
+ img_part = json.loads(part[2])
716
+ if img_part[4][0][12][7][0]:
717
+ for img_index, img_data in enumerate(
718
+ img_part[4][0][12][7][0]
719
+ ):
720
+ try:
721
+ img_url = img_data[0][3][3]
722
+ img_title = f"[Generated Image {img_index + 1}]"
723
+ img_alt = (
724
+ img_data[3][5][0]
725
+ if len(img_data[3]) > 5
726
+ and len(img_data[3][5]) > 0
727
+ else ""
728
+ )
729
+ generated_images.append(
730
+ {
731
+ "url": img_url,
732
+ "alt": img_alt,
733
+ "title": img_title,
734
+ }
735
+ )
736
+ except (IndexError, TypeError):
737
+ continue
738
+ break
739
+ except (IndexError, TypeError, json.JSONDecodeError):
740
+ continue
741
+ except (IndexError, TypeError):
742
+ pass
743
+
744
+ if len(generated_images) == 0 and len(body) > 4 and len(body[4]) > 0:
745
+ try:
746
+ candidate = body[4][0]
747
+ if len(candidate) > 22 and candidate[22]:
748
+ import re
749
+
750
+ content = (
751
+ candidate[22][0]
752
+ if isinstance(candidate[22], list) and len(candidate[22]) > 0
753
+ else str(candidate[22])
754
+ )
755
+ urls = re.findall(r"https?://[^\s]+", content)
756
+ for i, url in enumerate(urls):
757
+ if url[-1] in [".", ",", ")", "]", "}", '"', "'"]:
758
+ url = url[:-1]
759
+ generated_images.append(
760
+ {"url": url, "title": f"[Generated Image {i + 1}]", "alt": ""}
761
+ )
762
+ except (IndexError, TypeError) as e:
763
+ console.log(
764
+ f"[yellow]Warning: Could not parse alternative image structure: {e}[/yellow]"
765
+ )
766
+
767
+ if len(images) == 0 and len(generated_images) == 0 and content:
768
+ try:
769
+ import re
770
+
771
+ urls = re.findall(
772
+ r"(https?://[^\s]+\.(jpg|jpeg|png|gif|webp))", content.lower()
773
+ )
774
+
775
+ google_urls = re.findall(
776
+ r"(https?://lh\d+\.googleusercontent\.com/[^\s]+)", content
777
+ )
778
+
779
+ general_urls = re.findall(r"(https?://[^\s]+)", content)
780
+
781
+ all_urls = []
782
+ if urls:
783
+ all_urls.extend([url_tuple[0] for url_tuple in urls])
784
+ if google_urls:
785
+ all_urls.extend(google_urls)
786
+
787
+ if not all_urls and general_urls:
788
+ all_urls = general_urls
789
+
790
+ if all_urls:
791
+ for i, url in enumerate(all_urls):
792
+ if url[-1] in [".", ",", ")", "]", "}", '"', "'"]:
793
+ url = url[:-1]
794
+ images.append(
795
+ {"url": url, "title": f"[Image in Content {i + 1}]", "alt": ""}
796
+ )
797
+ console.log(
798
+ f"[green]Found {len(all_urls)} potential image URLs in content.[/green]"
799
+ )
800
+ except Exception as e:
801
+ console.log(
802
+ f"[yellow]Warning: Error extracting URLs from content: {e}[/yellow]"
803
+ )
804
+
805
+ all_images = images + generated_images
806
+
807
+ results: AskResponse = {
808
+ "content": content,
809
+ "conversation_id": conversation_id,
810
+ "response_id": response_id,
811
+ "factualityQueries": factualityQueries,
812
+ "textQuery": textQuery,
813
+ "choices": choices,
814
+ "images": all_images,
815
+ "error": False,
816
+ }
817
+
818
+ self.conversation_id = conversation_id
819
+ self.response_id = response_id
820
+ self.choice_id = choice_id
821
+ self._reqid += random.randint(1000, 9000)
822
+
823
+ return results
824
+
825
+ except (IndexError, TypeError) as e:
826
+ console.log(f"[red]Error extracting data from response: {e}[/red]")
827
+ return self._error_response(f"Error extracting data from response: {e}")
828
+
829
+ except json.JSONDecodeError as e:
830
+ console.log(f"[red]Error parsing JSON response: {e}[/red]")
831
+ resp_text = resp.text[:200] if resp else "No response"
832
+ return self._error_response(
833
+ f"Error parsing JSON response: {e}. Response: {resp_text}..."
834
+ )
835
+ except Timeout as e:
836
+ console.log(f"[red]Request timed out: {e}[/red]")
837
+ return self._error_response(f"Request timed out: {e}")
838
+ except HTTPError as e:
839
+ console.log(f"[red]HTTP error {e.response.status_code}: {e}[/red]")
840
+ return self._error_response(f"HTTP error {e.response.status_code}: {e}")
841
+ except (RequestException, CurlError) as e:
842
+ console.log(f"[red]Network error: {e}[/red]")
843
+ return self._error_response(f"Network error: {e}")
844
+ except Exception as e:
845
+ console.log(
846
+ f"[red]An unexpected error occurred during ask: {e}[/red]", style="bold red"
847
+ )
848
+ return self._error_response(f"An unexpected error occurred: {e}")
849
+
850
+
851
+ class Image(BaseModel):
852
+ """
853
+ Represents a single image object returned from Gemini.
854
+
855
+ Attributes:
856
+ url (str): URL of the image.
857
+ title (str): Title of the image (default: "[Image]").
858
+ alt (str): Optional description of the image.
859
+ proxy (str | dict | None): Proxy used when saving the image.
860
+ impersonate (str): Browser profile for curl_cffi to impersonate.
861
+ """
862
+
863
+ url: str
864
+ title: str = "[Image]"
865
+ alt: str = ""
866
+ proxy: Optional[Union[str, Dict[str, str]]] = None
867
+ impersonate: str = "chrome110"
868
+
869
+ def __str__(self) -> str:
870
+ return f"{self.title}({self.url}) - {self.alt}"
871
+
872
+ def __repr__(self) -> Any:
873
+ short_url = self.url if len(self.url) <= 50 else self.url[:20] + "..." + self.url[-20:]
874
+ short_alt = self.alt[:30] + "..." if len(self.alt) > 30 else self.alt
875
+ return f"Image(title='{self.title}', url='{short_url}', alt='{short_alt}')"
876
+
877
+ async def save(
878
+ self,
879
+ path: str = "downloaded_images",
880
+ filename: Optional[str] = None,
881
+ cookies: Optional[Dict[str, str]] = None,
882
+ verbose: bool = False,
883
+ skip_invalid_filename: bool = True,
884
+ ) -> Optional[str]:
885
+ """
886
+ Save the image to disk using curl_cffi.
887
+ Parameters:
888
+ path: str, optional
889
+ Directory to save the image (default "downloaded_images").
890
+ filename: str, optional
891
+ Filename to use; if not provided, inferred from URL.
892
+ cookies: dict, optional
893
+ Cookies used for the image request.
894
+ verbose: bool, optional
895
+ If True, outputs status messages (default False).
896
+ skip_invalid_filename: bool, optional
897
+ If True, skips saving if the filename is invalid.
898
+ Returns:
899
+ Absolute path of the saved image if successful; None if skipped.
900
+ Raises:
901
+ HTTPError if the network request fails.
902
+ RequestException/CurlError for other network errors.
903
+ IOError if file writing fails.
904
+ """
905
+ if not filename:
906
+ try:
907
+ from urllib.parse import unquote, urlparse
908
+
909
+ parsed_url = urlparse(self.url)
910
+ base_filename = os.path.basename(unquote(parsed_url.path))
911
+ safe_filename = re.sub(r'[<>:"/\\|?*]', "_", base_filename)
912
+ if safe_filename and len(safe_filename) > 0:
913
+ filename = safe_filename
914
+ else:
915
+ filename = f"image_{random.randint(1000, 9999)}.jpg"
916
+ except Exception:
917
+ filename = f"image_{random.randint(1000, 9999)}.jpg"
918
+
919
+ try:
920
+ _ = Path(filename)
921
+ max_len = 255
922
+ if len(filename) > max_len:
923
+ name, ext = os.path.splitext(filename)
924
+ filename = name[: max_len - len(ext) - 1] + ext
925
+ except (OSError, ValueError):
926
+ if verbose:
927
+ console.log(f"[yellow]Invalid filename generated: {filename}[/yellow]")
928
+ if skip_invalid_filename:
929
+ if verbose:
930
+ console.log("[yellow]Skipping save due to invalid filename.[/yellow]")
931
+ return None
932
+ filename = f"image_{random.randint(1000, 9999)}.jpg"
933
+ if verbose:
934
+ console.log(f"[yellow]Using fallback filename: {filename}[/yellow]")
935
+
936
+ proxies_dict = None
937
+ if isinstance(self.proxy, str):
938
+ proxies_dict = {"http": self.proxy, "https": self.proxy}
939
+ elif isinstance(self.proxy, dict):
940
+ proxies_dict = self.proxy
941
+
942
+ dest = None
943
+ try:
944
+ async with AsyncSession(
945
+ cookies=cookies,
946
+ proxies=cast(Any, proxies_dict),
947
+ impersonate=cast(Any, self.impersonate),
948
+ ) as client:
949
+ if verbose:
950
+ console.log(f"Attempting to download image from: {self.url}")
951
+
952
+ response = await client.get(self.url)
953
+ response.raise_for_status()
954
+
955
+ content_type = response.headers.get("content-type", "").lower()
956
+ if "image" not in content_type and verbose:
957
+ console.log(
958
+ f"[yellow]Warning: Content type is '{content_type}', not an image. Saving anyway.[/yellow]"
959
+ )
960
+
961
+ dest_path = Path(path)
962
+ dest_path.mkdir(parents=True, exist_ok=True)
963
+ dest = dest_path / filename
964
+
965
+ dest.write_bytes(response.content)
966
+
967
+ if verbose:
968
+ console.log(f"Image saved successfully as {dest.resolve()}")
969
+
970
+ return str(dest.resolve())
971
+
972
+ except HTTPError as e:
973
+ console.log(
974
+ f"[red]Error downloading image {self.url}: {e.response.status_code} {e}[/red]"
975
+ )
976
+ raise
977
+ except (RequestException, CurlError) as e:
978
+ console.log(f"[red]Network error downloading image {self.url}: {e}[/red]")
979
+ raise
980
+ except IOError as e:
981
+ console.log(f"[red]Error writing image file to {dest}: {e}[/red]")
982
+ raise
983
+ except Exception as e:
984
+ console.log(f"[red]An unexpected error occurred during image save: {e}[/red]")
985
+ raise
986
+
987
+
988
+ class WebImage(Image):
989
+ """
990
+ Represents an image retrieved from web search results.
991
+
992
+ Returned when asking Gemini to "SEND an image of [something]".
993
+ """
994
+
995
+ async def save(
996
+ self,
997
+ path: str = "downloaded_images",
998
+ filename: Optional[str] = None,
999
+ cookies: Optional[Dict[str, str]] = None,
1000
+ verbose: bool = False,
1001
+ skip_invalid_filename: bool = True,
1002
+ ) -> Optional[str]:
1003
+ """
1004
+ Save the image to disk using curl_cffi.
1005
+ Parameters:
1006
+ path: str, optional
1007
+ Directory to save the image (default "downloaded_images").
1008
+ filename: str, optional
1009
+ Filename to use; if not provided, inferred from URL.
1010
+ cookies: dict, optional
1011
+ Cookies used for the image request.
1012
+ verbose: bool, optional
1013
+ If True, outputs status messages (default False).
1014
+ skip_invalid_filename: bool, optional
1015
+ If True, skips saving if the filename is invalid.
1016
+ Returns:
1017
+ Absolute path of the saved image if successful; None if skipped.
1018
+ Raises:
1019
+ HTTPError if the network request fails.
1020
+ RequestException/CurlError for other network errors.
1021
+ IOError if file writing fails.
1022
+ """
1023
+ return await super().save(path, filename, cookies, verbose, skip_invalid_filename)
1024
+
1025
+
1026
+ class GeneratedImage(Image):
1027
+ """
1028
+ Represents an image generated by Google's AI image generator (e.g., ImageFX).
1029
+
1030
+ Attributes:
1031
+ cookies (dict[str, str]): Cookies required for accessing the generated image URL,
1032
+ typically from the GeminiClient/Chatbot instance.
1033
+ """
1034
+
1035
+ cookies: Dict[str, str]
1036
+
1037
+ @field_validator("cookies")
1038
+ @classmethod
1039
+ def validate_cookies(cls, v: Dict[str, str]) -> Dict[str, str]:
1040
+ """Ensures cookies are provided for generated images."""
1041
+ if not v or not isinstance(v, dict):
1042
+ raise ValueError("GeneratedImage requires a dictionary of cookies from the client.")
1043
+ return v
1044
+
1045
+ async def save(
1046
+ self,
1047
+ path: str = "downloaded_images",
1048
+ filename: Optional[str] = None,
1049
+ cookies: Optional[Dict[str, str]] = None,
1050
+ verbose: bool = False,
1051
+ skip_invalid_filename: bool = True,
1052
+ **kwargs,
1053
+ ) -> Optional[str]:
1054
+ """
1055
+ Save the generated image to disk.
1056
+ Parameters:
1057
+ filename: str, optional
1058
+ Filename to use. If not provided, a default name including
1059
+ a timestamp and part of the URL is used. Generated images
1060
+ are often in .png or .jpg format.
1061
+ Additional arguments are passed to Image.save.
1062
+ Returns:
1063
+ Absolute path of the saved image if successful, None if skipped.
1064
+ """
1065
+ if filename is None:
1066
+ ext = ".jpg" if ".jpg" in self.url.lower() else ".png"
1067
+ url_part = self.url.split("/")[-1][:10]
1068
+ filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{url_part}{ext}"
1069
+
1070
+ return await super().save(
1071
+ path, filename, cookies or self.cookies, verbose, skip_invalid_filename
1072
+ )