misarseo 1.0.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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/LICENSE +21 -0
  4. data/README.md +257 -0
  5. data/lib/misarseo/client.rb +29 -0
  6. data/lib/misarseo/errors.rb +100 -0
  7. data/lib/misarseo/generated/account.rb +30 -0
  8. data/lib/misarseo/generated/activity.rb +22 -0
  9. data/lib/misarseo/generated/ahrefs.rb +35 -0
  10. data/lib/misarseo/generated/ai_search.rb +87 -0
  11. data/lib/misarseo/generated/backlinks.rb +34 -0
  12. data/lib/misarseo/generated/baidu.rb +35 -0
  13. data/lib/misarseo/generated/bing.rb +95 -0
  14. data/lib/misarseo/generated/brave_search.rb +20 -0
  15. data/lib/misarseo/generated/competitor_monitor.rb +37 -0
  16. data/lib/misarseo/generated/competitors.rb +20 -0
  17. data/lib/misarseo/generated/content.rb +25 -0
  18. data/lib/misarseo/generated/crawl.rb +140 -0
  19. data/lib/misarseo/generated/dashboard.rb +20 -0
  20. data/lib/misarseo/generated/direct_vendor_ai.rb +20 -0
  21. data/lib/misarseo/generated/disavow.rb +35 -0
  22. data/lib/misarseo/generated/domain.rb +37 -0
  23. data/lib/misarseo/generated/ga4.rb +50 -0
  24. data/lib/misarseo/generated/google_business_profile.rb +77 -0
  25. data/lib/misarseo/generated/gsc.rb +70 -0
  26. data/lib/misarseo/generated/index_now.rb +20 -0
  27. data/lib/misarseo/generated/keywords.rb +71 -0
  28. data/lib/misarseo/generated/link_building.rb +60 -0
  29. data/lib/misarseo/generated/local.rb +30 -0
  30. data/lib/misarseo/generated/local_pack_rank.rb +40 -0
  31. data/lib/misarseo/generated/local_rank_grid.rb +40 -0
  32. data/lib/misarseo/generated/log_analyzer.rb +42 -0
  33. data/lib/misarseo/generated/mentions.rb +25 -0
  34. data/lib/misarseo/generated/patches.rb +55 -0
  35. data/lib/misarseo/generated/portfolios.rb +50 -0
  36. data/lib/misarseo/generated/projects.rb +40 -0
  37. data/lib/misarseo/generated/rank_tracking.rb +144 -0
  38. data/lib/misarseo/generated/report_builder.rb +67 -0
  39. data/lib/misarseo/generated/resources.rb +85 -0
  40. data/lib/misarseo/generated/team.rb +20 -0
  41. data/lib/misarseo/generated/usage.rb +20 -0
  42. data/lib/misarseo/generated/web_analytics.rb +35 -0
  43. data/lib/misarseo/generated/yandex.rb +45 -0
  44. data/lib/misarseo/pagination.rb +38 -0
  45. data/lib/misarseo/transport.rb +192 -0
  46. data/lib/misarseo/version.rb +5 -0
  47. data/lib/misarseo.rb +13 -0
  48. data/manifest.json +2850 -0
  49. metadata +114 -0
