webscout 8.3.7__py3-none-any.whl → 2025.10.13__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 (306) hide show
  1. webscout/AIauto.py +250 -250
  2. webscout/AIbase.py +379 -379
  3. webscout/AIutel.py +60 -60
  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 +16 -1
  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 -316
  61. webscout/Provider/AISEARCH/stellar_search.py +177 -177
  62. webscout/Provider/AISEARCH/webpilotai_search.py +255 -255
  63. webscout/Provider/Aitopia.py +314 -314
  64. webscout/Provider/Andi.py +1 -1
  65. webscout/Provider/Apriel.py +306 -0
  66. webscout/Provider/ChatGPTClone.py +237 -236
  67. webscout/Provider/ChatSandbox.py +343 -343
  68. webscout/Provider/Cloudflare.py +324 -324
  69. webscout/Provider/Cohere.py +208 -208
  70. webscout/Provider/Deepinfra.py +370 -366
  71. webscout/Provider/ExaAI.py +260 -260
  72. webscout/Provider/ExaChat.py +308 -308
  73. webscout/Provider/Flowith.py +221 -221
  74. webscout/Provider/GMI.py +293 -0
  75. webscout/Provider/Gemini.py +164 -164
  76. webscout/Provider/GeminiProxy.py +167 -167
  77. webscout/Provider/GithubChat.py +371 -372
  78. webscout/Provider/Groq.py +800 -800
  79. webscout/Provider/HeckAI.py +383 -383
  80. webscout/Provider/Jadve.py +282 -282
  81. webscout/Provider/K2Think.py +307 -307
  82. webscout/Provider/Koboldai.py +205 -205
  83. webscout/Provider/LambdaChat.py +423 -423
  84. webscout/Provider/Nemotron.py +244 -244
  85. webscout/Provider/Netwrck.py +248 -248
  86. webscout/Provider/OLLAMA.py +395 -395
  87. webscout/Provider/OPENAI/Cloudflare.py +393 -393
  88. webscout/Provider/OPENAI/FalconH1.py +451 -451
  89. webscout/Provider/OPENAI/FreeGemini.py +296 -296
  90. webscout/Provider/OPENAI/K2Think.py +431 -431
  91. webscout/Provider/OPENAI/NEMOTRON.py +240 -240
  92. webscout/Provider/OPENAI/PI.py +427 -427
  93. webscout/Provider/OPENAI/README.md +959 -959
  94. webscout/Provider/OPENAI/TogetherAI.py +345 -345
  95. webscout/Provider/OPENAI/TwoAI.py +465 -465
  96. webscout/Provider/OPENAI/__init__.py +33 -18
  97. webscout/Provider/OPENAI/base.py +248 -248
  98. webscout/Provider/OPENAI/chatglm.py +528 -0
  99. webscout/Provider/OPENAI/chatgpt.py +592 -592
  100. webscout/Provider/OPENAI/chatgptclone.py +521 -521
  101. webscout/Provider/OPENAI/chatsandbox.py +202 -202
  102. webscout/Provider/OPENAI/deepinfra.py +318 -314
  103. webscout/Provider/OPENAI/e2b.py +1665 -1665
  104. webscout/Provider/OPENAI/exaai.py +420 -420
  105. webscout/Provider/OPENAI/exachat.py +452 -452
  106. webscout/Provider/OPENAI/friendli.py +232 -232
  107. webscout/Provider/OPENAI/{refact.py → gmi.py} +324 -274
  108. webscout/Provider/OPENAI/groq.py +364 -364
  109. webscout/Provider/OPENAI/heckai.py +314 -314
  110. webscout/Provider/OPENAI/llmchatco.py +337 -337
  111. webscout/Provider/OPENAI/netwrck.py +355 -355
  112. webscout/Provider/OPENAI/oivscode.py +290 -290
  113. webscout/Provider/OPENAI/opkfc.py +518 -518
  114. webscout/Provider/OPENAI/pydantic_imports.py +1 -1
  115. webscout/Provider/OPENAI/scirachat.py +535 -535
  116. webscout/Provider/OPENAI/sonus.py +308 -308
  117. webscout/Provider/OPENAI/standardinput.py +442 -442
  118. webscout/Provider/OPENAI/textpollinations.py +340 -340
  119. webscout/Provider/OPENAI/toolbaz.py +419 -416
  120. webscout/Provider/OPENAI/typefully.py +362 -362
  121. webscout/Provider/OPENAI/utils.py +295 -295
  122. webscout/Provider/OPENAI/venice.py +436 -436
  123. webscout/Provider/OPENAI/wisecat.py +387 -387
  124. webscout/Provider/OPENAI/writecream.py +166 -166
  125. webscout/Provider/OPENAI/x0gpt.py +378 -378
  126. webscout/Provider/OPENAI/yep.py +389 -389
  127. webscout/Provider/OpenGPT.py +230 -230
  128. webscout/Provider/Openai.py +243 -243
  129. webscout/Provider/PI.py +405 -405
  130. webscout/Provider/Perplexitylabs.py +430 -430
  131. webscout/Provider/QwenLM.py +272 -272
  132. webscout/Provider/STT/__init__.py +16 -1
  133. webscout/Provider/Sambanova.py +257 -257
  134. webscout/Provider/StandardInput.py +309 -309
  135. webscout/Provider/TTI/README.md +82 -82
  136. webscout/Provider/TTI/__init__.py +33 -18
  137. webscout/Provider/TTI/aiarta.py +413 -413
  138. webscout/Provider/TTI/base.py +136 -136
  139. webscout/Provider/TTI/bing.py +243 -243
  140. webscout/Provider/TTI/gpt1image.py +149 -149
  141. webscout/Provider/TTI/imagen.py +196 -196
  142. webscout/Provider/TTI/infip.py +211 -211
  143. webscout/Provider/TTI/magicstudio.py +232 -232
  144. webscout/Provider/TTI/monochat.py +219 -219
  145. webscout/Provider/TTI/piclumen.py +214 -214
  146. webscout/Provider/TTI/pixelmuse.py +232 -232
  147. webscout/Provider/TTI/pollinations.py +232 -232
  148. webscout/Provider/TTI/together.py +288 -288
  149. webscout/Provider/TTI/utils.py +12 -12
  150. webscout/Provider/TTI/venice.py +367 -367
  151. webscout/Provider/TTS/README.md +192 -192
  152. webscout/Provider/TTS/__init__.py +33 -18
  153. webscout/Provider/TTS/parler.py +110 -110
  154. webscout/Provider/TTS/streamElements.py +333 -333
  155. webscout/Provider/TTS/utils.py +280 -280
  156. webscout/Provider/TeachAnything.py +237 -237
  157. webscout/Provider/TextPollinationsAI.py +310 -310
  158. webscout/Provider/TogetherAI.py +356 -356
  159. webscout/Provider/TwoAI.py +312 -312
  160. webscout/Provider/TypliAI.py +311 -311
  161. webscout/Provider/UNFINISHED/ChatHub.py +208 -208
  162. webscout/Provider/UNFINISHED/ChutesAI.py +313 -313
  163. webscout/Provider/UNFINISHED/GizAI.py +294 -294
  164. webscout/Provider/UNFINISHED/Marcus.py +198 -198
  165. webscout/Provider/UNFINISHED/Qodo.py +477 -477
  166. webscout/Provider/UNFINISHED/VercelAIGateway.py +338 -338
  167. webscout/Provider/UNFINISHED/XenAI.py +324 -324
  168. webscout/Provider/UNFINISHED/Youchat.py +330 -330
  169. webscout/Provider/UNFINISHED/liner.py +334 -0
  170. webscout/Provider/UNFINISHED/liner_api_request.py +262 -262
  171. webscout/Provider/UNFINISHED/puterjs.py +634 -634
  172. webscout/Provider/UNFINISHED/samurai.py +223 -223
  173. webscout/Provider/UNFINISHED/test_lmarena.py +119 -119
  174. webscout/Provider/Venice.py +250 -250
  175. webscout/Provider/VercelAI.py +256 -256
  176. webscout/Provider/WiseCat.py +231 -231
  177. webscout/Provider/WrDoChat.py +366 -366
  178. webscout/Provider/__init__.py +33 -18
  179. webscout/Provider/ai4chat.py +174 -174
  180. webscout/Provider/akashgpt.py +331 -331
  181. webscout/Provider/cerebras.py +446 -446
  182. webscout/Provider/chatglm.py +394 -301
  183. webscout/Provider/cleeai.py +211 -211
  184. webscout/Provider/elmo.py +282 -282
  185. webscout/Provider/geminiapi.py +208 -208
  186. webscout/Provider/granite.py +261 -261
  187. webscout/Provider/hermes.py +263 -263
  188. webscout/Provider/julius.py +223 -223
  189. webscout/Provider/learnfastai.py +309 -309
  190. webscout/Provider/llama3mitril.py +214 -214
  191. webscout/Provider/llmchat.py +243 -243
  192. webscout/Provider/llmchatco.py +290 -290
  193. webscout/Provider/meta.py +801 -801
  194. webscout/Provider/oivscode.py +309 -309
  195. webscout/Provider/scira_chat.py +383 -383
  196. webscout/Provider/searchchat.py +292 -292
  197. webscout/Provider/sonus.py +258 -258
  198. webscout/Provider/toolbaz.py +370 -367
  199. webscout/Provider/turboseek.py +273 -273
  200. webscout/Provider/typefully.py +207 -207
  201. webscout/Provider/yep.py +372 -372
  202. webscout/__init__.py +27 -31
  203. webscout/__main__.py +5 -5
  204. webscout/auth/api_key_manager.py +189 -189
  205. webscout/auth/config.py +175 -175
  206. webscout/auth/models.py +185 -185
  207. webscout/auth/routes.py +663 -664
  208. webscout/auth/simple_logger.py +236 -236
  209. webscout/cli.py +523 -523
  210. webscout/conversation.py +438 -438
  211. webscout/exceptions.py +361 -361
  212. webscout/litagent/Readme.md +298 -298
  213. webscout/litagent/__init__.py +28 -28
  214. webscout/litagent/agent.py +581 -581
  215. webscout/litagent/constants.py +59 -59
  216. webscout/litprinter/__init__.py +58 -58
  217. webscout/models.py +181 -181
  218. webscout/optimizers.py +419 -419
  219. webscout/prompt_manager.py +288 -288
  220. webscout/sanitize.py +1078 -1078
  221. webscout/scout/README.md +401 -401
  222. webscout/scout/__init__.py +8 -8
  223. webscout/scout/core/__init__.py +6 -6
  224. webscout/scout/core/crawler.py +297 -297
  225. webscout/scout/core/scout.py +706 -706
  226. webscout/scout/core/search_result.py +95 -95
  227. webscout/scout/core/text_analyzer.py +62 -62
  228. webscout/scout/core/text_utils.py +277 -277
  229. webscout/scout/core/web_analyzer.py +51 -51
  230. webscout/scout/element.py +599 -599
  231. webscout/scout/parsers/__init__.py +69 -69
  232. webscout/scout/parsers/html5lib_parser.py +172 -172
  233. webscout/scout/parsers/html_parser.py +236 -236
  234. webscout/scout/parsers/lxml_parser.py +178 -178
  235. webscout/scout/utils.py +37 -37
  236. webscout/search/__init__.py +51 -0
  237. webscout/search/base.py +195 -0
  238. webscout/search/duckduckgo_main.py +54 -0
  239. webscout/search/engines/__init__.py +48 -0
  240. webscout/search/engines/bing.py +84 -0
  241. webscout/search/engines/bing_news.py +52 -0
  242. webscout/search/engines/brave.py +43 -0
  243. webscout/search/engines/duckduckgo/__init__.py +25 -0
  244. webscout/search/engines/duckduckgo/answers.py +78 -0
  245. webscout/search/engines/duckduckgo/base.py +187 -0
  246. webscout/search/engines/duckduckgo/images.py +97 -0
  247. webscout/search/engines/duckduckgo/maps.py +168 -0
  248. webscout/search/engines/duckduckgo/news.py +68 -0
  249. webscout/search/engines/duckduckgo/suggestions.py +21 -0
  250. webscout/search/engines/duckduckgo/text.py +211 -0
  251. webscout/search/engines/duckduckgo/translate.py +47 -0
  252. webscout/search/engines/duckduckgo/videos.py +63 -0
  253. webscout/search/engines/duckduckgo/weather.py +74 -0
  254. webscout/search/engines/mojeek.py +37 -0
  255. webscout/search/engines/wikipedia.py +56 -0
  256. webscout/search/engines/yahoo.py +65 -0
  257. webscout/search/engines/yahoo_news.py +64 -0
  258. webscout/search/engines/yandex.py +43 -0
  259. webscout/search/engines/yep/__init__.py +13 -0
  260. webscout/search/engines/yep/base.py +32 -0
  261. webscout/search/engines/yep/images.py +99 -0
  262. webscout/search/engines/yep/suggestions.py +35 -0
  263. webscout/search/engines/yep/text.py +114 -0
  264. webscout/search/http_client.py +156 -0
  265. webscout/search/results.py +137 -0
  266. webscout/search/yep_main.py +44 -0
  267. webscout/swiftcli/Readme.md +323 -323
  268. webscout/swiftcli/__init__.py +95 -95
  269. webscout/swiftcli/core/__init__.py +7 -7
  270. webscout/swiftcli/core/cli.py +308 -308
  271. webscout/swiftcli/core/context.py +104 -104
  272. webscout/swiftcli/core/group.py +241 -241
  273. webscout/swiftcli/decorators/__init__.py +28 -28
  274. webscout/swiftcli/decorators/command.py +221 -221
  275. webscout/swiftcli/decorators/options.py +220 -220
  276. webscout/swiftcli/decorators/output.py +302 -302
  277. webscout/swiftcli/exceptions.py +21 -21
  278. webscout/swiftcli/plugins/__init__.py +9 -9
  279. webscout/swiftcli/plugins/base.py +135 -135
  280. webscout/swiftcli/plugins/manager.py +269 -269
  281. webscout/swiftcli/utils/__init__.py +59 -59
  282. webscout/swiftcli/utils/formatting.py +252 -252
  283. webscout/swiftcli/utils/parsing.py +267 -267
  284. webscout/update_checker.py +117 -117
  285. webscout/version.py +1 -1
  286. webscout/version.py.bak +2 -0
  287. webscout/zeroart/README.md +89 -89
  288. webscout/zeroart/__init__.py +134 -134
  289. webscout/zeroart/base.py +66 -66
  290. webscout/zeroart/effects.py +100 -100
  291. webscout/zeroart/fonts.py +1238 -1238
  292. {webscout-8.3.7.dist-info → webscout-2025.10.13.dist-info}/METADATA +936 -937
  293. webscout-2025.10.13.dist-info/RECORD +329 -0
  294. webscout/Provider/AISEARCH/DeepFind.py +0 -254
  295. webscout/Provider/OPENAI/Qwen3.py +0 -303
  296. webscout/Provider/OPENAI/qodo.py +0 -630
  297. webscout/Provider/OPENAI/xenai.py +0 -514
  298. webscout/tempid.py +0 -134
  299. webscout/webscout_search.py +0 -1183
  300. webscout/webscout_search_async.py +0 -649
  301. webscout/yep_search.py +0 -346
  302. webscout-8.3.7.dist-info/RECORD +0 -301
  303. {webscout-8.3.7.dist-info → webscout-2025.10.13.dist-info}/WHEEL +0 -0
  304. {webscout-8.3.7.dist-info → webscout-2025.10.13.dist-info}/entry_points.txt +0 -0
  305. {webscout-8.3.7.dist-info → webscout-2025.10.13.dist-info}/licenses/LICENSE.md +0 -0
  306. {webscout-8.3.7.dist-info → webscout-2025.10.13.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)