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 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
@@ -0,0 +1,3 @@
1
+ require 'huginn_agent'
2
+
3
+ HuginnAgent.register 'huginn_datacircle_agent/datacircle_agent'
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: []