genai-rb 0.0.2 → 0.1.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -9
- data/LICENSE.txt +21 -21
- data/README.md +106 -106
- data/lib/genai/chat.rb +1 -31
- data/lib/genai/model.rb +394 -394
- data/lib/genai/version.rb +2 -2
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ba4f68bb9f9fb0a8aedbe47bc8e81f2859f7418f049a6d9610b7f32982a09e0f
|
4
|
+
data.tar.gz: '08bb6995fb0b749cdadde7a6be83373c53db9033ec03b8d8bc5228cf7f74ed4a'
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6fb0326f7a91add9c63a3fc1876b0360345bb6176763b708fca4a7c1a9408a77c0d422ad658fce4127458a876cb8727a67eccbb4c71f1ebe60704eacd519cf84
|
7
|
+
data.tar.gz: 4ebf4cfc65b6d21be25ced6dc666795b4110e9d4adc3d2538e32760bbf69e8af61cdb441fd692e98969aef51993a05c3da04f7f7be209334f15903fc9995e940
|
data/CHANGELOG.md
CHANGED
data/LICENSE.txt
CHANGED
@@ -1,21 +1,21 @@
|
|
1
|
-
The MIT License (MIT)
|
2
|
-
|
3
|
-
Copyright (c) 2025 Siruu580
|
4
|
-
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
7
|
-
in the Software without restriction, including without limitation the rights
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
10
|
-
furnished to do so, subject to the following conditions:
|
11
|
-
|
12
|
-
The above copyright notice and this permission notice shall be included in
|
13
|
-
all copies or substantial portions of the Software.
|
14
|
-
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
-
THE SOFTWARE.
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2025 Siruu580
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
CHANGED
@@ -1,107 +1,107 @@
|
|
1
|
-
|
2
|
-
# Genai
|
3
|
-
|
4
|
-
An unofficial Ruby package for Google Gemini. Easily generate text and images, analyze web content, and integrate Google Search—all with a simple Ruby API.
|
5
|
-
|
6
|
-
## Installation
|
7
|
-
|
8
|
-
Add to your Gemfile:
|
9
|
-
|
10
|
-
```ruby
|
11
|
-
gem 'genai-rb'
|
12
|
-
```
|
13
|
-
|
14
|
-
Then run:
|
15
|
-
|
16
|
-
```bash
|
17
|
-
bundle install
|
18
|
-
```
|
19
|
-
|
20
|
-
Or install directly:
|
21
|
-
|
22
|
-
```bash
|
23
|
-
gem install genai-rb
|
24
|
-
```
|
25
|
-
|
26
|
-
## Usage
|
27
|
-
|
28
|
-
### Setup
|
29
|
-
|
30
|
-
Get your Gemini API key from [Google AI Studio](https://makersuite.google.com/app/apikey).
|
31
|
-
|
32
|
-
Set your API key:
|
33
|
-
|
34
|
-
```bash
|
35
|
-
export GEMINI_API_KEY="api_key"
|
36
|
-
```
|
37
|
-
|
38
|
-
Or pass it directly:
|
39
|
-
|
40
|
-
```ruby
|
41
|
-
require 'genai'
|
42
|
-
client = Genai.new(api_key: "api_key")
|
43
|
-
```
|
44
|
-
|
45
|
-
### Text Generation
|
46
|
-
|
47
|
-
```ruby
|
48
|
-
response = client.generate_content(contents: "Hello, how are you?")
|
49
|
-
puts response
|
50
|
-
```
|
51
|
-
|
52
|
-
### Use a Specific Model
|
53
|
-
|
54
|
-
```ruby
|
55
|
-
response = client.generate_content(model_id: "gemini-2.5-flash", contents: "Explain quantum computing.")
|
56
|
-
```
|
57
|
-
|
58
|
-
### URL Context
|
59
|
-
|
60
|
-
```ruby
|
61
|
-
response = client.generate_content(contents: "Summarize: https://example.com/article", tools: [:url_context])
|
62
|
-
```
|
63
|
-
|
64
|
-
### Google Search Integration
|
65
|
-
|
66
|
-
```ruby
|
67
|
-
response = client.generate_content(contents: "Latest AI news?", tools: [:google_search, :url_context])
|
68
|
-
```
|
69
|
-
|
70
|
-
### Advanced Configuration
|
71
|
-
|
72
|
-
```ruby
|
73
|
-
client = Genai.new(api_key: "api_key", timeout: 120, max_retries: 5)
|
74
|
-
response = client.model("gemini-2.0-flash").generate_content(
|
75
|
-
contents: "Write a creative story about a robot learning to paint",
|
76
|
-
config: { temperature: 0.8, max_output_tokens: 1000 }
|
77
|
-
)
|
78
|
-
```
|
79
|
-
|
80
|
-
### Model Instances
|
81
|
-
|
82
|
-
```ruby
|
83
|
-
model = client.model("gemini-2.5-flash")
|
84
|
-
response = model.generate_content(contents: "Explain the benefits of renewable energy")
|
85
|
-
```
|
86
|
-
|
87
|
-
## Supported Models
|
88
|
-
|
89
|
-
- All Gemini models
|
90
|
-
|
91
|
-
## Features
|
92
|
-
|
93
|
-
- Text and image generation
|
94
|
-
- URL context analysis
|
95
|
-
- Google Search integration
|
96
|
-
- Multiple model support
|
97
|
-
- Customizable generation config
|
98
|
-
- Robust error handling and retry logic
|
99
|
-
- Simple, intuitive Ruby API
|
100
|
-
|
101
|
-
## Contributing
|
102
|
-
|
103
|
-
Pull requests are always welcome.
|
104
|
-
|
105
|
-
## License
|
106
|
-
|
1
|
+
|
2
|
+
# Genai
|
3
|
+
|
4
|
+
An unofficial Ruby package for Google Gemini. Easily generate text and images, analyze web content, and integrate Google Search—all with a simple Ruby API.
|
5
|
+
|
6
|
+
## Installation
|
7
|
+
|
8
|
+
Add to your Gemfile:
|
9
|
+
|
10
|
+
```ruby
|
11
|
+
gem 'genai-rb'
|
12
|
+
```
|
13
|
+
|
14
|
+
Then run:
|
15
|
+
|
16
|
+
```bash
|
17
|
+
bundle install
|
18
|
+
```
|
19
|
+
|
20
|
+
Or install directly:
|
21
|
+
|
22
|
+
```bash
|
23
|
+
gem install genai-rb
|
24
|
+
```
|
25
|
+
|
26
|
+
## Usage
|
27
|
+
|
28
|
+
### Setup
|
29
|
+
|
30
|
+
Get your Gemini API key from [Google AI Studio](https://makersuite.google.com/app/apikey).
|
31
|
+
|
32
|
+
Set your API key:
|
33
|
+
|
34
|
+
```bash
|
35
|
+
export GEMINI_API_KEY="api_key"
|
36
|
+
```
|
37
|
+
|
38
|
+
Or pass it directly:
|
39
|
+
|
40
|
+
```ruby
|
41
|
+
require 'genai'
|
42
|
+
client = Genai.new(api_key: "api_key")
|
43
|
+
```
|
44
|
+
|
45
|
+
### Text Generation
|
46
|
+
|
47
|
+
```ruby
|
48
|
+
response = client.generate_content(contents: "Hello, how are you?")
|
49
|
+
puts response
|
50
|
+
```
|
51
|
+
|
52
|
+
### Use a Specific Model
|
53
|
+
|
54
|
+
```ruby
|
55
|
+
response = client.generate_content(model_id: "gemini-2.5-flash", contents: "Explain quantum computing.")
|
56
|
+
```
|
57
|
+
|
58
|
+
### URL Context
|
59
|
+
|
60
|
+
```ruby
|
61
|
+
response = client.generate_content(contents: "Summarize: https://example.com/article", tools: [:url_context])
|
62
|
+
```
|
63
|
+
|
64
|
+
### Google Search Integration
|
65
|
+
|
66
|
+
```ruby
|
67
|
+
response = client.generate_content(contents: "Latest AI news?", tools: [:google_search, :url_context])
|
68
|
+
```
|
69
|
+
|
70
|
+
### Advanced Configuration
|
71
|
+
|
72
|
+
```ruby
|
73
|
+
client = Genai.new(api_key: "api_key", timeout: 120, max_retries: 5)
|
74
|
+
response = client.model("gemini-2.0-flash").generate_content(
|
75
|
+
contents: "Write a creative story about a robot learning to paint",
|
76
|
+
config: { temperature: 0.8, max_output_tokens: 1000 }
|
77
|
+
)
|
78
|
+
```
|
79
|
+
|
80
|
+
### Model Instances
|
81
|
+
|
82
|
+
```ruby
|
83
|
+
model = client.model("gemini-2.5-flash")
|
84
|
+
response = model.generate_content(contents: "Explain the benefits of renewable energy")
|
85
|
+
```
|
86
|
+
|
87
|
+
## Supported Models
|
88
|
+
|
89
|
+
- All Gemini models
|
90
|
+
|
91
|
+
## Features
|
92
|
+
|
93
|
+
- Text and image generation
|
94
|
+
- URL context analysis
|
95
|
+
- Google Search integration
|
96
|
+
- Multiple model support
|
97
|
+
- Customizable generation config
|
98
|
+
- Robust error handling and retry logic
|
99
|
+
- Simple, intuitive Ruby API
|
100
|
+
|
101
|
+
## Contributing
|
102
|
+
|
103
|
+
Pull requests are always welcome.
|
104
|
+
|
105
|
+
## License
|
106
|
+
|
107
107
|
Open source under the [MIT License](https://opensource.org/licenses/MIT).
|
data/lib/genai/chat.rb
CHANGED
@@ -1,19 +1,4 @@
|
|
1
|
-
# Copyright 2025 Google LLC
|
2
|
-
#
|
3
|
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
-
# you may not use this file except in compliance with the License.
|
5
|
-
# You may obtain a copy of the License at
|
6
|
-
#
|
7
|
-
# http://www.apache.org/licenses/LICENSE-2.0
|
8
|
-
#
|
9
|
-
# Unless required by applicable law or agreed to in writing, software
|
10
|
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
-
# See the License for the specific language governing permissions and
|
13
|
-
# limitations under the License.
|
14
|
-
|
15
1
|
module Genai
|
16
|
-
# Chat history validation methods
|
17
2
|
module ChatValidator
|
18
3
|
def self.validate_content(content)
|
19
4
|
return false unless content[:parts] && !content[:parts].empty?
|
@@ -85,7 +70,6 @@ module Genai
|
|
85
70
|
@model = model
|
86
71
|
@config = config
|
87
72
|
|
88
|
-
# Convert history items to proper format
|
89
73
|
content_models = history.map do |content|
|
90
74
|
content.is_a?(Hash) ? content : content
|
91
75
|
end
|
@@ -95,22 +79,17 @@ module Genai
|
|
95
79
|
end
|
96
80
|
|
97
81
|
def record_history(user_input:, model_output:, automatic_function_calling_history: [], is_valid: true)
|
98
|
-
# Extract input contents from automatic function calling history or use user input
|
99
82
|
input_contents = if !automatic_function_calling_history.empty?
|
100
|
-
# Deduplicate existing chat history from AFC history
|
101
83
|
automatic_function_calling_history[@curated_history.length..-1] || [user_input]
|
102
84
|
else
|
103
85
|
[user_input]
|
104
86
|
end
|
105
87
|
|
106
|
-
# Use model output or create empty content if no output
|
107
88
|
output_contents = model_output.empty? ? [{ role: "model", parts: [] }] : model_output
|
108
89
|
|
109
|
-
# Update comprehensive history
|
110
90
|
@comprehensive_history.concat(input_contents)
|
111
91
|
@comprehensive_history.concat(output_contents)
|
112
92
|
|
113
|
-
# Update curated history only if valid
|
114
93
|
if is_valid
|
115
94
|
@curated_history.concat(input_contents)
|
116
95
|
@curated_history.concat(output_contents)
|
@@ -129,7 +108,6 @@ module Genai
|
|
129
108
|
end
|
130
109
|
|
131
110
|
def send_message(message, config: nil)
|
132
|
-
# Convert message to proper content format
|
133
111
|
input_content = case message
|
134
112
|
when String
|
135
113
|
{ role: "user", parts: [{ text: message }] }
|
@@ -141,24 +119,20 @@ module Genai
|
|
141
119
|
raise ArgumentError, "Message must be a String, Array, or Hash"
|
142
120
|
end
|
143
121
|
|
144
|
-
# Generate content using the model
|
145
122
|
model_instance = @client.model(@model)
|
146
123
|
response = model_instance.generate_content(
|
147
124
|
contents: @curated_history + [input_content],
|
148
125
|
config: config || @config
|
149
126
|
)
|
150
127
|
|
151
|
-
# Extract model output from response
|
152
128
|
model_output = if response[:candidates] && !response[:candidates].empty? && response[:candidates][0][:content]
|
153
129
|
[response[:candidates][0][:content]]
|
154
130
|
else
|
155
131
|
[]
|
156
132
|
end
|
157
133
|
|
158
|
-
# Get automatic function calling history if available
|
159
134
|
automatic_function_calling_history = response[:automatic_function_calling_history] || []
|
160
135
|
|
161
|
-
# Record the conversation in history
|
162
136
|
record_history(
|
163
137
|
user_input: input_content,
|
164
138
|
model_output: model_output,
|
@@ -170,7 +144,6 @@ module Genai
|
|
170
144
|
end
|
171
145
|
|
172
146
|
def send_message_stream(message, config: nil)
|
173
|
-
# Convert message to proper content format
|
174
147
|
input_content = case message
|
175
148
|
when String
|
176
149
|
{ role: "user", parts: [{ text: message }] }
|
@@ -182,8 +155,7 @@ module Genai
|
|
182
155
|
raise ArgumentError, "Message must be a String, Array, or Hash"
|
183
156
|
end
|
184
157
|
|
185
|
-
|
186
|
-
# For now, we'll use regular send_message
|
158
|
+
|
187
159
|
send_message(message, config: config)
|
188
160
|
end
|
189
161
|
end
|
@@ -198,9 +170,7 @@ module Genai
|
|
198
170
|
Chat.new(client: @client, model: model, config: config, history: history)
|
199
171
|
end
|
200
172
|
|
201
|
-
# 사용자별 세션 관리 메소드들
|
202
173
|
def get_user_session(user_id, model: "gemini-2.0-flash", config: nil)
|
203
|
-
# 사용자별 세션이 없으면 새로 생성
|
204
174
|
unless @user_sessions[user_id]
|
205
175
|
@user_sessions[user_id] = create(
|
206
176
|
model: model,
|
data/lib/genai/model.rb
CHANGED
@@ -1,395 +1,395 @@
|
|
1
|
-
require "net/http"
|
2
|
-
require "json"
|
3
|
-
require "uri"
|
4
|
-
require 'base64'
|
5
|
-
require 'cgi'
|
6
|
-
|
7
|
-
module Genai
|
8
|
-
class Model
|
9
|
-
attr_reader :client, :model_id
|
10
|
-
|
11
|
-
def initialize(client, model_id)
|
12
|
-
@client = client
|
13
|
-
@model_id = model_id
|
14
|
-
end
|
15
|
-
|
16
|
-
def generate_content(contents:, tools: nil, config: nil, grounding: nil, **options)
|
17
|
-
tools = Array(tools).dup if tools
|
18
|
-
if grounding
|
19
|
-
if grounding.is_a?(Hash) && grounding[:dynamic_threshold]
|
20
|
-
tools ||= []
|
21
|
-
tools << self.class.grounding_with_dynamic_threshold(grounding[:dynamic_threshold])
|
22
|
-
else
|
23
|
-
tools ||= []
|
24
|
-
tools << self.class.grounding_tool
|
25
|
-
end
|
26
|
-
end
|
27
|
-
|
28
|
-
contents_with_urls = extract_and_add_urls(contents)
|
29
|
-
request_body = build_request_body(contents: contents_with_urls, tools: tools, config: config, **options)
|
30
|
-
response = make_request(request_body)
|
31
|
-
parse_response(response)
|
32
|
-
end
|
33
|
-
|
34
|
-
def self.grounding_tool
|
35
|
-
{ google_search: {} }
|
36
|
-
end
|
37
|
-
|
38
|
-
def self.grounding_with_dynamic_threshold(threshold)
|
39
|
-
{
|
40
|
-
google_search_retrieval: {
|
41
|
-
dynamic_retrieval_config: {
|
42
|
-
mode: "MODE_DYNAMIC",
|
43
|
-
dynamic_threshold: threshold
|
44
|
-
}
|
45
|
-
}
|
46
|
-
}
|
47
|
-
end
|
48
|
-
|
49
|
-
private
|
50
|
-
|
51
|
-
def extract_and_add_urls(contents)
|
52
|
-
if contents.is_a?(String)
|
53
|
-
urls = extract_urls_from_text(contents)
|
54
|
-
if urls.any?
|
55
|
-
return [
|
56
|
-
{ role: "user", parts: [{ text: contents }] },
|
57
|
-
*urls.map { |url| { role: "user", parts: [{ text: url }] } }
|
58
|
-
]
|
59
|
-
end
|
60
|
-
end
|
61
|
-
contents
|
62
|
-
end
|
63
|
-
|
64
|
-
def extract_urls_from_text(text)
|
65
|
-
url_pattern = /https?:\/\/[^\s]+/
|
66
|
-
text.scan(url_pattern)
|
67
|
-
end
|
68
|
-
|
69
|
-
def build_request_body(contents:, tools: nil, config: nil, **options)
|
70
|
-
body = {
|
71
|
-
contents: normalize_contents(contents)
|
72
|
-
}
|
73
|
-
|
74
|
-
body[:tools] = normalize_tools(tools) if tools
|
75
|
-
body[:generationConfig] = normalize_config(config) if config
|
76
|
-
|
77
|
-
options.each { |key, value| body[key] = value }
|
78
|
-
|
79
|
-
body
|
80
|
-
end
|
81
|
-
|
82
|
-
def normalize_contents(contents)
|
83
|
-
if contents.is_a?(String)
|
84
|
-
if is_image_url?(contents) || is_base64_image?(contents)
|
85
|
-
[{ role: "user", parts: [{ inline_data: { mime_type: detect_mime_type(contents), data: extract_image_data(contents) } }] }]
|
86
|
-
else
|
87
|
-
[{ role: "user", parts: [{ text: contents }] }]
|
88
|
-
end
|
89
|
-
elsif contents.is_a?(Array)
|
90
|
-
contents.map do |content|
|
91
|
-
if content.is_a?(String)
|
92
|
-
if is_image_url?(content) || is_base64_image?(content)
|
93
|
-
{ role: "user", parts: [{ inline_data: { mime_type: detect_mime_type(content), data: extract_image_data(content) } }] }
|
94
|
-
else
|
95
|
-
{ role: "user", parts: [{ text: content }] }
|
96
|
-
end
|
97
|
-
elsif content.is_a?(Hash)
|
98
|
-
content[:role] ||= "user"
|
99
|
-
content
|
100
|
-
else
|
101
|
-
raise Error, "Invalid content format: #{content.class}"
|
102
|
-
end
|
103
|
-
end
|
104
|
-
elsif contents.is_a?(Hash)
|
105
|
-
contents[:role] ||= "user"
|
106
|
-
[contents]
|
107
|
-
else
|
108
|
-
raise Error, "Invalid contents format: #{contents.class}"
|
109
|
-
end
|
110
|
-
end
|
111
|
-
|
112
|
-
def is_image_url?(text)
|
113
|
-
image_extensions = %w[.jpg .jpeg .png .gif .webp .bmp .tiff]
|
114
|
-
text.match?(/^https?:\/\/.+/i) && image_extensions.any? { |ext| text.downcase.include?(ext) }
|
115
|
-
end
|
116
|
-
|
117
|
-
def is_base64_image?(text)
|
118
|
-
text.match?(/^data:image\/[a-zA-Z]+;base64,/)
|
119
|
-
end
|
120
|
-
|
121
|
-
def detect_mime_type(content)
|
122
|
-
if is_image_url?(content)
|
123
|
-
case content.downcase
|
124
|
-
when /\.(jpg|jpeg)$/
|
125
|
-
"image/jpeg"
|
126
|
-
when /\.png$/
|
127
|
-
"image/png"
|
128
|
-
when /\.gif$/
|
129
|
-
"image/gif"
|
130
|
-
when /\.webp$/
|
131
|
-
"image/webp"
|
132
|
-
when /\.bmp$/
|
133
|
-
"image/bmp"
|
134
|
-
when /\.tiff$/
|
135
|
-
"image/tiff"
|
136
|
-
else
|
137
|
-
"image/jpeg"
|
138
|
-
end
|
139
|
-
elsif is_base64_image?(content)
|
140
|
-
match = content.match(/^data:image\/([a-zA-Z]+);base64,/)
|
141
|
-
if match
|
142
|
-
"image/#{match[1]}"
|
143
|
-
else
|
144
|
-
"image/jpeg"
|
145
|
-
end
|
146
|
-
else
|
147
|
-
"text/plain"
|
148
|
-
end
|
149
|
-
end
|
150
|
-
|
151
|
-
def extract_image_data(content)
|
152
|
-
if is_image_url?(content)
|
153
|
-
download_and_encode_image(content)
|
154
|
-
elsif is_base64_image?(content)
|
155
|
-
content.match(/^data:image\/[a-zA-Z]+;base64,(.+)$/)[1]
|
156
|
-
else
|
157
|
-
content
|
158
|
-
end
|
159
|
-
end
|
160
|
-
|
161
|
-
def download_and_encode_image(url)
|
162
|
-
begin
|
163
|
-
uri = URI(url)
|
164
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
165
|
-
http.use_ssl = true if uri.scheme == 'https'
|
166
|
-
http.open_timeout = 10
|
167
|
-
http.read_timeout = 10
|
168
|
-
|
169
|
-
request = Net::HTTP::Get.new(uri)
|
170
|
-
request['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
171
|
-
|
172
|
-
response = http.request(request)
|
173
|
-
|
174
|
-
if response.is_a?(Net::HTTPSuccess)
|
175
|
-
Base64.strict_encode64(response.body)
|
176
|
-
else
|
177
|
-
raise Error, "Failed to download image: #{response.code}"
|
178
|
-
end
|
179
|
-
rescue => e
|
180
|
-
raise Error, "Error downloading image: #{e.message}"
|
181
|
-
end
|
182
|
-
end
|
183
|
-
|
184
|
-
def normalize_tools(tools)
|
185
|
-
return [] if tools.nil?
|
186
|
-
|
187
|
-
if tools.is_a?(Array)
|
188
|
-
tools.map { |tool| normalize_tool(tool) }
|
189
|
-
else
|
190
|
-
[normalize_tool(tools)]
|
191
|
-
end
|
192
|
-
end
|
193
|
-
|
194
|
-
def normalize_tool(tool)
|
195
|
-
case tool
|
196
|
-
when Hash
|
197
|
-
tool
|
198
|
-
when :url_context, "url_context"
|
199
|
-
{ url_context: {} }
|
200
|
-
when :google_search, "google_search"
|
201
|
-
{ google_search: {} }
|
202
|
-
when :google_search_retrieval, "google_search_retrieval"
|
203
|
-
{ google_search_retrieval: {} }
|
204
|
-
else
|
205
|
-
raise Error, "Unknown tool: #{tool}"
|
206
|
-
end
|
207
|
-
end
|
208
|
-
|
209
|
-
def normalize_config(config)
|
210
|
-
return {} if config.nil?
|
211
|
-
|
212
|
-
if config.is_a?(Hash)
|
213
|
-
config
|
214
|
-
else
|
215
|
-
raise Error, "Config must be a Hash"
|
216
|
-
end
|
217
|
-
end
|
218
|
-
|
219
|
-
def make_request(request_body)
|
220
|
-
uri = URI(client.config.api_url("models/#{model_id}:generateContent"))
|
221
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
222
|
-
http.use_ssl = true
|
223
|
-
http.open_timeout = client.config.timeout
|
224
|
-
http.read_timeout = client.config.timeout
|
225
|
-
|
226
|
-
request = Net::HTTP::Post.new(uri)
|
227
|
-
client.config.headers.each { |key, value| request[key] = value }
|
228
|
-
request.body = request_body.to_json
|
229
|
-
|
230
|
-
response = http.request(request)
|
231
|
-
|
232
|
-
case response
|
233
|
-
when Net::HTTPSuccess
|
234
|
-
response
|
235
|
-
when Net::HTTPClientError
|
236
|
-
raise Error, "Client error: #{response.code} - #{response.body}"
|
237
|
-
when Net::HTTPServerError
|
238
|
-
raise Error, "Server error: #{response.code} - #{response.body}"
|
239
|
-
else
|
240
|
-
raise Error, "Unexpected response: #{response.code} - #{response.body}"
|
241
|
-
end
|
242
|
-
end
|
243
|
-
|
244
|
-
def parse_response(response)
|
245
|
-
data = JSON.parse(response.body)
|
246
|
-
|
247
|
-
candidates = data["candidates"] || []
|
248
|
-
return "" if candidates.empty?
|
249
|
-
|
250
|
-
content = candidates.first["content"]
|
251
|
-
parts = content["parts"] || []
|
252
|
-
|
253
|
-
text_parts = parts.map { |part| part["text"] }.compact
|
254
|
-
text = text_parts.join
|
255
|
-
|
256
|
-
if candidates.first["groundingMetadata"]
|
257
|
-
grounding_info = candidates.first["groundingMetadata"]
|
258
|
-
if grounding_info["groundingChunks"]
|
259
|
-
text += "\n\n참고한 URL:\n"
|
260
|
-
grounding_info["groundingChunks"].each_with_index do |chunk, index|
|
261
|
-
if chunk["web"]
|
262
|
-
original_url = extract_original_url(chunk["web"]["uri"])
|
263
|
-
decoded_url = original_url.gsub(/\\u([0-9a-fA-F]{4})/) { |m| [$1.to_i(16)].pack('U') }
|
264
|
-
decoded_url = CGI.unescape(decoded_url)
|
265
|
-
encoded_url = decoded_url.gsub(' ', '%20')
|
266
|
-
text += "#{index + 1}. #{encoded_url}\n"
|
267
|
-
end
|
268
|
-
end
|
269
|
-
end
|
270
|
-
end
|
271
|
-
|
272
|
-
text
|
273
|
-
end
|
274
|
-
|
275
|
-
def extract_original_url(redirect_url)
|
276
|
-
return redirect_url unless redirect_url.include?("vertexaisearch.cloud.google.com")
|
277
|
-
|
278
|
-
final_url = follow_redirects(redirect_url)
|
279
|
-
return final_url if final_url && final_url != redirect_url
|
280
|
-
|
281
|
-
begin
|
282
|
-
uri = URI(redirect_url)
|
283
|
-
|
284
|
-
path_parts = uri.path.split("/")
|
285
|
-
if path_parts.include?("grounding-api-redirect")
|
286
|
-
encoded_url = path_parts.last
|
287
|
-
|
288
|
-
decoded_url = try_decode_url(encoded_url)
|
289
|
-
return decoded_url if decoded_url && decoded_url.start_with?("http")
|
290
|
-
end
|
291
|
-
|
292
|
-
if uri.query
|
293
|
-
params = URI.decode_www_form(uri.query)
|
294
|
-
original_url = params.find { |k, v| k == "url" || k == "original_url" || k == "target" }&.last
|
295
|
-
return original_url if original_url && original_url.start_with?("http")
|
296
|
-
end
|
297
|
-
|
298
|
-
if uri.query && uri.query.include?("http")
|
299
|
-
url_match = uri.query.match(/https?:\/\/[^\s&]+/)
|
300
|
-
return url_match[0] if url_match
|
301
|
-
end
|
302
|
-
|
303
|
-
if ENV['DEBUG']
|
304
|
-
puts "URL 파싱 실패 - 구조:"
|
305
|
-
puts " 전체 URL: #{redirect_url}"
|
306
|
-
puts " 경로: #{uri.path}"
|
307
|
-
puts " 쿼리: #{uri.query}"
|
308
|
-
puts " 인코딩된 부분: #{path_parts.last}" if path_parts.last
|
309
|
-
end
|
310
|
-
|
311
|
-
rescue => e
|
312
|
-
puts "URL 파싱 오류: #{e.message}" if ENV['DEBUG']
|
313
|
-
end
|
314
|
-
|
315
|
-
redirect_url
|
316
|
-
end
|
317
|
-
|
318
|
-
def follow_redirects(url, max_redirects = 5)
|
319
|
-
current_url = url
|
320
|
-
redirect_count = 0
|
321
|
-
|
322
|
-
while redirect_count < max_redirects
|
323
|
-
begin
|
324
|
-
uri = URI(current_url)
|
325
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
326
|
-
http.use_ssl = true if uri.scheme == 'https'
|
327
|
-
http.open_timeout = 10
|
328
|
-
http.read_timeout = 10
|
329
|
-
|
330
|
-
request = Net::HTTP::Get.new(uri)
|
331
|
-
request['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
332
|
-
|
333
|
-
response = http.request(request)
|
334
|
-
|
335
|
-
case response
|
336
|
-
when Net::HTTPRedirection
|
337
|
-
redirect_count += 1
|
338
|
-
location = response['location']
|
339
|
-
if location
|
340
|
-
if location.start_with?('/')
|
341
|
-
current_url = "#{uri.scheme}://#{uri.host}#{location}"
|
342
|
-
elsif location.start_with?('http')
|
343
|
-
current_url = location
|
344
|
-
else
|
345
|
-
current_url = "#{uri.scheme}://#{uri.host}/#{location}"
|
346
|
-
end
|
347
|
-
else
|
348
|
-
break
|
349
|
-
end
|
350
|
-
else
|
351
|
-
return current_url
|
352
|
-
end
|
353
|
-
rescue => e
|
354
|
-
puts "리다이렉트 추적 오류: #{e.message}" if ENV['DEBUG']
|
355
|
-
return url
|
356
|
-
end
|
357
|
-
end
|
358
|
-
|
359
|
-
current_url
|
360
|
-
end
|
361
|
-
|
362
|
-
def try_decode_url(encoded_url)
|
363
|
-
begin
|
364
|
-
require 'base64'
|
365
|
-
decoded_bytes = Base64.urlsafe_decode64(encoded_url)
|
366
|
-
decoded_url = decoded_bytes.force_encoding('UTF-8')
|
367
|
-
return decoded_url if decoded_url.start_with?("http")
|
368
|
-
rescue
|
369
|
-
end
|
370
|
-
|
371
|
-
begin
|
372
|
-
decoded_bytes = Base64.decode64(encoded_url)
|
373
|
-
decoded_url = decoded_bytes.force_encoding('UTF-8')
|
374
|
-
return decoded_url if decoded_url.start_with?("http")
|
375
|
-
rescue
|
376
|
-
end
|
377
|
-
|
378
|
-
begin
|
379
|
-
decoded_url = URI.decode(encoded_url)
|
380
|
-
return decoded_url if decoded_url.start_with?("http")
|
381
|
-
rescue
|
382
|
-
end
|
383
|
-
|
384
|
-
begin
|
385
|
-
padded_url = encoded_url + "=" * (4 - encoded_url.length % 4)
|
386
|
-
decoded_bytes = Base64.decode64(padded_url)
|
387
|
-
decoded_url = decoded_bytes.force_encoding('UTF-8')
|
388
|
-
return decoded_url if decoded_url.start_with?("http")
|
389
|
-
rescue
|
390
|
-
end
|
391
|
-
|
392
|
-
nil
|
393
|
-
end
|
394
|
-
end
|
1
|
+
require "net/http"
|
2
|
+
require "json"
|
3
|
+
require "uri"
|
4
|
+
require 'base64'
|
5
|
+
require 'cgi'
|
6
|
+
|
7
|
+
module Genai
|
8
|
+
class Model
|
9
|
+
attr_reader :client, :model_id
|
10
|
+
|
11
|
+
def initialize(client, model_id)
|
12
|
+
@client = client
|
13
|
+
@model_id = model_id
|
14
|
+
end
|
15
|
+
|
16
|
+
def generate_content(contents:, tools: nil, config: nil, grounding: nil, **options)
|
17
|
+
tools = Array(tools).dup if tools
|
18
|
+
if grounding
|
19
|
+
if grounding.is_a?(Hash) && grounding[:dynamic_threshold]
|
20
|
+
tools ||= []
|
21
|
+
tools << self.class.grounding_with_dynamic_threshold(grounding[:dynamic_threshold])
|
22
|
+
else
|
23
|
+
tools ||= []
|
24
|
+
tools << self.class.grounding_tool
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
contents_with_urls = extract_and_add_urls(contents)
|
29
|
+
request_body = build_request_body(contents: contents_with_urls, tools: tools, config: config, **options)
|
30
|
+
response = make_request(request_body)
|
31
|
+
parse_response(response)
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.grounding_tool
|
35
|
+
{ google_search: {} }
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.grounding_with_dynamic_threshold(threshold)
|
39
|
+
{
|
40
|
+
google_search_retrieval: {
|
41
|
+
dynamic_retrieval_config: {
|
42
|
+
mode: "MODE_DYNAMIC",
|
43
|
+
dynamic_threshold: threshold
|
44
|
+
}
|
45
|
+
}
|
46
|
+
}
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def extract_and_add_urls(contents)
|
52
|
+
if contents.is_a?(String)
|
53
|
+
urls = extract_urls_from_text(contents)
|
54
|
+
if urls.any?
|
55
|
+
return [
|
56
|
+
{ role: "user", parts: [{ text: contents }] },
|
57
|
+
*urls.map { |url| { role: "user", parts: [{ text: url }] } }
|
58
|
+
]
|
59
|
+
end
|
60
|
+
end
|
61
|
+
contents
|
62
|
+
end
|
63
|
+
|
64
|
+
def extract_urls_from_text(text)
|
65
|
+
url_pattern = /https?:\/\/[^\s]+/
|
66
|
+
text.scan(url_pattern)
|
67
|
+
end
|
68
|
+
|
69
|
+
def build_request_body(contents:, tools: nil, config: nil, **options)
|
70
|
+
body = {
|
71
|
+
contents: normalize_contents(contents)
|
72
|
+
}
|
73
|
+
|
74
|
+
body[:tools] = normalize_tools(tools) if tools
|
75
|
+
body[:generationConfig] = normalize_config(config) if config
|
76
|
+
|
77
|
+
options.each { |key, value| body[key] = value }
|
78
|
+
|
79
|
+
body
|
80
|
+
end
|
81
|
+
|
82
|
+
def normalize_contents(contents)
|
83
|
+
if contents.is_a?(String)
|
84
|
+
if is_image_url?(contents) || is_base64_image?(contents)
|
85
|
+
[{ role: "user", parts: [{ inline_data: { mime_type: detect_mime_type(contents), data: extract_image_data(contents) } }] }]
|
86
|
+
else
|
87
|
+
[{ role: "user", parts: [{ text: contents }] }]
|
88
|
+
end
|
89
|
+
elsif contents.is_a?(Array)
|
90
|
+
contents.map do |content|
|
91
|
+
if content.is_a?(String)
|
92
|
+
if is_image_url?(content) || is_base64_image?(content)
|
93
|
+
{ role: "user", parts: [{ inline_data: { mime_type: detect_mime_type(content), data: extract_image_data(content) } }] }
|
94
|
+
else
|
95
|
+
{ role: "user", parts: [{ text: content }] }
|
96
|
+
end
|
97
|
+
elsif content.is_a?(Hash)
|
98
|
+
content[:role] ||= "user"
|
99
|
+
content
|
100
|
+
else
|
101
|
+
raise Error, "Invalid content format: #{content.class}"
|
102
|
+
end
|
103
|
+
end
|
104
|
+
elsif contents.is_a?(Hash)
|
105
|
+
contents[:role] ||= "user"
|
106
|
+
[contents]
|
107
|
+
else
|
108
|
+
raise Error, "Invalid contents format: #{contents.class}"
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def is_image_url?(text)
|
113
|
+
image_extensions = %w[.jpg .jpeg .png .gif .webp .bmp .tiff]
|
114
|
+
text.match?(/^https?:\/\/.+/i) && image_extensions.any? { |ext| text.downcase.include?(ext) }
|
115
|
+
end
|
116
|
+
|
117
|
+
def is_base64_image?(text)
|
118
|
+
text.match?(/^data:image\/[a-zA-Z]+;base64,/)
|
119
|
+
end
|
120
|
+
|
121
|
+
def detect_mime_type(content)
|
122
|
+
if is_image_url?(content)
|
123
|
+
case content.downcase
|
124
|
+
when /\.(jpg|jpeg)$/
|
125
|
+
"image/jpeg"
|
126
|
+
when /\.png$/
|
127
|
+
"image/png"
|
128
|
+
when /\.gif$/
|
129
|
+
"image/gif"
|
130
|
+
when /\.webp$/
|
131
|
+
"image/webp"
|
132
|
+
when /\.bmp$/
|
133
|
+
"image/bmp"
|
134
|
+
when /\.tiff$/
|
135
|
+
"image/tiff"
|
136
|
+
else
|
137
|
+
"image/jpeg"
|
138
|
+
end
|
139
|
+
elsif is_base64_image?(content)
|
140
|
+
match = content.match(/^data:image\/([a-zA-Z]+);base64,/)
|
141
|
+
if match
|
142
|
+
"image/#{match[1]}"
|
143
|
+
else
|
144
|
+
"image/jpeg"
|
145
|
+
end
|
146
|
+
else
|
147
|
+
"text/plain"
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def extract_image_data(content)
|
152
|
+
if is_image_url?(content)
|
153
|
+
download_and_encode_image(content)
|
154
|
+
elsif is_base64_image?(content)
|
155
|
+
content.match(/^data:image\/[a-zA-Z]+;base64,(.+)$/)[1]
|
156
|
+
else
|
157
|
+
content
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def download_and_encode_image(url)
|
162
|
+
begin
|
163
|
+
uri = URI(url)
|
164
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
165
|
+
http.use_ssl = true if uri.scheme == 'https'
|
166
|
+
http.open_timeout = 10
|
167
|
+
http.read_timeout = 10
|
168
|
+
|
169
|
+
request = Net::HTTP::Get.new(uri)
|
170
|
+
request['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
171
|
+
|
172
|
+
response = http.request(request)
|
173
|
+
|
174
|
+
if response.is_a?(Net::HTTPSuccess)
|
175
|
+
Base64.strict_encode64(response.body)
|
176
|
+
else
|
177
|
+
raise Error, "Failed to download image: #{response.code}"
|
178
|
+
end
|
179
|
+
rescue => e
|
180
|
+
raise Error, "Error downloading image: #{e.message}"
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def normalize_tools(tools)
|
185
|
+
return [] if tools.nil?
|
186
|
+
|
187
|
+
if tools.is_a?(Array)
|
188
|
+
tools.map { |tool| normalize_tool(tool) }
|
189
|
+
else
|
190
|
+
[normalize_tool(tools)]
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
def normalize_tool(tool)
|
195
|
+
case tool
|
196
|
+
when Hash
|
197
|
+
tool
|
198
|
+
when :url_context, "url_context"
|
199
|
+
{ url_context: {} }
|
200
|
+
when :google_search, "google_search"
|
201
|
+
{ google_search: {} }
|
202
|
+
when :google_search_retrieval, "google_search_retrieval"
|
203
|
+
{ google_search_retrieval: {} }
|
204
|
+
else
|
205
|
+
raise Error, "Unknown tool: #{tool}"
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
def normalize_config(config)
|
210
|
+
return {} if config.nil?
|
211
|
+
|
212
|
+
if config.is_a?(Hash)
|
213
|
+
config
|
214
|
+
else
|
215
|
+
raise Error, "Config must be a Hash"
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
def make_request(request_body)
|
220
|
+
uri = URI(client.config.api_url("models/#{model_id}:generateContent"))
|
221
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
222
|
+
http.use_ssl = true
|
223
|
+
http.open_timeout = client.config.timeout
|
224
|
+
http.read_timeout = client.config.timeout
|
225
|
+
|
226
|
+
request = Net::HTTP::Post.new(uri)
|
227
|
+
client.config.headers.each { |key, value| request[key] = value }
|
228
|
+
request.body = request_body.to_json
|
229
|
+
|
230
|
+
response = http.request(request)
|
231
|
+
|
232
|
+
case response
|
233
|
+
when Net::HTTPSuccess
|
234
|
+
response
|
235
|
+
when Net::HTTPClientError
|
236
|
+
raise Error, "Client error: #{response.code} - #{response.body}"
|
237
|
+
when Net::HTTPServerError
|
238
|
+
raise Error, "Server error: #{response.code} - #{response.body}"
|
239
|
+
else
|
240
|
+
raise Error, "Unexpected response: #{response.code} - #{response.body}"
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
def parse_response(response)
|
245
|
+
data = JSON.parse(response.body)
|
246
|
+
|
247
|
+
candidates = data["candidates"] || []
|
248
|
+
return "" if candidates.empty?
|
249
|
+
|
250
|
+
content = candidates.first["content"]
|
251
|
+
parts = content["parts"] || []
|
252
|
+
|
253
|
+
text_parts = parts.map { |part| part["text"] }.compact
|
254
|
+
text = text_parts.join
|
255
|
+
|
256
|
+
if candidates.first["groundingMetadata"]
|
257
|
+
grounding_info = candidates.first["groundingMetadata"]
|
258
|
+
if grounding_info["groundingChunks"]
|
259
|
+
text += "\n\n참고한 URL:\n"
|
260
|
+
grounding_info["groundingChunks"].each_with_index do |chunk, index|
|
261
|
+
if chunk["web"]
|
262
|
+
original_url = extract_original_url(chunk["web"]["uri"])
|
263
|
+
decoded_url = original_url.gsub(/\\u([0-9a-fA-F]{4})/) { |m| [$1.to_i(16)].pack('U') }
|
264
|
+
decoded_url = CGI.unescape(decoded_url)
|
265
|
+
encoded_url = decoded_url.gsub(' ', '%20')
|
266
|
+
text += "#{index + 1}. #{encoded_url}\n"
|
267
|
+
end
|
268
|
+
end
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
text
|
273
|
+
end
|
274
|
+
|
275
|
+
def extract_original_url(redirect_url)
|
276
|
+
return redirect_url unless redirect_url.include?("vertexaisearch.cloud.google.com")
|
277
|
+
|
278
|
+
final_url = follow_redirects(redirect_url)
|
279
|
+
return final_url if final_url && final_url != redirect_url
|
280
|
+
|
281
|
+
begin
|
282
|
+
uri = URI(redirect_url)
|
283
|
+
|
284
|
+
path_parts = uri.path.split("/")
|
285
|
+
if path_parts.include?("grounding-api-redirect")
|
286
|
+
encoded_url = path_parts.last
|
287
|
+
|
288
|
+
decoded_url = try_decode_url(encoded_url)
|
289
|
+
return decoded_url if decoded_url && decoded_url.start_with?("http")
|
290
|
+
end
|
291
|
+
|
292
|
+
if uri.query
|
293
|
+
params = URI.decode_www_form(uri.query)
|
294
|
+
original_url = params.find { |k, v| k == "url" || k == "original_url" || k == "target" }&.last
|
295
|
+
return original_url if original_url && original_url.start_with?("http")
|
296
|
+
end
|
297
|
+
|
298
|
+
if uri.query && uri.query.include?("http")
|
299
|
+
url_match = uri.query.match(/https?:\/\/[^\s&]+/)
|
300
|
+
return url_match[0] if url_match
|
301
|
+
end
|
302
|
+
|
303
|
+
if ENV['DEBUG']
|
304
|
+
puts "URL 파싱 실패 - 구조:"
|
305
|
+
puts " 전체 URL: #{redirect_url}"
|
306
|
+
puts " 경로: #{uri.path}"
|
307
|
+
puts " 쿼리: #{uri.query}"
|
308
|
+
puts " 인코딩된 부분: #{path_parts.last}" if path_parts.last
|
309
|
+
end
|
310
|
+
|
311
|
+
rescue => e
|
312
|
+
puts "URL 파싱 오류: #{e.message}" if ENV['DEBUG']
|
313
|
+
end
|
314
|
+
|
315
|
+
redirect_url
|
316
|
+
end
|
317
|
+
|
318
|
+
def follow_redirects(url, max_redirects = 5)
|
319
|
+
current_url = url
|
320
|
+
redirect_count = 0
|
321
|
+
|
322
|
+
while redirect_count < max_redirects
|
323
|
+
begin
|
324
|
+
uri = URI(current_url)
|
325
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
326
|
+
http.use_ssl = true if uri.scheme == 'https'
|
327
|
+
http.open_timeout = 10
|
328
|
+
http.read_timeout = 10
|
329
|
+
|
330
|
+
request = Net::HTTP::Get.new(uri)
|
331
|
+
request['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
332
|
+
|
333
|
+
response = http.request(request)
|
334
|
+
|
335
|
+
case response
|
336
|
+
when Net::HTTPRedirection
|
337
|
+
redirect_count += 1
|
338
|
+
location = response['location']
|
339
|
+
if location
|
340
|
+
if location.start_with?('/')
|
341
|
+
current_url = "#{uri.scheme}://#{uri.host}#{location}"
|
342
|
+
elsif location.start_with?('http')
|
343
|
+
current_url = location
|
344
|
+
else
|
345
|
+
current_url = "#{uri.scheme}://#{uri.host}/#{location}"
|
346
|
+
end
|
347
|
+
else
|
348
|
+
break
|
349
|
+
end
|
350
|
+
else
|
351
|
+
return current_url
|
352
|
+
end
|
353
|
+
rescue => e
|
354
|
+
puts "리다이렉트 추적 오류: #{e.message}" if ENV['DEBUG']
|
355
|
+
return url
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
359
|
+
current_url
|
360
|
+
end
|
361
|
+
|
362
|
+
def try_decode_url(encoded_url)
|
363
|
+
begin
|
364
|
+
require 'base64'
|
365
|
+
decoded_bytes = Base64.urlsafe_decode64(encoded_url)
|
366
|
+
decoded_url = decoded_bytes.force_encoding('UTF-8')
|
367
|
+
return decoded_url if decoded_url.start_with?("http")
|
368
|
+
rescue
|
369
|
+
end
|
370
|
+
|
371
|
+
begin
|
372
|
+
decoded_bytes = Base64.decode64(encoded_url)
|
373
|
+
decoded_url = decoded_bytes.force_encoding('UTF-8')
|
374
|
+
return decoded_url if decoded_url.start_with?("http")
|
375
|
+
rescue
|
376
|
+
end
|
377
|
+
|
378
|
+
begin
|
379
|
+
decoded_url = URI.decode(encoded_url)
|
380
|
+
return decoded_url if decoded_url.start_with?("http")
|
381
|
+
rescue
|
382
|
+
end
|
383
|
+
|
384
|
+
begin
|
385
|
+
padded_url = encoded_url + "=" * (4 - encoded_url.length % 4)
|
386
|
+
decoded_bytes = Base64.decode64(padded_url)
|
387
|
+
decoded_url = decoded_bytes.force_encoding('UTF-8')
|
388
|
+
return decoded_url if decoded_url.start_with?("http")
|
389
|
+
rescue
|
390
|
+
end
|
391
|
+
|
392
|
+
nil
|
393
|
+
end
|
394
|
+
end
|
395
395
|
end
|
data/lib/genai/version.rb
CHANGED
@@ -1,3 +1,3 @@
|
|
1
|
-
module Genai
|
2
|
-
VERSION = "0.0
|
1
|
+
module Genai
|
2
|
+
VERSION = "0.1.0"
|
3
3
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: genai-rb
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Siruu580
|
@@ -89,5 +89,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
89
89
|
requirements: []
|
90
90
|
rubygems_version: 3.6.7
|
91
91
|
specification_version: 4
|
92
|
-
summary:
|
92
|
+
summary: gemini module for ruby
|
93
93
|
test_files: []
|