webscout 8.3.6__py3-none-any.whl → 2025.10.11__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.

Potentially problematic release.


This version of webscout might be problematic. Click here for more details.

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