blog_verification 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.
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../constants/error_codes'
4
+ require_relative '../errors'
5
+ require_relative '../utils/url_security'
6
+ require_relative '../utils/date_helper'
7
+ require_relative '../adapters/devto_adapter'
8
+ require_relative '../adapters/medium_adapter'
9
+ require_relative 'validation_service'
10
+
11
+ module BlogVerification
12
+ module Services
13
+ class VerificationService
14
+ include ErrorCodes
15
+
16
+ attr_reader :adapters, :validation_service
17
+
18
+ def initialize(adapters = nil, validation_service = nil)
19
+ @adapters = adapters || {
20
+ 'devto' => Adapters::DevtoAdapter.new,
21
+ 'medium' => Adapters::MediumAdapter.new
22
+ }
23
+ @validation_service = validation_service || ValidationService.new
24
+ end
25
+
26
+ # Validates input attributes and returns { valid: boolean, errors: [...], sanitized: {...} }
27
+ def validate_input(params)
28
+ errors = []
29
+ unless params.is_a?(Hash)
30
+ return {
31
+ valid: false,
32
+ errors: [
33
+ {
34
+ 'code' => INVALID_REQUEST,
35
+ 'field' => nil,
36
+ 'message' => 'Verification input must be a Hash or Object.'
37
+ }
38
+ ]
39
+ }
40
+ end
41
+
42
+ # Normalize keys to string
43
+ h = {}
44
+ params.each { |k, v| h[k.to_s] = v }
45
+
46
+ platform = h['platform']
47
+ url = h['url']
48
+ username = h['username']
49
+ published_at = h['publishedAt'] || h['published_at']
50
+ topic = h['topic']
51
+ summary = h['summary']
52
+ tags = h['tags']
53
+ min_word_count = h['minWordCount'] || h['min_word_count']
54
+ reference_date = h['referenceDate'] || h['reference_date']
55
+
56
+ # 1. Platform validation
57
+ if platform.nil? || platform.to_s.strip.empty?
58
+ errors << {
59
+ 'code' => MISSING_REQUIRED_FIELD,
60
+ 'field' => 'platform',
61
+ 'message' => 'Platform is required.'
62
+ }
63
+ else
64
+ plat = platform.to_s.downcase.strip
65
+ unless %w[devto medium].include?(plat)
66
+ errors << {
67
+ 'code' => INVALID_PLATFORM,
68
+ 'field' => 'platform',
69
+ 'message' => "Platform must be either 'medium' or 'devto'."
70
+ }
71
+ end
72
+ end
73
+
74
+ # 2. URL validation
75
+ if url.nil? || url.to_s.strip.empty?
76
+ errors << {
77
+ 'code' => MISSING_REQUIRED_FIELD,
78
+ 'field' => 'url',
79
+ 'message' => 'URL is required.'
80
+ }
81
+ end
82
+
83
+ # 3. Username validation
84
+ if username.nil? || username.to_s.strip.empty?
85
+ errors << {
86
+ 'code' => MISSING_REQUIRED_FIELD,
87
+ 'field' => 'username',
88
+ 'message' => 'Username is required.'
89
+ }
90
+ end
91
+
92
+ # 4. publishedAt validation
93
+ if published_at.nil? || published_at.to_s.strip.empty?
94
+ errors << {
95
+ 'code' => MISSING_REQUIRED_FIELD,
96
+ 'field' => 'publishedAt',
97
+ 'message' => 'publishedAt date is required.'
98
+ }
99
+ elsif !Utils::DateHelper.valid_calendar_date?(published_at.to_s.strip)
100
+ errors << {
101
+ 'code' => INVALID_REQUEST,
102
+ 'field' => 'publishedAt',
103
+ 'message' => 'publishedAt must be a valid calendar date in YYYY-MM-DD format.'
104
+ }
105
+ end
106
+
107
+ # 5. Topic validation
108
+ if topic.nil? || topic.to_s.strip.empty?
109
+ errors << {
110
+ 'code' => MISSING_REQUIRED_FIELD,
111
+ 'field' => 'topic',
112
+ 'message' => 'Topic is required.'
113
+ }
114
+ end
115
+
116
+ # 6. Summary validation
117
+ if summary.nil? || summary.to_s.strip.empty?
118
+ errors << {
119
+ 'code' => MISSING_REQUIRED_FIELD,
120
+ 'field' => 'summary',
121
+ 'message' => 'Summary is required.'
122
+ }
123
+ end
124
+
125
+ # 7. Tags validation (optional)
126
+ if !tags.nil? && !tags.is_a?(Array)
127
+ errors << {
128
+ 'code' => INVALID_REQUEST,
129
+ 'field' => 'tags',
130
+ 'message' => 'Tags must be an array of strings.'
131
+ }
132
+ end
133
+
134
+ # 8. minWordCount validation (optional)
135
+ if !min_word_count.nil? && (!min_word_count.is_a?(Numeric) || min_word_count < 1)
136
+ errors << {
137
+ 'code' => INVALID_REQUEST,
138
+ 'field' => 'minWordCount',
139
+ 'message' => 'minWordCount must be a positive integer.'
140
+ }
141
+ end
142
+
143
+ # 9. referenceDate validation (optional)
144
+ if !reference_date.nil? && !Utils::DateHelper.valid_calendar_date?(reference_date.to_s.strip)
145
+ errors << {
146
+ 'code' => INVALID_REQUEST,
147
+ 'field' => 'referenceDate',
148
+ 'message' => 'referenceDate must be a valid calendar date in YYYY-MM-DD format.'
149
+ }
150
+ end
151
+
152
+ if errors.empty?
153
+ {
154
+ valid: true,
155
+ sanitized: {
156
+ 'platform' => platform.to_s.downcase.strip,
157
+ 'url' => url.to_s.strip,
158
+ 'username' => username.to_s.strip,
159
+ 'publishedAt' => published_at.to_s.strip,
160
+ 'topic' => topic.to_s.strip,
161
+ 'summary' => summary.to_s.strip,
162
+ 'tags' => tags,
163
+ 'minWordCount' => min_word_count ? min_word_count.to_i : 500,
164
+ 'referenceDate' => reference_date ? reference_date.to_s.strip : nil
165
+ }
166
+ }
167
+ else
168
+ { valid: false, errors: errors }
169
+ end
170
+ end
171
+
172
+ # Retrieves adapter for the requested platform
173
+ def get_adapter(platform)
174
+ plat = platform.to_s.downcase.strip
175
+ adapter = @adapters[plat]
176
+ unless adapter
177
+ raise ValidationError.new(INVALID_PLATFORM, "Unsupported platform: '#{platform}'.", 'platform')
178
+ end
179
+ adapter
180
+ end
181
+
182
+ # Performs end-to-end verification of blog metadata
183
+ # Returns JSON-compatible verification result
184
+ def verify(params)
185
+ validation = validate_input(params)
186
+ unless validation[:valid]
187
+ return {
188
+ 'success' => false,
189
+ 'status' => 'error',
190
+ 'errors' => validation[:errors]
191
+ }
192
+ end
193
+
194
+ request_data = validation[:sanitized]
195
+ platform = request_data['platform']
196
+ url = request_data['url']
197
+
198
+ adapter = get_adapter(platform)
199
+
200
+ # Validate URL format and platform host
201
+ begin
202
+ Utils::UrlSecurity.validate_article_url(url, platform)
203
+ rescue ValidationError => e
204
+ return {
205
+ 'success' => false,
206
+ 'status' => 'error',
207
+ 'errors' => [
208
+ {
209
+ 'code' => e.error_code,
210
+ 'field' => e.field || 'url',
211
+ 'message' => e.message
212
+ }
213
+ ]
214
+ }
215
+ rescue SSRFError => e
216
+ return {
217
+ 'success' => false,
218
+ 'status' => 'error',
219
+ 'errors' => [
220
+ {
221
+ 'code' => e.error_code,
222
+ 'field' => 'url',
223
+ 'message' => e.message
224
+ }
225
+ ]
226
+ }
227
+ end
228
+
229
+ metadata = nil
230
+ begin
231
+ metadata = adapter.get_article_metadata(url)
232
+ rescue UpstreamError => e
233
+ return @validation_service.build_source_failure_response(request_data, e)
234
+ rescue SSRFError => e
235
+ return {
236
+ 'success' => false,
237
+ 'status' => 'error',
238
+ 'errors' => [
239
+ {
240
+ 'code' => e.error_code,
241
+ 'field' => 'url',
242
+ 'message' => e.message
243
+ }
244
+ ]
245
+ }
246
+ rescue StandardError => e
247
+ return {
248
+ 'success' => false,
249
+ 'status' => 'error',
250
+ 'errors' => [
251
+ {
252
+ 'code' => INTERNAL_ERROR,
253
+ 'field' => nil,
254
+ 'message' => "Internal verification error: #{e.message}"
255
+ }
256
+ ]
257
+ }
258
+ end
259
+
260
+ @validation_service.evaluate(request_data, metadata)
261
+ end
262
+
263
+ # Same as verify, but raises exceptions on input or network failures
264
+ def verify!(params)
265
+ result = verify(params)
266
+ if result['status'] == 'error' && result['errors']
267
+ first_err = result['errors'].first
268
+ raise ValidationError.new(first_err['code'], first_err['message'], first_err['field'])
269
+ end
270
+ result
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'time'
5
+
6
+ module BlogVerification
7
+ module Utils
8
+ module DateHelper
9
+ DATE_REGEX = /\A\d{4}-\d{2}-\d{2}\z/.freeze
10
+
11
+ class << self
12
+ # Validates that a string is strictly in YYYY-MM-DD format and represents a valid calendar day
13
+ def valid_calendar_date?(str)
14
+ return false unless str.is_a?(String)
15
+ return false unless str.match?(DATE_REGEX)
16
+
17
+ parts = str.split('-').map(&:to_i)
18
+ year, month, day = parts[0], parts[1], parts[2]
19
+
20
+ return false if year < 1000 || year > 9999
21
+ return false if month < 1 || month > 12
22
+
23
+ days_in_month = days_in_month_for(year, month)
24
+ day >= 1 && day <= days_in_month
25
+ end
26
+
27
+ def days_in_month_for(year, month)
28
+ case month
29
+ when 1, 3, 5, 7, 8, 10, 12 then 31
30
+ when 4, 6, 9, 11 then 30
31
+ when 2
32
+ leap_year?(year) ? 29 : 28
33
+ else
34
+ 0
35
+ end
36
+ end
37
+
38
+ def leap_year?(year)
39
+ (year % 4).zero? && ((year % 100) != 0 || (year % 400).zero?)
40
+ end
41
+
42
+ # Normalizes any date input into a YYYY-MM-DD UTC calendar date string
43
+ def normalize_utc_date(date_input)
44
+ return nil if date_input.nil?
45
+
46
+ if date_input.is_a?(String)
47
+ # If already YYYY-MM-DD and valid, return as is
48
+ return date_input if valid_calendar_date?(date_input)
49
+
50
+ # Try parsing ISO 8601 or RFC timestamps
51
+ begin
52
+ parsed_time = Time.parse(date_input).utc
53
+ return parsed_time.strftime('%Y-%m-%d')
54
+ rescue ArgumentError
55
+ return nil
56
+ end
57
+ elsif date_input.is_a?(Time)
58
+ date_input.utc.strftime('%Y-%m-%d')
59
+ elsif date_input.is_a?(Date)
60
+ date_input.strftime('%Y-%m-%d')
61
+ elsif date_input.is_a?(Numeric)
62
+ # Handle milliseconds vs seconds
63
+ sec = date_input < 1e11 ? date_input : (date_input / 1000.0)
64
+ Time.at(sec).utc.strftime('%Y-%m-%d')
65
+ end
66
+ end
67
+
68
+ # Compares expected YYYY-MM-DD calendar date with actual date input
69
+ def compare_calendar_dates(expected_date, actual_date_input)
70
+ normalized_actual = normalize_utc_date(actual_date_input)
71
+ return { match: false, normalized_actual: nil } if normalized_actual.nil?
72
+
73
+ {
74
+ match: expected_date == normalized_actual,
75
+ normalized_actual: normalized_actual
76
+ }
77
+ end
78
+
79
+ # Returns quarter details for any date or timestamp in UTC
80
+ # Schedule:
81
+ # - Quarter 1: 1st April to June (months 4, 5, 6)
82
+ # - Quarter 2: 1st July to September (months 7, 8, 9)
83
+ # - Quarter 3: 1st October to December (months 10, 11, 12)
84
+ # - Quarter 4: 1st January to March (months 1, 2, 3)
85
+ def get_quarter_info(date_input)
86
+ return nil if date_input.nil?
87
+
88
+ parsed_time = nil
89
+ if date_input.is_a?(Time)
90
+ parsed_time = date_input.utc
91
+ elsif date_input.is_a?(Date)
92
+ parsed_time = Time.utc(date_input.year, date_input.month, date_input.day)
93
+ elsif date_input.is_a?(String)
94
+ if valid_calendar_date?(date_input)
95
+ parts = date_input.split('-').map(&:to_i)
96
+ parsed_time = Time.utc(parts[0], parts[1], parts[2])
97
+ else
98
+ begin
99
+ parsed_time = Time.parse(date_input).utc
100
+ rescue ArgumentError
101
+ return nil
102
+ end
103
+ end
104
+ elsif date_input.is_a?(Numeric)
105
+ sec = date_input < 1e11 ? date_input : (date_input / 1000.0)
106
+ parsed_time = Time.at(sec).utc
107
+ else
108
+ return nil
109
+ end
110
+
111
+ y = parsed_time.year
112
+ m = parsed_time.month
113
+
114
+ case m
115
+ when 4..6
116
+ q_num = 1
117
+ q_name = 'April - June'
118
+ s_date = format('%04d-04-01', y)
119
+ e_date = format('%04d-06-30', y)
120
+ when 7..9
121
+ q_num = 2
122
+ q_name = 'July - September'
123
+ s_date = format('%04d-07-01', y)
124
+ e_date = format('%04d-09-30', y)
125
+ when 10..12
126
+ q_num = 3
127
+ q_name = 'October - December'
128
+ s_date = format('%04d-10-01', y)
129
+ e_date = format('%04d-12-31', y)
130
+ when 1..3
131
+ q_num = 4
132
+ q_name = 'January - March'
133
+ s_date = format('%04d-01-01', y)
134
+ e_date = format('%04d-03-31', y)
135
+ end
136
+
137
+ {
138
+ quarter_number: q_num,
139
+ quarter_name: q_name,
140
+ year: y,
141
+ label: "#{q_name} #{y}",
142
+ start_date: s_date,
143
+ end_date: e_date
144
+ }
145
+ end
146
+
147
+ # Verifies whether blog creation date falls in the current quarter
148
+ def date_in_current_quarter?(blog_date, reference_date = nil)
149
+ blog_quarter = get_quarter_info(blog_date)
150
+ current_quarter = get_quarter_info(reference_date || Time.now.utc)
151
+
152
+ return { in_current_quarter: false, blog_quarter: nil, current_quarter: current_quarter } if blog_quarter.nil?
153
+ return { in_current_quarter: false, blog_quarter: blog_quarter, current_quarter: nil } if current_quarter.nil?
154
+
155
+ in_current = (blog_quarter[:quarter_number] == current_quarter[:quarter_number]) &&
156
+ (blog_quarter[:year] == current_quarter[:year])
157
+
158
+ {
159
+ in_current_quarter: in_current,
160
+ blog_quarter: blog_quarter,
161
+ current_quarter: current_quarter
162
+ }
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'set'
5
+
6
+ module BlogVerification
7
+ module Utils
8
+ module Normalizer
9
+ class << self
10
+ # Normalizes a username:
11
+ # - Trims whitespace
12
+ # - Removes leading '@' if present
13
+ # - Lowercases for case-insensitive comparison
14
+ def normalize_username(username)
15
+ return '' unless username.is_a?(String)
16
+
17
+ cleaned = username.strip
18
+ cleaned = cleaned[1..-1].strip if cleaned.start_with?('@')
19
+ cleaned.downcase
20
+ end
21
+
22
+ # Normalizes an array of tags:
23
+ # - Trims whitespace
24
+ # - Lowercases
25
+ # - Removes duplicates
26
+ # - Filters out empty strings
27
+ # - Sorts for deterministic representation
28
+ def normalize_tags(tags)
29
+ return [] unless tags.is_a?(Array)
30
+
31
+ tags.map do |tag|
32
+ tag.is_a?(String) ? tag.strip.downcase : nil
33
+ end.compact.reject(&:empty?).uniq.sort
34
+ end
35
+
36
+ # Compares two sets of tags for equality, ignoring order
37
+ def compare_tag_sets(expected_tags, source_tags)
38
+ norm_expected = normalize_tags(expected_tags)
39
+ norm_actual = normalize_tags(source_tags)
40
+
41
+ if norm_expected.length != norm_actual.length
42
+ return {
43
+ match: false,
44
+ normalized_expected: norm_expected,
45
+ normalized_actual: norm_actual
46
+ }
47
+ end
48
+
49
+ match = norm_expected == norm_actual
50
+ {
51
+ match: match,
52
+ normalized_expected: norm_expected,
53
+ normalized_actual: norm_actual
54
+ }
55
+ end
56
+
57
+ # Normalizes article summary/description according to FR-08:
58
+ # - Removes HTML tags
59
+ # - Decodes HTML entities (e.g. &amp;, &quot;, &#39;, &lt;, &gt;)
60
+ # - Trims leading and trailing whitespace
61
+ # - Collapses repeated whitespace
62
+ # - Case-normalization (lowercase)
63
+ def normalize_summary(text)
64
+ return '' unless text.is_a?(String)
65
+
66
+ # 1. Remove HTML tags
67
+ stripped = text.gsub(/<[^>]*>/, ' ')
68
+ # 2. Decode HTML entities
69
+ decoded = CGI.unescapeHTML(stripped)
70
+ # 3. Collapse whitespace and trim
71
+ collapsed = decoded.gsub(/\s+/, ' ').strip
72
+ # 4. Lowercase
73
+ collapsed.downcase
74
+ end
75
+
76
+ # Compares expected summary and actual source description
77
+ def compare_summaries(expected_summary, source_description)
78
+ norm_expected = normalize_summary(expected_summary)
79
+ norm_actual = normalize_summary(source_description)
80
+
81
+ match = !norm_expected.empty? && !norm_actual.empty? && (norm_expected == norm_actual)
82
+ {
83
+ match: match,
84
+ normalized_expected: norm_expected,
85
+ normalized_actual: norm_actual
86
+ }
87
+ end
88
+
89
+ # Calculates the number of words in an article or text.
90
+ # - Strips HTML tags
91
+ # - Decodes HTML entities
92
+ # - Splits on whitespace and counts non-empty word tokens
93
+ def count_words(text)
94
+ return 0 unless text.is_a?(String)
95
+
96
+ stripped = text.gsub(/<[^>]*>/, ' ')
97
+ decoded = CGI.unescapeHTML(stripped)
98
+ words = decoded.strip.split(/\s+/).reject(&:empty?)
99
+ words.length
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end