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,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../constants/error_codes'
4
+
5
+ module BlogVerification
6
+ module Rails
7
+ # Custom ActiveModel validator for Rails models:
8
+ #
9
+ # class ArticleSubmission < ApplicationRecord
10
+ # validates :url, blog_verification: {
11
+ # platform: :platform,
12
+ # username: :author_username,
13
+ # published_at: :published_date,
14
+ # topic: :expected_topic,
15
+ # summary: :expected_summary,
16
+ # tags: :expected_tags,
17
+ # min_word_count: 500
18
+ # }
19
+ # end
20
+ class BlogVerificationValidator < ::ActiveModel::EachValidator
21
+ def validate_each(record, attribute, value)
22
+ return if value.nil? || value.to_s.strip.empty?
23
+
24
+ resolve_attr = lambda do |key|
25
+ val = options[key]
26
+ if val.is_a?(Symbol) && record.respond_to?(val)
27
+ record.public_send(val)
28
+ elsif val.is_a?(Proc)
29
+ val.call(record)
30
+ else
31
+ val
32
+ end
33
+ end
34
+
35
+ platform = resolve_attr.call(:platform)
36
+ username = resolve_attr.call(:username)
37
+ published_at = resolve_attr.call(:published_at) || resolve_attr.call(:publishedAt)
38
+ topic = resolve_attr.call(:topic)
39
+ summary = resolve_attr.call(:summary)
40
+ tags = resolve_attr.call(:tags)
41
+ min_word_count = resolve_attr.call(:min_word_count) || resolve_attr.call(:minWordCount) || 500
42
+ reference_date = resolve_attr.call(:reference_date) || resolve_attr.call(:referenceDate)
43
+
44
+ payload = {
45
+ 'platform' => platform,
46
+ 'url' => value,
47
+ 'username' => username,
48
+ 'publishedAt' => published_at,
49
+ 'topic' => topic,
50
+ 'summary' => summary,
51
+ 'tags' => tags,
52
+ 'minWordCount' => min_word_count,
53
+ 'referenceDate' => reference_date
54
+ }
55
+
56
+ result = BlogVerification.verify(payload)
57
+
58
+ # Store full verification result on the record if accessor exists
59
+ if record.respond_to?(:verification_result=)
60
+ record.verification_result = result
61
+ end
62
+
63
+ if result['status'] == 'error'
64
+ (result['errors'] || []).each do |err|
65
+ record.errors.add(attribute, err['message'] || 'is invalid')
66
+ end
67
+ elsif result['status'] == BlogVerification::ErrorCodes::VerificationStatus::INVALID
68
+ (result['errors'] || []).each do |err|
69
+ record.errors.add(attribute, err['message'] || 'failed blog verification')
70
+ end
71
+ elsif result['status'] == BlogVerification::ErrorCodes::VerificationStatus::UNVERIFIED
72
+ if options[:allow_unverified] != true
73
+ record.errors.add(attribute, 'could not be verified from the platform')
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../constants/error_codes'
4
+ require_relative '../constants/taxonomy'
5
+ require_relative '../utils/normalizer'
6
+ require_relative '../utils/date_helper'
7
+
8
+ module BlogVerification
9
+ module Services
10
+ class ValidationService
11
+ include ErrorCodes
12
+
13
+ # Evaluates all validation checks against retrieved source metadata
14
+ #
15
+ # @param [Hash] request_data User request hash
16
+ # @param [Hash] source_metadata Metadata extracted by platform adapter
17
+ # @return [Hash] JSON-compliant verification response structure
18
+ def evaluate(request_data, source_metadata)
19
+ req = normalize_request_data(request_data)
20
+ src = normalize_source_metadata(source_metadata)
21
+
22
+ checks = {}
23
+ errors = []
24
+
25
+ # 1. URL check
26
+ checks['url'] = { 'status' => CheckStatus::PASSED }
27
+
28
+ # 2. Author username check (FR-04)
29
+ author_username = src.dig('author', 'username')
30
+ if author_username && !author_username.to_s.strip.empty?
31
+ norm_expected = Utils::Normalizer.normalize_username(req['username'])
32
+ norm_actual = Utils::Normalizer.normalize_username(author_username)
33
+
34
+ if norm_expected == norm_actual
35
+ checks['username'] = {
36
+ 'status' => CheckStatus::PASSED,
37
+ 'expected' => req['username'],
38
+ 'actual' => author_username
39
+ }
40
+ else
41
+ checks['username'] = {
42
+ 'status' => CheckStatus::FAILED,
43
+ 'expected' => req['username'],
44
+ 'actual' => author_username
45
+ }
46
+ errors << {
47
+ 'code' => AUTHOR_MISMATCH,
48
+ 'field' => 'username',
49
+ 'message' => DEFAULT_ERROR_MESSAGES[AUTHOR_MISMATCH],
50
+ 'expected' => req['username'],
51
+ 'actual' => author_username
52
+ }
53
+ end
54
+ else
55
+ checks['username'] = {
56
+ 'status' => CheckStatus::UNVERIFIED,
57
+ 'message' => 'Author username could not be established from source metadata.'
58
+ }
59
+ errors << {
60
+ 'code' => SOURCE_METADATA_UNAVAILABLE,
61
+ 'field' => 'username',
62
+ 'message' => 'Author username could not be established from source metadata.'
63
+ }
64
+ end
65
+
66
+ # 3. Publication date check (FR-05)
67
+ published_at_src = src['published_at']
68
+ if published_at_src && !published_at_src.to_s.strip.empty?
69
+ date_comp = Utils::DateHelper.compare_calendar_dates(req['published_at'], published_at_src)
70
+ normalized_actual = date_comp[:normalized_actual]
71
+
72
+ if normalized_actual
73
+ if date_comp[:match]
74
+ checks['publishedAt'] = {
75
+ 'status' => CheckStatus::PASSED,
76
+ 'expected' => req['published_at'],
77
+ 'actual' => normalized_actual
78
+ }
79
+ else
80
+ checks['publishedAt'] = {
81
+ 'status' => CheckStatus::FAILED,
82
+ 'expected' => req['published_at'],
83
+ 'actual' => normalized_actual
84
+ }
85
+ errors << {
86
+ 'code' => PUBLICATION_DATE_MISMATCH,
87
+ 'field' => 'publishedAt',
88
+ 'message' => DEFAULT_ERROR_MESSAGES[PUBLICATION_DATE_MISMATCH],
89
+ 'expected' => req['published_at'],
90
+ 'actual' => normalized_actual
91
+ }
92
+ end
93
+ else
94
+ checks['publishedAt'] = {
95
+ 'status' => CheckStatus::UNVERIFIED,
96
+ 'message' => 'Source publication date could not be parsed to a valid UTC date.'
97
+ }
98
+ errors << {
99
+ 'code' => SOURCE_METADATA_UNAVAILABLE,
100
+ 'field' => 'publishedAt',
101
+ 'message' => 'Source publication date could not be parsed to a valid UTC date.'
102
+ }
103
+ end
104
+ else
105
+ checks['publishedAt'] = {
106
+ 'status' => CheckStatus::UNVERIFIED,
107
+ 'message' => 'Original publication date could not be established from source metadata.'
108
+ }
109
+ errors << {
110
+ 'code' => SOURCE_METADATA_UNAVAILABLE,
111
+ 'field' => 'publishedAt',
112
+ 'message' => 'Original publication date could not be established from source metadata.'
113
+ }
114
+ end
115
+
116
+ # 4. Quarter verification check
117
+ if published_at_src && !published_at_src.to_s.strip.empty?
118
+ quarter_result = Utils::DateHelper.date_in_current_quarter?(published_at_src, req['reference_date'])
119
+ if quarter_result[:in_current_quarter]
120
+ checks['quarter'] = {
121
+ 'status' => CheckStatus::PASSED,
122
+ 'currentQuarter' => quarter_result[:current_quarter][:label],
123
+ 'blogQuarter' => quarter_result[:blog_quarter][:label],
124
+ 'message' => "Blog publication date is in the current quarter (#{quarter_result[:current_quarter][:label]})."
125
+ }
126
+ elsif quarter_result[:blog_quarter] && quarter_result[:current_quarter]
127
+ checks['quarter'] = {
128
+ 'status' => CheckStatus::FAILED,
129
+ 'currentQuarter' => quarter_result[:current_quarter][:label],
130
+ 'blogQuarter' => quarter_result[:blog_quarter][:label],
131
+ 'message' => "Blog publication date is not in the current quarter. Expected #{quarter_result[:current_quarter][:label]}, but blog was published in #{quarter_result[:blog_quarter][:label]}."
132
+ }
133
+ errors << {
134
+ 'code' => NOT_IN_CURRENT_QUARTER,
135
+ 'field' => 'publishedAt',
136
+ 'message' => "Blog publication date is not in the current quarter. Expected #{quarter_result[:current_quarter][:label]}, but blog was published in #{quarter_result[:blog_quarter][:label]}.",
137
+ 'expected' => quarter_result[:current_quarter][:label],
138
+ 'actual' => quarter_result[:blog_quarter][:label]
139
+ }
140
+ else
141
+ checks['quarter'] = {
142
+ 'status' => CheckStatus::UNVERIFIED,
143
+ 'message' => 'Quarter could not be established from source publication date.'
144
+ }
145
+ errors << {
146
+ 'code' => SOURCE_METADATA_UNAVAILABLE,
147
+ 'field' => 'quarter',
148
+ 'message' => 'Quarter could not be established from source publication date.'
149
+ }
150
+ end
151
+ else
152
+ checks['quarter'] = {
153
+ 'status' => CheckStatus::UNVERIFIED,
154
+ 'message' => 'Quarter could not be verified because source publication date is unavailable.'
155
+ }
156
+ errors << {
157
+ 'code' => SOURCE_METADATA_UNAVAILABLE,
158
+ 'field' => 'quarter',
159
+ 'message' => 'Quarter could not be verified because source publication date is unavailable.'
160
+ }
161
+ end
162
+
163
+ # 5. Tags check (FR-06 - Optional)
164
+ has_supplied_tags = req['tags'].is_a?(Array) && !req['tags'].empty?
165
+ if !has_supplied_tags
166
+ checks['tags'] = {
167
+ 'status' => CheckStatus::SKIPPED,
168
+ 'message' => 'Tag validation was skipped because tags were not provided.'
169
+ }
170
+ else
171
+ source_tags = src['tags']
172
+ if source_tags.is_a?(Array)
173
+ comp = Utils::Normalizer.compare_tag_sets(req['tags'], source_tags)
174
+ if comp[:match]
175
+ checks['tags'] = {
176
+ 'status' => CheckStatus::PASSED,
177
+ 'expected' => comp[:normalized_expected],
178
+ 'actual' => comp[:normalized_actual]
179
+ }
180
+ else
181
+ checks['tags'] = {
182
+ 'status' => CheckStatus::FAILED,
183
+ 'expected' => comp[:normalized_expected],
184
+ 'actual' => comp[:normalized_actual]
185
+ }
186
+ errors << {
187
+ 'code' => TAGS_MISMATCH,
188
+ 'field' => 'tags',
189
+ 'message' => DEFAULT_ERROR_MESSAGES[TAGS_MISMATCH],
190
+ 'expected' => comp[:normalized_expected],
191
+ 'actual' => comp[:normalized_actual]
192
+ }
193
+ end
194
+ else
195
+ checks['tags'] = {
196
+ 'status' => CheckStatus::UNVERIFIED,
197
+ 'message' => 'Source platform does not expose reliable tag metadata.'
198
+ }
199
+ errors << {
200
+ 'code' => SOURCE_METADATA_UNAVAILABLE,
201
+ 'field' => 'tags',
202
+ 'message' => 'Source platform does not expose reliable tag metadata.'
203
+ }
204
+ end
205
+ end
206
+
207
+ # 6. Topic check (FR-07)
208
+ verified_topic = Taxonomy.derive_topic_from_metadata(src)
209
+ if verified_topic
210
+ if Taxonomy.compare_topics(req['topic'], verified_topic)
211
+ checks['topic'] = {
212
+ 'status' => CheckStatus::PASSED,
213
+ 'expected' => req['topic'],
214
+ 'actual' => verified_topic
215
+ }
216
+ else
217
+ checks['topic'] = {
218
+ 'status' => CheckStatus::FAILED,
219
+ 'expected' => req['topic'],
220
+ 'actual' => verified_topic
221
+ }
222
+ errors << {
223
+ 'code' => TOPIC_MISMATCH,
224
+ 'field' => 'topic',
225
+ 'message' => DEFAULT_ERROR_MESSAGES[TOPIC_MISMATCH],
226
+ 'expected' => req['topic'],
227
+ 'actual' => verified_topic
228
+ }
229
+ end
230
+ else
231
+ checks['topic'] = {
232
+ 'status' => CheckStatus::UNVERIFIED,
233
+ 'message' => 'Article topic could not be established reliably from source metadata.'
234
+ }
235
+ errors << {
236
+ 'code' => SOURCE_METADATA_UNAVAILABLE,
237
+ 'field' => 'topic',
238
+ 'message' => 'Article topic could not be established reliably from source metadata.'
239
+ }
240
+ end
241
+
242
+ # 7. Summary check (FR-08)
243
+ source_description = src['description']
244
+ if source_description && !source_description.to_s.strip.empty?
245
+ comp = Utils::Normalizer.compare_summaries(req['summary'], source_description)
246
+ if comp[:match]
247
+ checks['summary'] = { 'status' => CheckStatus::PASSED }
248
+ else
249
+ checks['summary'] = { 'status' => CheckStatus::FAILED }
250
+ errors << {
251
+ 'code' => SUMMARY_MISMATCH,
252
+ 'field' => 'summary',
253
+ 'message' => DEFAULT_ERROR_MESSAGES[SUMMARY_MISMATCH],
254
+ 'expected' => req['summary'],
255
+ 'actual' => source_description
256
+ }
257
+ end
258
+ else
259
+ checks['summary'] = {
260
+ 'status' => CheckStatus::UNVERIFIED,
261
+ 'message' => 'Article description or excerpt could not be established from source metadata.'
262
+ }
263
+ errors << {
264
+ 'code' => SOURCE_METADATA_UNAVAILABLE,
265
+ 'field' => 'summary',
266
+ 'message' => 'Article description or excerpt could not be established from source metadata.'
267
+ }
268
+ end
269
+
270
+ # 8. Blog content length / word count check
271
+ min_words = req['min_word_count'] || 500
272
+ actual_word_count = src['word_count']
273
+ if actual_word_count.is_a?(Numeric)
274
+ if actual_word_count >= min_words
275
+ checks['contentLength'] = {
276
+ 'status' => CheckStatus::PASSED,
277
+ 'wordCount' => actual_word_count,
278
+ 'minRequired' => min_words,
279
+ 'message' => "Blog length passed (#{actual_word_count} words >= #{min_words} words)."
280
+ }
281
+ else
282
+ checks['contentLength'] = {
283
+ 'status' => CheckStatus::FAILED,
284
+ 'wordCount' => actual_word_count,
285
+ 'minRequired' => min_words,
286
+ 'message' => "Blog length failed: article has #{actual_word_count} words, which is below the required #{min_words} words."
287
+ }
288
+ errors << {
289
+ 'code' => INSUFFICIENT_CONTENT_LENGTH,
290
+ 'field' => 'contentLength',
291
+ 'message' => "The blog content does not meet the minimum required length of #{min_words} words.",
292
+ 'expected' => min_words,
293
+ 'actual' => actual_word_count
294
+ }
295
+ end
296
+ else
297
+ checks['contentLength'] = {
298
+ 'status' => CheckStatus::UNVERIFIED,
299
+ 'message' => 'Blog content length could not be established from source metadata.'
300
+ }
301
+ errors << {
302
+ 'code' => SOURCE_METADATA_UNAVAILABLE,
303
+ 'field' => 'contentLength',
304
+ 'message' => 'Blog content length could not be established from source metadata.'
305
+ }
306
+ end
307
+
308
+ aggregate_status(checks, errors, req)
309
+ end
310
+
311
+ # Aggregates individual check statuses into valid, invalid, or unverified response
312
+ def aggregate_status(checks, errors, req)
313
+ check_statuses = checks.values.map { |c| c['status'] }
314
+ has_failed = check_statuses.include?(CheckStatus::FAILED)
315
+ has_unverified = check_statuses.include?(CheckStatus::UNVERIFIED)
316
+
317
+ if has_failed
318
+ overall_status = VerificationStatus::INVALID
319
+ msg = 'Blog verification failed.'
320
+ elsif has_unverified
321
+ overall_status = VerificationStatus::UNVERIFIED
322
+ msg = 'Blog verification could not be completed.'
323
+ else
324
+ overall_status = VerificationStatus::VALID
325
+ msg = 'Blog verification completed successfully.'
326
+ end
327
+
328
+ res = {
329
+ 'success' => true,
330
+ 'status' => overall_status,
331
+ 'message' => msg,
332
+ 'data' => {
333
+ 'platform' => req['platform'],
334
+ 'url' => req['url'],
335
+ 'checks' => checks
336
+ }
337
+ }
338
+
339
+ res['errors'] = errors unless errors.empty?
340
+ res
341
+ end
342
+
343
+ # Builds a structured response when upstream source fails
344
+ def build_source_failure_response(request_data, upstream_error)
345
+ req = normalize_request_data(request_data)
346
+ has_supplied_tags = req['tags'].is_a?(Array) && !req['tags'].empty?
347
+ is_not_found = upstream_error.respond_to?(:error_code) &&
348
+ upstream_error.error_code == ARTICLE_NOT_FOUND
349
+
350
+ if is_not_found
351
+ return {
352
+ 'success' => true,
353
+ 'status' => VerificationStatus::INVALID,
354
+ 'message' => 'Blog verification failed.',
355
+ 'errors' => [
356
+ {
357
+ 'code' => ARTICLE_NOT_FOUND,
358
+ 'field' => 'url',
359
+ 'message' => upstream_error.message || DEFAULT_ERROR_MESSAGES[ARTICLE_NOT_FOUND]
360
+ }
361
+ ],
362
+ 'data' => {
363
+ 'platform' => req['platform'],
364
+ 'url' => req['url'],
365
+ 'checks' => {
366
+ 'url' => {
367
+ 'status' => CheckStatus::FAILED,
368
+ 'message' => upstream_error.message || DEFAULT_ERROR_MESSAGES[ARTICLE_NOT_FOUND]
369
+ },
370
+ 'username' => { 'status' => CheckStatus::UNVERIFIED },
371
+ 'publishedAt' => { 'status' => CheckStatus::UNVERIFIED },
372
+ 'quarter' => { 'status' => CheckStatus::UNVERIFIED },
373
+ 'tags' => has_supplied_tags ? { 'status' => CheckStatus::UNVERIFIED } : { 'status' => CheckStatus::SKIPPED, 'message' => 'Tag validation was skipped because tags were not provided.' },
374
+ 'topic' => { 'status' => CheckStatus::UNVERIFIED },
375
+ 'summary' => { 'status' => CheckStatus::UNVERIFIED },
376
+ 'contentLength' => { 'status' => CheckStatus::UNVERIFIED }
377
+ }
378
+ }
379
+ }
380
+ end
381
+
382
+ err_code = upstream_error.respond_to?(:error_code) ? upstream_error.error_code : SOURCE_UNAVAILABLE
383
+ {
384
+ 'success' => true,
385
+ 'status' => VerificationStatus::UNVERIFIED,
386
+ 'message' => 'Blog verification could not be completed.',
387
+ 'errors' => [
388
+ {
389
+ 'code' => err_code,
390
+ 'field' => nil,
391
+ 'message' => upstream_error.message
392
+ }
393
+ ],
394
+ 'data' => {
395
+ 'platform' => req['platform'],
396
+ 'url' => req['url'],
397
+ 'checks' => {
398
+ 'url' => { 'status' => CheckStatus::UNVERIFIED },
399
+ 'username' => { 'status' => CheckStatus::UNVERIFIED },
400
+ 'publishedAt' => { 'status' => CheckStatus::UNVERIFIED },
401
+ 'quarter' => { 'status' => CheckStatus::UNVERIFIED },
402
+ 'tags' => has_supplied_tags ? { 'status' => CheckStatus::UNVERIFIED } : { 'status' => CheckStatus::SKIPPED, 'message' => 'Tag validation was skipped because tags were not provided.' },
403
+ 'topic' => { 'status' => CheckStatus::UNVERIFIED },
404
+ 'summary' => { 'status' => CheckStatus::UNVERIFIED },
405
+ 'contentLength' => { 'status' => CheckStatus::UNVERIFIED }
406
+ }
407
+ }
408
+ }
409
+ end
410
+
411
+ private
412
+
413
+ def normalize_request_data(data)
414
+ h = {}
415
+ data.each do |k, v|
416
+ key_str = k.to_s
417
+ case key_str
418
+ when 'platform' then h['platform'] = v.to_s.downcase.strip
419
+ when 'url' then h['url'] = v.to_s.strip
420
+ when 'username' then h['username'] = v.to_s.strip
421
+ when 'publishedAt', 'published_at' then h['published_at'] = v.to_s.strip
422
+ when 'tags' then h['tags'] = v
423
+ when 'topic' then h['topic'] = v.to_s.strip
424
+ when 'summary' then h['summary'] = v.to_s.strip
425
+ when 'minWordCount', 'min_word_count' then h['min_word_count'] = v.to_i
426
+ when 'referenceDate', 'reference_date' then h['reference_date'] = v.to_s.strip
427
+ end
428
+ end
429
+ h
430
+ end
431
+
432
+ def normalize_source_metadata(metadata)
433
+ return {} unless metadata.is_a?(Hash)
434
+
435
+ h = {}
436
+ metadata.each do |k, v|
437
+ key_str = k.to_s
438
+ case key_str
439
+ when 'canonical_url', 'canonicalUrl' then h['canonical_url'] = v
440
+ when 'platform' then h['platform'] = v
441
+ when 'author'
442
+ if v.is_a?(Hash)
443
+ author_hash = {}
444
+ v.each do |ak, av|
445
+ ak_str = ak.to_s
446
+ if %w[username].include?(ak_str)
447
+ author_hash['username'] = av
448
+ elsif %w[displayName display_name name].include?(ak_str)
449
+ author_hash['display_name'] = av
450
+ end
451
+ end
452
+ h['author'] = author_hash
453
+ end
454
+ when 'published_at', 'publishedAt' then h['published_at'] = v
455
+ when 'updated_at', 'updatedAt' then h['updated_at'] = v
456
+ when 'tags' then h['tags'] = v
457
+ when 'topic' then h['topic'] = v
458
+ when 'description' then h['description'] = v
459
+ when 'content' then h['content'] = v
460
+ when 'word_count', 'wordCount' then h['word_count'] = v
461
+ when 'raw_metadata', 'rawMetadata' then h['raw_metadata'] = v
462
+ end
463
+ end
464
+ h
465
+ end
466
+ end
467
+ end
468
+ end