@@ -0,0 +1,144 @@
1
+ # AUTO-GENERATED by scripts/generate-sdks.mjs from sdks/manifest.json.
2
+ # Do not edit by hand: changes are overwritten. To change this surface,
3
+ # change the API route and its registry entry, run `pnpm sdk:sync`, then
4
+ # `pnpm sdk:generate`.
5
+
6
+ module MisarSEO
7
+ module Generated
8
+ # +client.rank_tracking+ — 23 endpoint(s).
9
+ class RankTrackingResource
10
+ def initialize(transport)
11
+ @transport = transport
12
+ end
13
+
14
+ # POST /rank-tracking/annotations
15
+ def add_annotation(body = nil, query: nil, **fields)
16
+ @transport.request("POST", "/rank-tracking/annotations", query: query, body: Transport.body(body, fields))
17
+ end
18
+
19
+ # POST /rank-tracking/competitors
20
+ def add_competitor(body = nil, query: nil, **fields)
21
+ @transport.request("POST", "/rank-tracking/competitors", query: query, body: Transport.body(body, fields))
22
+ end
23
+
24
+ # POST /rank-tracking/keywords
25
+ def add_keywords(body = nil, query: nil, **fields)
26
+ @transport.request("POST", "/rank-tracking/keywords", query: query, body: Transport.body(body, fields))
27
+ end
28
+
29
+ # POST /rank-tracking/configs
30
+ def create(body = nil, query: nil, **fields)
31
+ @transport.request("POST", "/rank-tracking/configs", query: query, body: Transport.body(body, fields))
32
+ end
33
+
34
+ # DELETE /rank-tracking/configs
35
+ def delete(body = nil, query: nil, **fields)
36
+ @transport.request("DELETE", "/rank-tracking/configs", query: query, body: Transport.body(body, fields))
37
+ end
38
+
39
+ # GET /rank-tracking/keywords
40
+ #
41
+ # Optional query parameters: +page+, +pageSize+.
42
+ def get(config_id, project_id, query: nil)
43
+ @transport.request("GET", "/rank-tracking/keywords", query: Transport.merge(query, "configId" => config_id, "projectId" => project_id), body: nil)
44
+ end
45
+
46
+ # GET /rank-tracking/ai-overview-trend
47
+ #
48
+ # Optional query parameters: +device+, +sinceDays+.
49
+ def get_ai_overview_trend(config_id, project_id, query: nil)
50
+ @transport.request("GET", "/rank-tracking/ai-overview-trend", query: Transport.merge(query, "configId" => config_id, "projectId" => project_id), body: nil)
51
+ end
52
+
53
+ # GET /rank-tracking/trend
54
+ #
55
+ # Optional query parameters: +device+, +sinceDays+.
56
+ def get_config_trend(config_id, project_id, query: nil)
57
+ @transport.request("GET", "/rank-tracking/trend", query: Transport.merge(query, "configId" => config_id, "projectId" => project_id), body: nil)
58
+ end
59
+
60
+ # GET /rank-tracking/keyword-history
61
+ #
62
+ # Optional query parameters: +sinceDays+.
63
+ def get_keyword_history(config_id, project_id, tracking_keyword_id, query: nil)
64
+ @transport.request("GET", "/rank-tracking/keyword-history", query: Transport.merge(query, "configId" => config_id, "projectId" => project_id, "trackingKeywordId" => tracking_keyword_id), body: nil)
65
+ end
66
+
67
+ # GET /rank-tracking/runs
68
+ def get_latest_run(config_id, project_id, query: nil)
69
+ @transport.request("GET", "/rank-tracking/runs", query: Transport.merge(query, "configId" => config_id, "projectId" => project_id), body: nil)
70
+ end
71
+
72
+ # GET /rank-tracking/movers
73
+ #
74
+ # Optional query parameters: +minGain+, +sinceDays+.
75
+ def get_rank_movers(config_id, project_id, query: nil)
76
+ @transport.request("GET", "/rank-tracking/movers", query: Transport.merge(query, "configId" => config_id, "projectId" => project_id), body: nil)
77
+ end
78
+
79
+ # GET /rank-tracking/volatility
80
+ #
81
+ # Optional query parameters: +device+, +runLimit+.
82
+ def get_volatility_index(config_id, project_id, query: nil)
83
+ @transport.request("GET", "/rank-tracking/volatility", query: Transport.merge(query, "configId" => config_id, "projectId" => project_id), body: nil)
84
+ end
85
+
86
+ # GET /rank-tracking/annotations
87
+ def list_annotations(config_id, project_id, query: nil)
88
+ @transport.request("GET", "/rank-tracking/annotations", query: Transport.merge(query, "configId" => config_id, "projectId" => project_id), body: nil)
89
+ end
90
+
91
+ # GET /rank-tracking/cannibalisation
92
+ def list_cannibalisation(config_id, project_id, query: nil)
93
+ @transport.request("GET", "/rank-tracking/cannibalisation", query: Transport.merge(query, "configId" => config_id, "projectId" => project_id), body: nil)
94
+ end
95
+
96
+ # GET /rank-tracking/competitors
97
+ #
98
+ # Optional query parameters: +device+.
99
+ def list_competitors(config_id, project_id, query: nil)
100
+ @transport.request("GET", "/rank-tracking/competitors", query: Transport.merge(query, "configId" => config_id, "projectId" => project_id), body: nil)
101
+ end
102
+
103
+ # GET /rank-tracking/configs
104
+ def list_configs(project_id, query: nil)
105
+ @transport.request("GET", "/rank-tracking/configs", query: Transport.merge(query, "projectId" => project_id), body: nil)
106
+ end
107
+
108
+ # GET /rank-tracking/keyword-tags
109
+ def list_keyword_tags(config_id, project_id, query: nil)
110
+ @transport.request("GET", "/rank-tracking/keyword-tags", query: Transport.merge(query, "configId" => config_id, "projectId" => project_id), body: nil)
111
+ end
112
+
113
+ # DELETE /rank-tracking/annotations
114
+ def remove_annotation(body = nil, query: nil, **fields)
115
+ @transport.request("DELETE", "/rank-tracking/annotations", query: query, body: Transport.body(body, fields))
116
+ end
117
+
118
+ # DELETE /rank-tracking/competitors
119
+ def remove_competitor(body = nil, query: nil, **fields)
120
+ @transport.request("DELETE", "/rank-tracking/competitors", query: query, body: Transport.body(body, fields))
121
+ end
122
+
123
+ # DELETE /rank-tracking/keywords
124
+ def remove_keywords(body = nil, query: nil, **fields)
125
+ @transport.request("DELETE", "/rank-tracking/keywords", query: query, body: Transport.body(body, fields))
126
+ end
127
+
128
+ # POST /rank-tracking/keyword-target-url
129
+ def set_keyword_target_url(body = nil, query: nil, **fields)
130
+ @transport.request("POST", "/rank-tracking/keyword-target-url", query: query, body: Transport.body(body, fields))
131
+ end
132
+
133
+ # PATCH /rank-tracking/configs
134
+ def update(body = nil, query: nil, **fields)
135
+ @transport.request("PATCH", "/rank-tracking/configs", query: query, body: Transport.body(body, fields))
136
+ end
137
+
138
+ # POST /rank-tracking/keyword-tags
139
+ def update_keyword_tag(body = nil, query: nil, **fields)
140
+ @transport.request("POST", "/rank-tracking/keyword-tags", query: query, body: Transport.body(body, fields))
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,67 @@
1
+ # AUTO-GENERATED by scripts/generate-sdks.mjs from sdks/manifest.json.
2
+ # Do not edit by hand: changes are overwritten. To change this surface,
3
+ # change the API route and its registry entry, run `pnpm sdk:sync`, then
4
+ # `pnpm sdk:generate`.
5
+
6
+ module MisarSEO
7
+ module Generated
8
+ # +client.report_builder+ — 10 endpoint(s).
9
+ class ReportBuilderResource
10
+ def initialize(transport)
11
+ @transport = transport
12
+ end
13
+
14
+ # POST /reports/definitions
15
+ def create_definition(body = nil, query: nil, **fields)
16
+ @transport.request("POST", "/reports/definitions", query: query, body: Transport.body(body, fields))
17
+ end
18
+
19
+ # DELETE /reports/definitions/:reportDefinitionId
20
+ def delete_definition(report_definition_id, project_id, query: nil)
21
+ @transport.request("DELETE", "/reports/definitions/#{Transport.seg(report_definition_id)}", query: Transport.merge(query, "projectId" => project_id), body: nil)
22
+ end
23
+
24
+ # DELETE /reports/definitions/:reportDefinitionId/schedule
25
+ def delete_schedule(report_definition_id, project_id, query: nil)
26
+ @transport.request("DELETE", "/reports/definitions/#{Transport.seg(report_definition_id)}/schedule", query: Transport.merge(query, "projectId" => project_id), body: nil)
27
+ end
28
+
29
+ # GET /reports/definitions/:reportDefinitionId
30
+ def get_definition(report_definition_id, project_id, query: nil)
31
+ @transport.request("GET", "/reports/definitions/#{Transport.seg(report_definition_id)}", query: Transport.merge(query, "projectId" => project_id), body: nil)
32
+ end
33
+
34
+ # GET /reports/definitions/:reportDefinitionId/schedule
35
+ def get_schedule(report_definition_id, project_id, query: nil)
36
+ @transport.request("GET", "/reports/definitions/#{Transport.seg(report_definition_id)}/schedule", query: Transport.merge(query, "projectId" => project_id), body: nil)
37
+ end
38
+
39
+ # GET /reports/definitions
40
+ def list_definitions(project_id, query: nil)
41
+ @transport.request("GET", "/reports/definitions", query: Transport.merge(query, "projectId" => project_id), body: nil)
42
+ end
43
+
44
+ # PATCH /reports/definitions/:reportDefinitionId
45
+ def rename_definition(report_definition_id, body = nil, query: nil, **fields)
46
+ @transport.request("PATCH", "/reports/definitions/#{Transport.seg(report_definition_id)}", query: query, body: Transport.body(body, fields))
47
+ end
48
+
49
+ # GET /reports/definitions/:reportDefinitionId/render
50
+ #
51
+ # Optional query parameters: +periodDays+.
52
+ def render_definition(report_definition_id, project_id, query: nil)
53
+ @transport.request("GET", "/reports/definitions/#{Transport.seg(report_definition_id)}/render", query: Transport.merge(query, "projectId" => project_id), body: nil)
54
+ end
55
+
56
+ # POST /reports/definitions/:reportDefinitionId/widgets
57
+ def set_widgets(report_definition_id, body = nil, query: nil, **fields)
58
+ @transport.request("POST", "/reports/definitions/#{Transport.seg(report_definition_id)}/widgets", query: query, body: Transport.body(body, fields))
59
+ end
60
+
61
+ # POST /reports/definitions/:reportDefinitionId/schedule
62
+ def upsert_schedule(report_definition_id, body = nil, query: nil, **fields)
63
+ @transport.request("POST", "/reports/definitions/#{Transport.seg(report_definition_id)}/schedule", query: query, body: Transport.body(body, fields))
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,85 @@
1
+ # AUTO-GENERATED by scripts/generate-sdks.mjs from sdks/manifest.json.
2
+ # Do not edit by hand: changes are overwritten. To change this surface,
3
+ # change the API route and its registry entry, run `pnpm sdk:sync`, then
4
+ # `pnpm sdk:generate`.
5
+
6
+ require_relative "account"
7
+ require_relative "activity"
8
+ require_relative "ahrefs"
9
+ require_relative "ai_search"
10
+ require_relative "backlinks"
11
+ require_relative "baidu"
12
+ require_relative "bing"
13
+ require_relative "brave_search"
14
+ require_relative "competitor_monitor"
15
+ require_relative "competitors"
16
+ require_relative "content"
17
+ require_relative "crawl"
18
+ require_relative "dashboard"
19
+ require_relative "direct_vendor_ai"
20
+ require_relative "disavow"
21
+ require_relative "domain"
22
+ require_relative "ga4"
23
+ require_relative "google_business_profile"
24
+ require_relative "gsc"
25
+ require_relative "index_now"
26
+ require_relative "keywords"
27
+ require_relative "link_building"
28
+ require_relative "local"
29
+ require_relative "local_pack_rank"
30
+ require_relative "local_rank_grid"
31
+ require_relative "log_analyzer"
32
+ require_relative "mentions"
33
+ require_relative "patches"
34
+ require_relative "portfolios"
35
+ require_relative "projects"
36
+ require_relative "rank_tracking"
37
+ require_relative "report_builder"
38
+ require_relative "team"
39
+ require_relative "usage"
40
+ require_relative "web_analytics"
41
+ require_relative "yandex"
42
+
43
+ module MisarSEO
44
+ module Generated
45
+ # Client attribute name => resource class.
46
+ RESOURCES = {
47
+ account: AccountResource,
48
+ activity: ActivityResource,
49
+ ahrefs: AhrefsResource,
50
+ ai_search: AiSearchResource,
51
+ backlinks: BacklinksResource,
52
+ baidu: BaiduResource,
53
+ bing: BingResource,
54
+ brave_search: BraveSearchResource,
55
+ competitor_monitor: CompetitorMonitorResource,
56
+ competitors: CompetitorsResource,
57
+ content: ContentResource,
58
+ crawl: CrawlResource,
59
+ dashboard: DashboardResource,
60
+ direct_vendor_ai: DirectVendorAiResource,
61
+ disavow: DisavowResource,
62
+ domain: DomainResource,
63
+ ga4: Ga4Resource,
64
+ google_business_profile: GoogleBusinessProfileResource,
65
+ gsc: GscResource,
66
+ index_now: IndexNowResource,
67
+ keywords: KeywordsResource,
68
+ link_building: LinkBuildingResource,
69
+ local: LocalResource,
70
+ local_pack_rank: LocalPackRankResource,
71
+ local_rank_grid: LocalRankGridResource,
72
+ log_analyzer: LogAnalyzerResource,
73
+ mentions: MentionsResource,
74
+ patches: PatchesResource,
75
+ portfolios: PortfoliosResource,
76
+ projects: ProjectsResource,
77
+ rank_tracking: RankTrackingResource,
78
+ report_builder: ReportBuilderResource,
79
+ team: TeamResource,
80
+ usage: UsageResource,
81
+ web_analytics: WebAnalyticsResource,
82
+ yandex: YandexResource,
83
+ }.freeze
84
+ end
85
+ end
@@ -0,0 +1,20 @@
1
+ # AUTO-GENERATED by scripts/generate-sdks.mjs from sdks/manifest.json.
2
+ # Do not edit by hand: changes are overwritten. To change this surface,
3
+ # change the API route and its registry entry, run `pnpm sdk:sync`, then
4
+ # `pnpm sdk:generate`.
5
+
6
+ module MisarSEO
7
+ module Generated
8
+ # +client.team+ — 1 endpoint(s).
9
+ class TeamResource
10
+ def initialize(transport)
11
+ @transport = transport
12
+ end
13
+
14
+ # GET /team/members
15
+ def list_members(query: nil)
16
+ @transport.request("GET", "/team/members", query: query, body: nil)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # AUTO-GENERATED by scripts/generate-sdks.mjs from sdks/manifest.json.
2
+ # Do not edit by hand: changes are overwritten. To change this surface,
3
+ # change the API route and its registry entry, run `pnpm sdk:sync`, then
4
+ # `pnpm sdk:generate`.
5
+
6
+ module MisarSEO
7
+ module Generated
8
+ # +client.usage+ — 1 endpoint(s).
9
+ class UsageResource
10
+ def initialize(transport)
11
+ @transport = transport
12
+ end
13
+
14
+ # GET /usage
15
+ def get(query: nil)
16
+ @transport.request("GET", "/usage", query: query, body: nil)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,35 @@
1
+ # AUTO-GENERATED by scripts/generate-sdks.mjs from sdks/manifest.json.
2
+ # Do not edit by hand: changes are overwritten. To change this surface,
3
+ # change the API route and its registry entry, run `pnpm sdk:sync`, then
4
+ # `pnpm sdk:generate`.
5
+
6
+ module MisarSEO
7
+ module Generated
8
+ # +client.web_analytics+ — 4 endpoint(s).
9
+ class WebAnalyticsResource
10
+ def initialize(transport)
11
+ @transport = transport
12
+ end
13
+
14
+ # POST /web-analytics/devices
15
+ def devices(body = nil, query: nil, **fields)
16
+ @transport.request("POST", "/web-analytics/devices", query: query, body: Transport.body(body, fields))
17
+ end
18
+
19
+ # POST /web-analytics/sources
20
+ def sources(body = nil, query: nil, **fields)
21
+ @transport.request("POST", "/web-analytics/sources", query: query, body: Transport.body(body, fields))
22
+ end
23
+
24
+ # POST /web-analytics/summary
25
+ def summary(body = nil, query: nil, **fields)
26
+ @transport.request("POST", "/web-analytics/summary", query: query, body: Transport.body(body, fields))
27
+ end
28
+
29
+ # POST /web-analytics/top-pages
30
+ def top_pages(body = nil, query: nil, **fields)
31
+ @transport.request("POST", "/web-analytics/top-pages", query: query, body: Transport.body(body, fields))
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,45 @@
1
+ # AUTO-GENERATED by scripts/generate-sdks.mjs from sdks/manifest.json.
2
+ # Do not edit by hand: changes are overwritten. To change this surface,
3
+ # change the API route and its registry entry, run `pnpm sdk:sync`, then
4
+ # `pnpm sdk:generate`.
5
+
6
+ module MisarSEO
7
+ module Generated
8
+ # +client.yandex+ — 6 endpoint(s).
9
+ class YandexResource
10
+ def initialize(transport)
11
+ @transport = transport
12
+ end
13
+
14
+ # GET /yandex/connection
15
+ def connect(project_id, query: nil)
16
+ @transport.request("GET", "/yandex/connection", query: Transport.merge(query, "projectId" => project_id), body: nil)
17
+ end
18
+
19
+ # DELETE /yandex/connection
20
+ def disconnect(body = nil, query: nil, **fields)
21
+ @transport.request("DELETE", "/yandex/connection", query: query, body: Transport.body(body, fields))
22
+ end
23
+
24
+ # GET /yandex/hosts
25
+ def list_hosts(project_id, query: nil)
26
+ @transport.request("GET", "/yandex/hosts", query: Transport.merge(query, "projectId" => project_id), body: nil)
27
+ end
28
+
29
+ # POST /yandex/reindex-url
30
+ def reindex_url(body = nil, query: nil, **fields)
31
+ @transport.request("POST", "/yandex/reindex-url", query: query, body: Transport.body(body, fields))
32
+ end
33
+
34
+ # POST /yandex/connection
35
+ def set_host(body = nil, query: nil, **fields)
36
+ @transport.request("POST", "/yandex/connection", query: query, body: Transport.body(body, fields))
37
+ end
38
+
39
+ # POST /yandex/site-overview
40
+ def site_overview(body = nil, query: nil, **fields)
41
+ @transport.request("POST", "/yandex/site-overview", query: query, body: Transport.body(body, fields))
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MisarSEO
4
+ # The API's two pagination shapes. Offset pages (rows, hasMore, page)
5
+ # address rows by position; keyset pages (rows, hasMore, nextCursor) by key,
6
+ # so they never skip or repeat when rows are inserted mid-walk.
7
+ module Pagination
8
+ module_function
9
+
10
+ # Every row of an offset-paginated endpoint, page by page.
11
+ def collect_offset_pages(start_page = 1)
12
+ rows = []
13
+ page = start_page
14
+ loop do
15
+ result = yield(page) || {}
16
+ batch = Array(result["rows"])
17
+ rows.concat(batch)
18
+ return rows if !result["hasMore"] || batch.empty?
19
+
20
+ page += 1
21
+ end
22
+ end
23
+
24
+ # Every row following nextCursor; stops when it is absent or repeats.
25
+ def collect_cursor_pages(start_cursor = nil)
26
+ rows = []
27
+ cursor = start_cursor
28
+ loop do
29
+ result = yield(cursor) || {}
30
+ rows.concat(Array(result["rows"]))
31
+ nxt = result["nextCursor"]
32
+ return rows if !result["hasMore"] || nxt.nil? || nxt == cursor
33
+
34
+ cursor = nxt
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module MisarSEO
8
+ # One Server-Sent Events frame.
9
+ Event = Struct.new(:event, :data)
10
+
11
+ # Request plumbing the generated resources are written against.
12
+ #
13
+ # Retry policy, identical in every MisarSEO SDK: 429 is retried for every
14
+ # verb unless the body says plan_limit_exceeded; 502/503/504 and network
15
+ # failures are retried for GET only (a POST may have started a billed
16
+ # crawl); backoff starts at 250 ms with jitter and honours Retry-After up to
17
+ # the configured cap.
18
+ #
19
+ # +adapter+ is anything responding to
20
+ # +call(method, url, headers, body) -> [status, headers, body_string]+;
21
+ # the default is Net::HTTP. Tests inject a recorder.
22
+ class Transport
23
+ DEFAULT_BASE_URL = "https://api.misar.io/seo"
24
+
25
+ def self.seg(value)
26
+ URI.encode_www_form_component(value.to_s).gsub("+", "%20")
27
+ end
28
+
29
+ # The JSON body of a call. Ruby 3 binds a brace-less trailing hash
30
+ # (+create("name" => "x")+) to keyword arguments, not to the positional
31
+ # +body+, so every body method also takes +**fields+ and they land here.
32
+ # Both spellings, and +create(name: "x")+, therefore send the same body.
33
+ def self.body(body, fields)
34
+ return body if fields.empty?
35
+ unless body.nil? || body.is_a?(Hash)
36
+ raise ArgumentError, "pass the request body as one Hash or as keyword fields, not both"
37
+ end
38
+
39
+ (body || {}).transform_keys(&:to_s).merge(fields.transform_keys(&:to_s))
40
+ end
41
+
42
+ # Required query parameters first; an explicit key in +query+ wins.
43
+ def self.merge(query, required)
44
+ required.merge((query || {}).transform_keys(&:to_s))
45
+ end
46
+
47
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: 30, max_retries: 2,
48
+ retry_base_delay: 0.25, max_retry_delay: 60, adapter: nil)
49
+ @api_key = api_key
50
+ @base_url = base_url.sub(%r{/+\z}, "")
51
+ @timeout = timeout
52
+ @max_retries = max_retries
53
+ @retry_base_delay = retry_base_delay
54
+ @max_retry_delay = max_retry_delay
55
+ @adapter = adapter || method(:net_http)
56
+ end
57
+
58
+ def headers(accept = "application/json")
59
+ {
60
+ "Authorization" => "Bearer #{@api_key}",
61
+ "Content-Type" => "application/json",
62
+ "Accept" => accept,
63
+ "User-Agent" => "misarseo-ruby/#{VERSION}"
64
+ }
65
+ end
66
+
67
+ def url(path, query)
68
+ q = (query || {}).reject { |_, v| v.nil? }
69
+ return "#{@base_url}#{path}" if q.empty?
70
+
71
+ "#{@base_url}#{path}?" + q.map { |k, v| "#{Transport.seg(k)}=#{Transport.seg(v)}" }.join("&")
72
+ end
73
+
74
+ def request(method, path, query: nil, body: nil)
75
+ target = url(path, query)
76
+ payload = body.nil? ? nil : JSON.generate(body)
77
+ attempt = 0
78
+ loop do
79
+ begin
80
+ status, resp_headers, raw = @adapter.call(method, target, headers, payload)
81
+ rescue StandardError => e
82
+ raise NetworkError.new(0, "NETWORK_ERROR", "could not reach the MisarSEO API: #{e.message}") \
83
+ unless method == "GET" && attempt < @max_retries
84
+
85
+ sleep(backoff(attempt, nil))
86
+ attempt += 1
87
+ next
88
+ end
89
+ return decode(raw) if status.between?(200, 299)
90
+
91
+ error = MisarSEO.error_from(status, resp_headers, decode(raw))
92
+ retryable = status == 429 || (method == "GET" && [502, 503, 504].include?(status))
93
+ raise error if error.is_a?(PlanLimitError) || !retryable || attempt >= @max_retries
94
+
95
+ wait = backoff(attempt, error.retry_after)
96
+ raise error if wait.nil?
97
+
98
+ sleep(wait)
99
+ attempt += 1
100
+ end
101
+ end
102
+
103
+ # GET a text/event-stream endpoint, yielding Event frames until a terminal one.
104
+ def stream(path, query: nil, terminal: %w[complete error])
105
+ buffer = +""
106
+ done = false
107
+ @adapter.call("GET", url(path, query), headers("text/event-stream"), nil) do |status, resp_headers, chunk|
108
+ next if done
109
+
110
+ unless status.between?(200, 299)
111
+ raise MisarSEO.error_from(status, resp_headers, decode(chunk))
112
+ end
113
+
114
+ buffer << chunk.to_s.delete("\r")
115
+ while (idx = buffer.index("\n\n"))
116
+ frame = buffer.slice!(0, idx + 2)
117
+ event = Transport.parse_frame(frame)
118
+ next unless event
119
+
120
+ yield event
121
+ if terminal.include?(event.event)
122
+ done = true
123
+ break
124
+ end
125
+ end
126
+ end
127
+ event = Transport.parse_frame(buffer) unless done
128
+ yield event if event
129
+ nil
130
+ end
131
+
132
+ # Decode one SSE frame; nil for keep-alive comments and frames without an event line.
133
+ def self.parse_frame(frame)
134
+ name = nil
135
+ data = []
136
+ frame.to_s.split("\n").each do |line|
137
+ next if line.empty? || line.start_with?(":")
138
+
139
+ if line.start_with?("event:")
140
+ name = line[6..-1].strip
141
+ elsif line.start_with?("data:")
142
+ data << line[5..-1].sub(/\A +/, "")
143
+ end
144
+ end
145
+ return nil unless name
146
+
147
+ text = data.join("\n")
148
+ value = begin
149
+ text.empty? ? text : JSON.parse(text)
150
+ rescue JSON::ParserError
151
+ text
152
+ end
153
+ Event.new(name, value)
154
+ end
155
+
156
+ private
157
+
158
+ def decode(text)
159
+ return nil if text.nil? || text.strip.empty?
160
+
161
+ JSON.parse(text)
162
+ rescue JSON::ParserError
163
+ text
164
+ end
165
+
166
+ def backoff(attempt, retry_after)
167
+ return (retry_after > @max_retry_delay ? nil : retry_after) if retry_after
168
+
169
+ base = @retry_base_delay * (2**attempt)
170
+ [@max_retry_delay, base / 2.0 + rand * base / 2.0].min
171
+ end
172
+
173
+ def net_http(method, target, headers, payload, &block)
174
+ uri = URI(target)
175
+ http = Net::HTTP.new(uri.host, uri.port)
176
+ http.use_ssl = uri.scheme == "https"
177
+ http.open_timeout = @timeout
178
+ http.read_timeout = block ? nil : @timeout
179
+ klass = Net::HTTP.const_get(method.capitalize)
180
+ req = klass.new(uri.request_uri, headers)
181
+ req.body = payload if payload
182
+ if block
183
+ http.request(req) do |resp|
184
+ resp.read_body { |chunk| block.call(resp.code.to_i, resp.to_hash, chunk) }
185
+ end
186
+ return
187
+ end
188
+ resp = http.request(req)
189
+ [resp.code.to_i, resp.to_hash, resp.body]
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MisarSEO
4
+ VERSION = "1.0.0"
5
+ end
data/lib/misarseo.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "misarseo/version"
4
+ require_relative "misarseo/errors"
5
+ require_relative "misarseo/transport"
6
+ require_relative "misarseo/pagination"
7
+ require_relative "misarseo/generated/resources"
8
+ require_relative "misarseo/client"
9
+
10
+ # MisarSEO API client for Ruby: keyword research, site audits, rank tracking,
11
+ # backlinks, AI search visibility, Search Console, GA4 and Business Profile.
12
+ module MisarSEO
13
+ end