huginn_datacircle_agent 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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +93 -0
- data/lib/huginn_datacircle_agent/datacircle_agent.rb +180 -0
- data/lib/huginn_datacircle_agent.rb +3 -0
- metadata +64 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6a022db3f1ff492afbaf28b1cc4a935a271a758877912cee8ffed3cd6dc25b71
|
|
4
|
+
data.tar.gz: 550324c4aa6de94ac365cf6e056b45e4784ee89a730a0da4c6ecf47e793a3deb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cf53385e9fc84cdd2247aadfce4dc26c2a04c1013ce1b45bec614fe71a3fb7c84103080f92c824f850e73984f91ffbdf6cf6b5c7bbeeeb3c16c752a824a27b43
|
|
7
|
+
data.tar.gz: 46165f536cf5754b45d16ca972a8bb9d3e4de62008b47e0dd6bba7d01c37f8e298bb722db2e37782d907af65f0cb3b70a72abdf900691ca7e022654df4806d1b
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Datacircle
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Datacircle agent for Huginn
|
|
2
|
+
|
|
3
|
+
Find the best GTM APIs. We benchmark all the GTM APIs and we put the results on our website: https://datacircle.dev
|
|
4
|
+
|
|
5
|
+
This gem adds the Datacircle Agent to [Huginn](https://github.com/huginn/huginn). The agent emits the best plans of an API, like an email finder or a SERP API, as Huginn events. It reads Datacircle's open API, with no key and no account.
|
|
6
|
+
|
|
7
|
+
## Use the agent to
|
|
8
|
+
|
|
9
|
+
- Get the best email finder plans each morning, and an email when the first one changes.
|
|
10
|
+
- Find every plan of one provider, or the cheapest plans of an API.
|
|
11
|
+
- Follow one provider's rank on each API it sells.
|
|
12
|
+
|
|
13
|
+
## Its calls
|
|
14
|
+
|
|
15
|
+
Set `call` to one of these. Each one is a call of the API, at https://api.datacircle.dev/v1:
|
|
16
|
+
|
|
17
|
+
| `call` | API call | Events |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `best_plans` | `GET /apis/{slug}/best` | the plans of one API (`api`), best first |
|
|
20
|
+
| `search_plans` | `GET /plans` | plans by API, provider or plan name (`api`, `query`), sorted by `score`, `provider` or `price` |
|
|
21
|
+
| `list_apis` | `GET /apis` | every API on Datacircle, with the API's slug and best plan |
|
|
22
|
+
| `provider` | `GET /providers/{slug}` | one provider's rank on each API (`provider`) |
|
|
23
|
+
|
|
24
|
+
`limit` is how many plans `best_plans` and `search_plans` emit, 1 to 50. Each plan has a rank, a score out of 100, a price per 1,000 and a link to the plan's page on datacircle.dev. That page lists every field.
|
|
25
|
+
|
|
26
|
+
Each option takes Liquid. So a Datacircle Agent can read the event of another agent: `"api": "{{ slug }}"` reads the best plans of each API a `list_apis` agent emits.
|
|
27
|
+
|
|
28
|
+
The spec: https://api.datacircle.dev/v1/openapi.json. At most 60 calls a minute from one address. The agent waits and calls again when the API asks it to.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
Add the gem to `ADDITIONAL_GEMS` in Huginn's `.env`:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
ADDITIONAL_GEMS=huginn_datacircle_agent
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
With Huginn's docker image, pass it as a variable:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
docker run -it -p 3000:3000 -e ADDITIONAL_GEMS=huginn_datacircle_agent ghcr.io/huginn/huginn
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Then run `bundle` and restart Huginn. The Datacircle Agent is in the agent types of "New Agent".
|
|
45
|
+
|
|
46
|
+
## Options
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"call": "best_plans",
|
|
51
|
+
"api": "email-finder",
|
|
52
|
+
"query": "",
|
|
53
|
+
"sort": "score",
|
|
54
|
+
"provider": "",
|
|
55
|
+
"limit": "10",
|
|
56
|
+
"offset": "0",
|
|
57
|
+
"expected_update_period_in_days": "2"
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- `api`: the API's slug, the words of its sheet's address on datacircle.dev, like `email-finder` for https://datacircle.dev/email-finder-api-pricing. `search_plans` also takes the API's name.
|
|
62
|
+
- `query`: words that must all appear in the provider's or plan's name.
|
|
63
|
+
- `provider`: the provider's slug, its name in lowercase with hyphens, like `bright-data`.
|
|
64
|
+
- `offset`: how many plans `search_plans` skips first.
|
|
65
|
+
|
|
66
|
+
## An event
|
|
67
|
+
|
|
68
|
+
A plan of `best_plans`, shortened. Each plan has 83 fields:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"rank": 1,
|
|
73
|
+
"provider": "GetProspect",
|
|
74
|
+
"plan": "Growth 50k",
|
|
75
|
+
"api": "Employee email",
|
|
76
|
+
"score": 79.7,
|
|
77
|
+
"perThousand": 7.98,
|
|
78
|
+
"pay": 399,
|
|
79
|
+
"currency": "USD",
|
|
80
|
+
"billing": "monthly",
|
|
81
|
+
"page": "https://datacircle.dev/find-email-apis/getprospect/free-post-v2-email-find"
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Links
|
|
86
|
+
|
|
87
|
+
- Datacircle: https://datacircle.dev
|
|
88
|
+
- What each field means: https://datacircle.dev/fields
|
|
89
|
+
- The API's spec: https://api.datacircle.dev/v1/openapi.json
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
module Agents
|
|
2
|
+
class DatacircleAgent < Agent
|
|
3
|
+
include WebRequestConcern
|
|
4
|
+
include FormConfigurable
|
|
5
|
+
|
|
6
|
+
API = 'https://api.datacircle.dev/v1'.freeze
|
|
7
|
+
CALLS = %w[best_plans search_plans list_apis provider].freeze
|
|
8
|
+
SORTS = %w[score provider price].freeze
|
|
9
|
+
|
|
10
|
+
can_dry_run!
|
|
11
|
+
default_schedule 'every_1d'
|
|
12
|
+
|
|
13
|
+
description <<~MD
|
|
14
|
+
The Datacircle Agent reads Datacircle's open API and emits an event for each plan, API or rank it gets back. Datacircle benchmarks the GTM APIs (email finders, LinkedIn profile APIs, SERP APIs and more) and puts the results on [datacircle.dev](https://datacircle.dev). The API needs no key and no account.
|
|
15
|
+
|
|
16
|
+
Set `call` to one of:
|
|
17
|
+
|
|
18
|
+
* `best_plans`: the plans of one API, best first (`GET /apis/{slug}/best`). `api` is the API's slug, like `email-finder`.
|
|
19
|
+
* `search_plans`: the plans whose provider or plan name holds every word of `query` (`GET /plans`). `api` keeps one API's plans only. `sort` is `score`, `provider` or `price`. `offset` skips the first plans found.
|
|
20
|
+
* `list_apis`: every API on Datacircle, with its slug and its best plan (`GET /apis`).
|
|
21
|
+
* `provider`: one provider's rank on each API it sells (`GET /providers/{slug}`). `provider` is its slug, like `bright-data`.
|
|
22
|
+
|
|
23
|
+
`limit` is how many plans `best_plans` and `search_plans` emit, 1 to 50 (10 when left empty).
|
|
24
|
+
|
|
25
|
+
Each option takes [Liquid](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid), filled by each event the agent receives. So `"api": "{{ slug }}"` reads the best plans of each API a `list_apis` agent emits.
|
|
26
|
+
|
|
27
|
+
Set `expected_update_period_in_days` to the most days that should pass between two events of this agent.
|
|
28
|
+
|
|
29
|
+
What each field of a plan means: [datacircle.dev/fields](https://datacircle.dev/fields). The spec: [api.datacircle.dev/v1/openapi.json](https://api.datacircle.dev/v1/openapi.json). At most 60 calls a minute from one address.
|
|
30
|
+
MD
|
|
31
|
+
|
|
32
|
+
event_description <<~MD
|
|
33
|
+
A plan of `best_plans` or `search_plans` looks like this, shortened (each plan has 83 fields, [datacircle.dev/fields](https://datacircle.dev/fields) says what each means):
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
"rank": 1,
|
|
37
|
+
"provider": "GetProspect",
|
|
38
|
+
"plan": "Growth 50k",
|
|
39
|
+
"api": "Employee email",
|
|
40
|
+
"score": 79.7,
|
|
41
|
+
"perThousand": 7.98,
|
|
42
|
+
"pay": 399,
|
|
43
|
+
"currency": "USD",
|
|
44
|
+
"billing": "monthly",
|
|
45
|
+
"page": "https://datacircle.dev/find-email-apis/getprospect/free-post-v2-email-find"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
`list_apis` emits one event for each API: `api`, `slug`, `plans`, `providers`, `sheetPage`, `bestPage` and its `best` plan.
|
|
49
|
+
|
|
50
|
+
`provider` emits one event for each API the provider sells: `provider`, `providerSlug`, `website`, `api`, `slug`, `rank`, `sheetPage`, `bestPage` and the provider's `plans` on that API.
|
|
51
|
+
MD
|
|
52
|
+
|
|
53
|
+
form_configurable :call, type: :array, values: CALLS
|
|
54
|
+
form_configurable :api
|
|
55
|
+
form_configurable :query
|
|
56
|
+
form_configurable :sort, type: :array, values: SORTS
|
|
57
|
+
form_configurable :provider
|
|
58
|
+
form_configurable :limit
|
|
59
|
+
form_configurable :offset
|
|
60
|
+
form_configurable :expected_update_period_in_days
|
|
61
|
+
|
|
62
|
+
def default_options
|
|
63
|
+
{
|
|
64
|
+
'call' => 'best_plans',
|
|
65
|
+
'api' => 'email-finder',
|
|
66
|
+
'query' => '',
|
|
67
|
+
'sort' => 'score',
|
|
68
|
+
'provider' => '',
|
|
69
|
+
'limit' => '10',
|
|
70
|
+
'offset' => '0',
|
|
71
|
+
'expected_update_period_in_days' => '2'
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate_options
|
|
76
|
+
call = options['call'].to_s
|
|
77
|
+
unless CALLS.include?(call) || liquid?(call)
|
|
78
|
+
errors.add(:base, "call must be one of #{CALLS.join(', ')}")
|
|
79
|
+
end
|
|
80
|
+
errors.add(:base, 'api is required for best_plans') if call == 'best_plans' && options['api'].blank?
|
|
81
|
+
errors.add(:base, 'provider is required for provider') if call == 'provider' && options['provider'].blank?
|
|
82
|
+
|
|
83
|
+
sort = options['sort'].to_s
|
|
84
|
+
if sort.present? && !SORTS.include?(sort) && !liquid?(sort)
|
|
85
|
+
errors.add(:base, "sort must be one of #{SORTS.join(', ')}")
|
|
86
|
+
end
|
|
87
|
+
{ 'limit' => 1..50, 'offset' => 0.. }.each do |key, range|
|
|
88
|
+
value = options[key].to_s
|
|
89
|
+
next if value.blank? || liquid?(value)
|
|
90
|
+
unless value =~ /\A\d+\z/ && range.cover?(value.to_i)
|
|
91
|
+
errors.add(:base, "#{key} must be a whole number from #{range.begin}#{range.end ? " to #{range.end}" : ' up'}")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
errors.add(:base, 'expected_update_period_in_days is required') if options['expected_update_period_in_days'].blank?
|
|
96
|
+
validate_web_request_options!
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def working?
|
|
100
|
+
event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def check
|
|
104
|
+
emit_answer(interpolated)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def receive(incoming_events)
|
|
108
|
+
incoming_events.each do |event|
|
|
109
|
+
interpolate_with(event) do
|
|
110
|
+
emit_answer(interpolated)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def liquid?(value)
|
|
118
|
+
value.include?('{{') || value.include?('{%')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def emit_answer(opts)
|
|
122
|
+
call = opts['call'].to_s
|
|
123
|
+
path, params = request_for(call, opts)
|
|
124
|
+
return error("call must be one of #{CALLS.join(', ')}, not #{call.inspect}") unless path
|
|
125
|
+
|
|
126
|
+
response = get(path, params)
|
|
127
|
+
body = begin
|
|
128
|
+
JSON.parse(response.body)
|
|
129
|
+
rescue JSON::ParserError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
unless response.success? && body
|
|
133
|
+
reason = body.is_a?(Hash) && body['error'] ? body['error'] : response.body.to_s[0, 300]
|
|
134
|
+
return error("Datacircle answered #{response.status} to #{path}: #{reason}")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
items_of(call, body).each do |item|
|
|
138
|
+
create_event payload: item
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def request_for(call, opts)
|
|
143
|
+
case call
|
|
144
|
+
when 'best_plans'
|
|
145
|
+
["/apis/#{ERB::Util.url_encode(opts['api'].to_s.strip)}/best", { 'limit' => opts['limit'] }]
|
|
146
|
+
when 'search_plans'
|
|
147
|
+
['/plans', opts.slice('api', 'query', 'sort', 'limit', 'offset')]
|
|
148
|
+
when 'list_apis'
|
|
149
|
+
['/apis', {}]
|
|
150
|
+
when 'provider'
|
|
151
|
+
["/providers/#{ERB::Util.url_encode(opts['provider'].to_s.strip)}", {}]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# One call, and once more after the wait the API asks for when more than 60 calls a minute came from this address.
|
|
156
|
+
def get(path, params)
|
|
157
|
+
params = params.transform_values { |value| value.to_s.strip }.reject { |_, value| value.empty? }
|
|
158
|
+
response = faraday.get("#{API}#{path}", params, 'Accept' => 'application/json')
|
|
159
|
+
return response unless response.status == 429
|
|
160
|
+
|
|
161
|
+
wait = response.headers['Retry-After'].to_i.clamp(1, 60)
|
|
162
|
+
log "Datacircle asked to wait #{wait} s (60 calls a minute at most), calling again then"
|
|
163
|
+
sleep wait
|
|
164
|
+
faraday.get("#{API}#{path}", params, 'Accept' => 'application/json')
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def items_of(call, body)
|
|
168
|
+
case call
|
|
169
|
+
when 'best_plans', 'search_plans'
|
|
170
|
+
body['plans']
|
|
171
|
+
when 'list_apis'
|
|
172
|
+
body
|
|
173
|
+
when 'provider'
|
|
174
|
+
body['apis'].map do |api|
|
|
175
|
+
{ 'provider' => body['provider'], 'providerSlug' => body['slug'], 'website' => body['website'] }.merge(api)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: huginn_datacircle_agent
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Wayne Hamadi
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: huginn_agent
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
description: 'A Huginn agent that emits the best plans of each GTM API, plan searches
|
|
27
|
+
and provider ranks from Datacircle''s open API, with no key. We benchmark all the
|
|
28
|
+
GTM APIs and we put the results on our website: https://datacircle.dev'
|
|
29
|
+
email:
|
|
30
|
+
- wayne@datacircle.dev
|
|
31
|
+
executables: []
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- LICENSE.txt
|
|
36
|
+
- README.md
|
|
37
|
+
- lib/huginn_datacircle_agent.rb
|
|
38
|
+
- lib/huginn_datacircle_agent/datacircle_agent.rb
|
|
39
|
+
homepage: https://datacircle.dev
|
|
40
|
+
licenses:
|
|
41
|
+
- MIT
|
|
42
|
+
metadata:
|
|
43
|
+
homepage_uri: https://datacircle.dev
|
|
44
|
+
source_code_uri: https://github.com/waynehamadi/huginn_datacircle_agent
|
|
45
|
+
documentation_uri: https://github.com/waynehamadi/huginn_datacircle_agent#readme
|
|
46
|
+
bug_tracker_uri: https://github.com/waynehamadi/huginn_datacircle_agent/issues
|
|
47
|
+
rdoc_options: []
|
|
48
|
+
require_paths:
|
|
49
|
+
- lib
|
|
50
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '2.7'
|
|
55
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '0'
|
|
60
|
+
requirements: []
|
|
61
|
+
rubygems_version: 4.0.24
|
|
62
|
+
specification_version: 4
|
|
63
|
+
summary: Find the best GTM APIs, in Huginn.
|
|
64
|
+
test_files: []
|