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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +413 -0
- data/blog_verification.gemspec +40 -0
- data/lib/blog_verification/adapters/base_adapter.rb +30 -0
- data/lib/blog_verification/adapters/devto_adapter.rb +98 -0
- data/lib/blog_verification/adapters/medium_adapter.rb +271 -0
- data/lib/blog_verification/configuration.rb +29 -0
- data/lib/blog_verification/constants/error_codes.rb +65 -0
- data/lib/blog_verification/constants/taxonomy.rb +248 -0
- data/lib/blog_verification/errors.rb +34 -0
- data/lib/blog_verification/rails/railtie.rb +13 -0
- data/lib/blog_verification/rails/validator.rb +79 -0
- data/lib/blog_verification/services/validation_service.rb +468 -0
- data/lib/blog_verification/services/verification_service.rb +274 -0
- data/lib/blog_verification/utils/date_helper.rb +167 -0
- data/lib/blog_verification/utils/normalizer.rb +104 -0
- data/lib/blog_verification/utils/url_security.rb +314 -0
- data/lib/blog_verification/version.rb +5 -0
- data/lib/blog_verification.rb +56 -0
- metadata +143 -0
|
@@ -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. &, ", ', <, >)
|
|
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
|