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.
- webscout/AIauto.py +250 -250
- webscout/AIbase.py +379 -379
- webscout/AIutel.py +60 -60
- webscout/Bard.py +1012 -1012
- webscout/Bing_search.py +417 -417
- webscout/DWEBS.py +529 -529
- webscout/Extra/Act.md +309 -309
- webscout/Extra/GitToolkit/__init__.py +10 -10
- webscout/Extra/GitToolkit/gitapi/README.md +110 -110
- webscout/Extra/GitToolkit/gitapi/__init__.py +11 -11
- webscout/Extra/GitToolkit/gitapi/repository.py +195 -195
- webscout/Extra/GitToolkit/gitapi/user.py +96 -96
- webscout/Extra/GitToolkit/gitapi/utils.py +61 -61
- webscout/Extra/YTToolkit/README.md +375 -375
- webscout/Extra/YTToolkit/YTdownloader.py +956 -956
- webscout/Extra/YTToolkit/__init__.py +2 -2
- webscout/Extra/YTToolkit/transcriber.py +475 -475
- webscout/Extra/YTToolkit/ytapi/README.md +44 -44
- webscout/Extra/YTToolkit/ytapi/__init__.py +6 -6
- webscout/Extra/YTToolkit/ytapi/channel.py +307 -307
- webscout/Extra/YTToolkit/ytapi/errors.py +13 -13
- webscout/Extra/YTToolkit/ytapi/extras.py +118 -118
- webscout/Extra/YTToolkit/ytapi/https.py +88 -88
- webscout/Extra/YTToolkit/ytapi/patterns.py +61 -61
- webscout/Extra/YTToolkit/ytapi/playlist.py +58 -58
- webscout/Extra/YTToolkit/ytapi/pool.py +7 -7
- webscout/Extra/YTToolkit/ytapi/query.py +39 -39
- webscout/Extra/YTToolkit/ytapi/stream.py +62 -62
- webscout/Extra/YTToolkit/ytapi/utils.py +62 -62
- webscout/Extra/YTToolkit/ytapi/video.py +232 -232
- webscout/Extra/autocoder/__init__.py +9 -9
- webscout/Extra/autocoder/autocoder.py +1105 -1105
- webscout/Extra/autocoder/autocoder_utiles.py +332 -332
- webscout/Extra/gguf.md +429 -429
- webscout/Extra/gguf.py +1213 -1213
- webscout/Extra/tempmail/README.md +487 -487
- webscout/Extra/tempmail/__init__.py +27 -27
- webscout/Extra/tempmail/async_utils.py +140 -140
- webscout/Extra/tempmail/base.py +160 -160
- webscout/Extra/tempmail/cli.py +186 -186
- webscout/Extra/tempmail/emailnator.py +84 -84
- webscout/Extra/tempmail/mail_tm.py +360 -360
- webscout/Extra/tempmail/temp_mail_io.py +291 -291
- webscout/Extra/weather.md +281 -281
- webscout/Extra/weather.py +193 -193
- webscout/Litlogger/README.md +10 -10
- webscout/Litlogger/__init__.py +15 -15
- webscout/Litlogger/formats.py +13 -13
- webscout/Litlogger/handlers.py +121 -121
- webscout/Litlogger/levels.py +13 -13
- webscout/Litlogger/logger.py +134 -134
- webscout/Provider/AISEARCH/Perplexity.py +332 -332
- webscout/Provider/AISEARCH/README.md +279 -279
- webscout/Provider/AISEARCH/__init__.py +16 -1
- webscout/Provider/AISEARCH/felo_search.py +206 -206
- webscout/Provider/AISEARCH/genspark_search.py +323 -323
- webscout/Provider/AISEARCH/hika_search.py +185 -185
- webscout/Provider/AISEARCH/iask_search.py +410 -410
- webscout/Provider/AISEARCH/monica_search.py +219 -219
- webscout/Provider/AISEARCH/scira_search.py +316 -316
- webscout/Provider/AISEARCH/stellar_search.py +177 -177
- webscout/Provider/AISEARCH/webpilotai_search.py +255 -255
- webscout/Provider/Aitopia.py +314 -314
- webscout/Provider/Andi.py +1 -1
- webscout/Provider/Apriel.py +306 -0
- webscout/Provider/ChatGPTClone.py +237 -236
- webscout/Provider/ChatSandbox.py +343 -343
- webscout/Provider/Cloudflare.py +324 -324
- webscout/Provider/Cohere.py +208 -208
- webscout/Provider/Deepinfra.py +370 -366
- webscout/Provider/ExaAI.py +260 -260
- webscout/Provider/ExaChat.py +308 -308
- webscout/Provider/Flowith.py +221 -221
- webscout/Provider/GMI.py +293 -0
- webscout/Provider/Gemini.py +164 -164
- webscout/Provider/GeminiProxy.py +167 -167
- webscout/Provider/GithubChat.py +371 -372
- webscout/Provider/Groq.py +800 -800
- webscout/Provider/HeckAI.py +383 -383
- webscout/Provider/Jadve.py +282 -282
- webscout/Provider/K2Think.py +307 -307
- webscout/Provider/Koboldai.py +205 -205
- webscout/Provider/LambdaChat.py +423 -423
- webscout/Provider/Nemotron.py +244 -244
- webscout/Provider/Netwrck.py +248 -248
- webscout/Provider/OLLAMA.py +395 -395
- webscout/Provider/OPENAI/Cloudflare.py +393 -393
- webscout/Provider/OPENAI/FalconH1.py +451 -451
- webscout/Provider/OPENAI/FreeGemini.py +296 -296
- webscout/Provider/OPENAI/K2Think.py +431 -431
- webscout/Provider/OPENAI/NEMOTRON.py +240 -240
- webscout/Provider/OPENAI/PI.py +427 -427
- webscout/Provider/OPENAI/README.md +959 -959
- webscout/Provider/OPENAI/TogetherAI.py +345 -345
- webscout/Provider/OPENAI/TwoAI.py +465 -465
- webscout/Provider/OPENAI/__init__.py +33 -18
- webscout/Provider/OPENAI/base.py +248 -248
- webscout/Provider/OPENAI/chatglm.py +528 -0
- webscout/Provider/OPENAI/chatgpt.py +592 -592
- webscout/Provider/OPENAI/chatgptclone.py +521 -521
- webscout/Provider/OPENAI/chatsandbox.py +202 -202
- webscout/Provider/OPENAI/deepinfra.py +318 -314
- webscout/Provider/OPENAI/e2b.py +1665 -1665
- webscout/Provider/OPENAI/exaai.py +420 -420
- webscout/Provider/OPENAI/exachat.py +452 -452
- webscout/Provider/OPENAI/friendli.py +232 -232
- webscout/Provider/OPENAI/{refact.py → gmi.py} +324 -274
- webscout/Provider/OPENAI/groq.py +364 -364
- webscout/Provider/OPENAI/heckai.py +314 -314
- webscout/Provider/OPENAI/llmchatco.py +337 -337
- webscout/Provider/OPENAI/netwrck.py +355 -355
- webscout/Provider/OPENAI/oivscode.py +290 -290
- webscout/Provider/OPENAI/opkfc.py +518 -518
- webscout/Provider/OPENAI/pydantic_imports.py +1 -1
- webscout/Provider/OPENAI/scirachat.py +535 -535
- webscout/Provider/OPENAI/sonus.py +308 -308
- webscout/Provider/OPENAI/standardinput.py +442 -442
- webscout/Provider/OPENAI/textpollinations.py +340 -340
- webscout/Provider/OPENAI/toolbaz.py +419 -416
- webscout/Provider/OPENAI/typefully.py +362 -362
- webscout/Provider/OPENAI/utils.py +295 -295
- webscout/Provider/OPENAI/venice.py +436 -436
- webscout/Provider/OPENAI/wisecat.py +387 -387
- webscout/Provider/OPENAI/writecream.py +166 -166
- webscout/Provider/OPENAI/x0gpt.py +378 -378
- webscout/Provider/OPENAI/yep.py +389 -389
- webscout/Provider/OpenGPT.py +230 -230
- webscout/Provider/Openai.py +243 -243
- webscout/Provider/PI.py +405 -405
- webscout/Provider/Perplexitylabs.py +430 -430
- webscout/Provider/QwenLM.py +272 -272
- webscout/Provider/STT/__init__.py +16 -1
- webscout/Provider/Sambanova.py +257 -257
- webscout/Provider/StandardInput.py +309 -309
- webscout/Provider/TTI/README.md +82 -82
- webscout/Provider/TTI/__init__.py +33 -18
- webscout/Provider/TTI/aiarta.py +413 -413
- webscout/Provider/TTI/base.py +136 -136
- webscout/Provider/TTI/bing.py +243 -243
- webscout/Provider/TTI/gpt1image.py +149 -149
- webscout/Provider/TTI/imagen.py +196 -196
- webscout/Provider/TTI/infip.py +211 -211
- webscout/Provider/TTI/magicstudio.py +232 -232
- webscout/Provider/TTI/monochat.py +219 -219
- webscout/Provider/TTI/piclumen.py +214 -214
- webscout/Provider/TTI/pixelmuse.py +232 -232
- webscout/Provider/TTI/pollinations.py +232 -232
- webscout/Provider/TTI/together.py +288 -288
- webscout/Provider/TTI/utils.py +12 -12
- webscout/Provider/TTI/venice.py +367 -367
- webscout/Provider/TTS/README.md +192 -192
- webscout/Provider/TTS/__init__.py +33 -18
- webscout/Provider/TTS/parler.py +110 -110
- webscout/Provider/TTS/streamElements.py +333 -333
- webscout/Provider/TTS/utils.py +280 -280
- webscout/Provider/TeachAnything.py +237 -237
- webscout/Provider/TextPollinationsAI.py +310 -310
- webscout/Provider/TogetherAI.py +356 -356
- webscout/Provider/TwoAI.py +312 -312
- webscout/Provider/TypliAI.py +311 -311
- webscout/Provider/UNFINISHED/ChatHub.py +208 -208
- webscout/Provider/UNFINISHED/ChutesAI.py +313 -313
- webscout/Provider/UNFINISHED/GizAI.py +294 -294
- webscout/Provider/UNFINISHED/Marcus.py +198 -198
- webscout/Provider/UNFINISHED/Qodo.py +477 -477
- webscout/Provider/UNFINISHED/VercelAIGateway.py +338 -338
- webscout/Provider/UNFINISHED/XenAI.py +324 -324
- webscout/Provider/UNFINISHED/Youchat.py +330 -330
- webscout/Provider/UNFINISHED/liner.py +334 -0
- webscout/Provider/UNFINISHED/liner_api_request.py +262 -262
- webscout/Provider/UNFINISHED/puterjs.py +634 -634
- webscout/Provider/UNFINISHED/samurai.py +223 -223
- webscout/Provider/UNFINISHED/test_lmarena.py +119 -119
- webscout/Provider/Venice.py +250 -250
- webscout/Provider/VercelAI.py +256 -256
- webscout/Provider/WiseCat.py +231 -231
- webscout/Provider/WrDoChat.py +366 -366
- webscout/Provider/__init__.py +33 -18
- webscout/Provider/ai4chat.py +174 -174
- webscout/Provider/akashgpt.py +331 -331
- webscout/Provider/cerebras.py +446 -446
- webscout/Provider/chatglm.py +394 -301
- webscout/Provider/cleeai.py +211 -211
- webscout/Provider/elmo.py +282 -282
- webscout/Provider/geminiapi.py +208 -208
- webscout/Provider/granite.py +261 -261
- webscout/Provider/hermes.py +263 -263
- webscout/Provider/julius.py +223 -223
- webscout/Provider/learnfastai.py +309 -309
- webscout/Provider/llama3mitril.py +214 -214
- webscout/Provider/llmchat.py +243 -243
- webscout/Provider/llmchatco.py +290 -290
- webscout/Provider/meta.py +801 -801
- webscout/Provider/oivscode.py +309 -309
- webscout/Provider/scira_chat.py +383 -383
- webscout/Provider/searchchat.py +292 -292
- webscout/Provider/sonus.py +258 -258
- webscout/Provider/toolbaz.py +370 -367
- webscout/Provider/turboseek.py +273 -273
- webscout/Provider/typefully.py +207 -207
- webscout/Provider/yep.py +372 -372
- webscout/__init__.py +27 -31
- webscout/__main__.py +5 -5
- webscout/auth/api_key_manager.py +189 -189
- webscout/auth/config.py +175 -175
- webscout/auth/models.py +185 -185
- webscout/auth/routes.py +663 -664
- webscout/auth/simple_logger.py +236 -236
- webscout/cli.py +523 -523
- webscout/conversation.py +438 -438
- webscout/exceptions.py +361 -361
- webscout/litagent/Readme.md +298 -298
- webscout/litagent/__init__.py +28 -28
- webscout/litagent/agent.py +581 -581
- webscout/litagent/constants.py +59 -59
- webscout/litprinter/__init__.py +58 -58
- webscout/models.py +181 -181
- webscout/optimizers.py +419 -419
- webscout/prompt_manager.py +288 -288
- webscout/sanitize.py +1078 -1078
- webscout/scout/README.md +401 -401
- webscout/scout/__init__.py +8 -8
- webscout/scout/core/__init__.py +6 -6
- webscout/scout/core/crawler.py +297 -297
- webscout/scout/core/scout.py +706 -706
- webscout/scout/core/search_result.py +95 -95
- webscout/scout/core/text_analyzer.py +62 -62
- webscout/scout/core/text_utils.py +277 -277
- webscout/scout/core/web_analyzer.py +51 -51
- webscout/scout/element.py +599 -599
- webscout/scout/parsers/__init__.py +69 -69
- webscout/scout/parsers/html5lib_parser.py +172 -172
- webscout/scout/parsers/html_parser.py +236 -236
- webscout/scout/parsers/lxml_parser.py +178 -178
- webscout/scout/utils.py +37 -37
- webscout/search/__init__.py +51 -0
- webscout/search/base.py +195 -0
- webscout/search/duckduckgo_main.py +54 -0
- webscout/search/engines/__init__.py +48 -0
- webscout/search/engines/bing.py +84 -0
- webscout/search/engines/bing_news.py +52 -0
- webscout/search/engines/brave.py +43 -0
- webscout/search/engines/duckduckgo/__init__.py +25 -0
- webscout/search/engines/duckduckgo/answers.py +78 -0
- webscout/search/engines/duckduckgo/base.py +187 -0
- webscout/search/engines/duckduckgo/images.py +97 -0
- webscout/search/engines/duckduckgo/maps.py +168 -0
- webscout/search/engines/duckduckgo/news.py +68 -0
- webscout/search/engines/duckduckgo/suggestions.py +21 -0
- webscout/search/engines/duckduckgo/text.py +211 -0
- webscout/search/engines/duckduckgo/translate.py +47 -0
- webscout/search/engines/duckduckgo/videos.py +63 -0
- webscout/search/engines/duckduckgo/weather.py +74 -0
- webscout/search/engines/mojeek.py +37 -0
- webscout/search/engines/wikipedia.py +56 -0
- webscout/search/engines/yahoo.py +65 -0
- webscout/search/engines/yahoo_news.py +64 -0
- webscout/search/engines/yandex.py +43 -0
- webscout/search/engines/yep/__init__.py +13 -0
- webscout/search/engines/yep/base.py +32 -0
- webscout/search/engines/yep/images.py +99 -0
- webscout/search/engines/yep/suggestions.py +35 -0
- webscout/search/engines/yep/text.py +114 -0
- webscout/search/http_client.py +156 -0
- webscout/search/results.py +137 -0
- webscout/search/yep_main.py +44 -0
- webscout/swiftcli/Readme.md +323 -323
- webscout/swiftcli/__init__.py +95 -95
- webscout/swiftcli/core/__init__.py +7 -7
- webscout/swiftcli/core/cli.py +308 -308
- webscout/swiftcli/core/context.py +104 -104
- webscout/swiftcli/core/group.py +241 -241
- webscout/swiftcli/decorators/__init__.py +28 -28
- webscout/swiftcli/decorators/command.py +221 -221
- webscout/swiftcli/decorators/options.py +220 -220
- webscout/swiftcli/decorators/output.py +302 -302
- webscout/swiftcli/exceptions.py +21 -21
- webscout/swiftcli/plugins/__init__.py +9 -9
- webscout/swiftcli/plugins/base.py +135 -135
- webscout/swiftcli/plugins/manager.py +269 -269
- webscout/swiftcli/utils/__init__.py +59 -59
- webscout/swiftcli/utils/formatting.py +252 -252
- webscout/swiftcli/utils/parsing.py +267 -267
- webscout/update_checker.py +117 -117
- webscout/version.py +1 -1
- webscout/version.py.bak +2 -0
- webscout/zeroart/README.md +89 -89
- webscout/zeroart/__init__.py +134 -134
- webscout/zeroart/base.py +66 -66
- webscout/zeroart/effects.py +100 -100
- webscout/zeroart/fonts.py +1238 -1238
- {webscout-8.3.7.dist-info → webscout-2025.10.13.dist-info}/METADATA +936 -937
- webscout-2025.10.13.dist-info/RECORD +329 -0
- webscout/Provider/AISEARCH/DeepFind.py +0 -254
- webscout/Provider/OPENAI/Qwen3.py +0 -303
- webscout/Provider/OPENAI/qodo.py +0 -630
- webscout/Provider/OPENAI/xenai.py +0 -514
- webscout/tempid.py +0 -134
- webscout/webscout_search.py +0 -1183
- webscout/webscout_search_async.py +0 -649
- webscout/yep_search.py +0 -346
- webscout-8.3.7.dist-info/RECORD +0 -301
- {webscout-8.3.7.dist-info → webscout-2025.10.13.dist-info}/WHEEL +0 -0
- {webscout-8.3.7.dist-info → webscout-2025.10.13.dist-info}/entry_points.txt +0 -0
- {webscout-8.3.7.dist-info → webscout-2025.10.13.dist-info}/licenses/LICENSE.md +0 -0
- {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)
|