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,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'base_adapter'
|
|
5
|
+
require_relative '../utils/url_security'
|
|
6
|
+
require_relative '../utils/normalizer'
|
|
7
|
+
require_relative '../errors'
|
|
8
|
+
require_relative '../constants/error_codes'
|
|
9
|
+
|
|
10
|
+
module BlogVerification
|
|
11
|
+
module Adapters
|
|
12
|
+
class MediumAdapter < BaseAdapter
|
|
13
|
+
def initialize
|
|
14
|
+
super('medium')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Extracts canonical username from a username string, identifier, or Medium author URL.
|
|
18
|
+
# Medium canonical usernames are formatted as @username or bare username identifiers.
|
|
19
|
+
def extract_username(input)
|
|
20
|
+
return nil if input.nil? || !input.is_a?(String)
|
|
21
|
+
|
|
22
|
+
trimmed = input.strip
|
|
23
|
+
|
|
24
|
+
# 1. @username in URL or path (e.g. https://medium.com/@thedevankit or @thedevankit)
|
|
25
|
+
at_match = trimmed.match(/@([a-zA-Z0-9_.-]+)/)
|
|
26
|
+
return at_match[1] if at_match && at_match[1]
|
|
27
|
+
|
|
28
|
+
# 2. Subdomain URL (e.g. https://thedevankit.medium.com)
|
|
29
|
+
sub_match = trimmed.match(%r{\Ahttps?://([a-zA-Z0-9_-]+)\.medium\.com}i)
|
|
30
|
+
if sub_match && sub_match[1] && !%w[www api cdn-images-1].include?(sub_match[1].downcase)
|
|
31
|
+
return sub_match[1]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# 3. Clean bare username / identifier string (e.g. "thedevankit")
|
|
35
|
+
if !trimmed.include?('/') && !trimmed.include?(':') && trimmed.match?(/\A[a-zA-Z0-9_.-]+\z/)
|
|
36
|
+
return trimmed.sub(/\A@/, '')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Extracts article body HTML/text content from Medium page
|
|
43
|
+
def extract_article_content(html)
|
|
44
|
+
return nil if html.nil? || !html.is_a?(String)
|
|
45
|
+
|
|
46
|
+
article_match = html.match(%r{<article\b[^>]*>([\s\S]*?)</article>}i)
|
|
47
|
+
return article_match[1] if article_match
|
|
48
|
+
|
|
49
|
+
p_matches = html.scan(%r{<p\b[^>]*>([\s\S]*?)</p>}i).flatten
|
|
50
|
+
return p_matches.join(' ') unless p_matches.empty?
|
|
51
|
+
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Parses JSON-LD script blocks from HTML string
|
|
56
|
+
def parse_json_ld(html)
|
|
57
|
+
blocks = []
|
|
58
|
+
return blocks if html.nil? || !html.is_a?(String)
|
|
59
|
+
|
|
60
|
+
# Match script tags with type application/ld+json
|
|
61
|
+
regex = %r{<script\b[^>]*\btype=["']application/ld\+json["'][^>]*>([\s\S]*?)</script>}i
|
|
62
|
+
html.scan(regex) do |match|
|
|
63
|
+
content = match[0]
|
|
64
|
+
begin
|
|
65
|
+
parsed = JSON.parse(content)
|
|
66
|
+
if parsed.is_a?(Array)
|
|
67
|
+
blocks.concat(parsed)
|
|
68
|
+
elsif parsed.is_a?(Hash)
|
|
69
|
+
if parsed['@graph'].is_a?(Array)
|
|
70
|
+
blocks.concat(parsed['@graph'])
|
|
71
|
+
else
|
|
72
|
+
blocks << parsed
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
rescue JSON::ParserError
|
|
76
|
+
# Skip invalid JSON-LD block
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
blocks
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Extracts meta tag attributes from HTML
|
|
84
|
+
def parse_meta_tags(html)
|
|
85
|
+
meta = {
|
|
86
|
+
published_time: nil,
|
|
87
|
+
author_url: nil,
|
|
88
|
+
author_name: nil,
|
|
89
|
+
description: nil,
|
|
90
|
+
canonical_url: nil,
|
|
91
|
+
tags: []
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return meta if html.nil? || !html.is_a?(String)
|
|
95
|
+
|
|
96
|
+
meta_regex = /<meta\s+([^>]+)>/i
|
|
97
|
+
html.scan(meta_regex) do |match|
|
|
98
|
+
tag_content = match[0]
|
|
99
|
+
|
|
100
|
+
get_attr = lambda do |name|
|
|
101
|
+
m = tag_content.match(/#{name}=["']([^"']*)["']/i)
|
|
102
|
+
m ? m[1] : nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
prop = get_attr.call('property') || get_attr.call('name')
|
|
106
|
+
content = get_attr.call('content')
|
|
107
|
+
|
|
108
|
+
next unless prop && content
|
|
109
|
+
|
|
110
|
+
lower_prop = prop.downcase
|
|
111
|
+
case lower_prop
|
|
112
|
+
when 'article:published_time'
|
|
113
|
+
meta[:published_time] = content
|
|
114
|
+
when 'article:author'
|
|
115
|
+
meta[:author_url] = content
|
|
116
|
+
when 'author'
|
|
117
|
+
meta[:author_name] = content
|
|
118
|
+
when 'description'
|
|
119
|
+
meta[:description] ||= content
|
|
120
|
+
when 'og:description'
|
|
121
|
+
meta[:description] ||= content
|
|
122
|
+
when 'article:tag'
|
|
123
|
+
meta[:tags] << content
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
canonical_match = html.match(%r{<link\s+rel=["']canonical["']\s+href=["']([^"']+)["']}i)
|
|
128
|
+
meta[:canonical_url] = canonical_match[1] if canonical_match
|
|
129
|
+
|
|
130
|
+
meta
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Retrieves and normalizes metadata for a Medium article
|
|
134
|
+
def get_article_metadata(url, _options = {})
|
|
135
|
+
Utils::UrlSecurity.validate_article_url(url, 'medium')
|
|
136
|
+
|
|
137
|
+
response = Utils::UrlSecurity.safe_fetch(
|
|
138
|
+
url,
|
|
139
|
+
{
|
|
140
|
+
headers: {
|
|
141
|
+
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
142
|
+
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 BlogVerificationAPI/1.0'
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
'medium'
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
case response.status
|
|
149
|
+
when 404
|
|
150
|
+
raise UpstreamError.new(ErrorCodes::ARTICLE_NOT_FOUND, 'The specified article does not exist on Medium.', 404)
|
|
151
|
+
when 401, 403
|
|
152
|
+
raise UpstreamError.new(ErrorCodes::ARTICLE_ACCESS_DENIED, 'Access to Medium article is restricted.', response.status)
|
|
153
|
+
when 429
|
|
154
|
+
raise UpstreamError.new(ErrorCodes::SOURCE_RATE_LIMITED, 'Medium rate limit exceeded.', 429)
|
|
155
|
+
else
|
|
156
|
+
if response.status >= 500
|
|
157
|
+
raise UpstreamError.new(ErrorCodes::SOURCE_UNAVAILABLE, "Medium returned HTTP #{response.status}.", response.status)
|
|
158
|
+
elsif !response.ok?
|
|
159
|
+
raise UpstreamError.new(ErrorCodes::SOURCE_RESPONSE_INVALID, "Medium returned HTTP #{response.status}.", response.status)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
html = response.text
|
|
164
|
+
meta_tags = parse_meta_tags(html)
|
|
165
|
+
json_ld_blocks = parse_json_ld(html)
|
|
166
|
+
|
|
167
|
+
# 1. Locate article JSON-LD block and author block
|
|
168
|
+
author_obj = nil
|
|
169
|
+
article_block = json_ld_blocks.find do |b|
|
|
170
|
+
%w[NewsArticle BlogPosting Article TechArticle].include?(b['@type'])
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
if article_block && article_block['author']
|
|
174
|
+
author_obj = article_block['author'].is_a?(Array) ? article_block['author'][0] : article_block['author']
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Inspect all blocks if author not found in article block
|
|
178
|
+
unless author_obj
|
|
179
|
+
json_ld_blocks.each do |b|
|
|
180
|
+
if b.is_a?(Hash) && b['author']
|
|
181
|
+
author_obj = b['author'].is_a?(Array) ? b['author'][0] : b['author']
|
|
182
|
+
article_block ||= b
|
|
183
|
+
break
|
|
184
|
+
elsif b.is_a?(Hash) && b['@type'] == 'Person'
|
|
185
|
+
author_obj = b
|
|
186
|
+
break
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
article_block ||= json_ld_blocks[0] || {}
|
|
192
|
+
|
|
193
|
+
# Canonical URL
|
|
194
|
+
canonical_url = meta_tags[:canonical_url] || article_block['mainEntityOfPage'] || article_block['url'] || url
|
|
195
|
+
|
|
196
|
+
# Canonical Author Extraction
|
|
197
|
+
canonical_username = nil
|
|
198
|
+
display_name = nil
|
|
199
|
+
|
|
200
|
+
if author_obj
|
|
201
|
+
if author_obj.is_a?(Hash)
|
|
202
|
+
display_name = author_obj['name']
|
|
203
|
+
|
|
204
|
+
# 1. Identifier
|
|
205
|
+
canonical_username = extract_username(author_obj['identifier']) if author_obj['identifier']
|
|
206
|
+
# 2. @id
|
|
207
|
+
canonical_username ||= extract_username(author_obj['@id']) if author_obj['@id']
|
|
208
|
+
# 3. url
|
|
209
|
+
canonical_username ||= extract_username(author_obj['url']) if author_obj['url']
|
|
210
|
+
elsif author_obj.is_a?(String)
|
|
211
|
+
canonical_username = extract_username(author_obj)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Fallbacks to meta tags or article URL
|
|
216
|
+
if !canonical_username && meta_tags[:author_url]
|
|
217
|
+
canonical_username = extract_username(meta_tags[:author_url])
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
canonical_username ||= extract_username(canonical_url) || extract_username(url)
|
|
221
|
+
display_name ||= meta_tags[:author_name]
|
|
222
|
+
|
|
223
|
+
# Publication date
|
|
224
|
+
published_at = article_block['datePublished'] ||
|
|
225
|
+
(json_ld_blocks[0] && json_ld_blocks[0]['datePublished']) ||
|
|
226
|
+
meta_tags[:published_time]
|
|
227
|
+
|
|
228
|
+
updated_at = article_block['dateModified'] ||
|
|
229
|
+
(json_ld_blocks[0] && json_ld_blocks[0]['dateModified'])
|
|
230
|
+
|
|
231
|
+
# Description / Summary
|
|
232
|
+
description = article_block['description'] ||
|
|
233
|
+
(json_ld_blocks[0] && json_ld_blocks[0]['description']) ||
|
|
234
|
+
meta_tags[:description]
|
|
235
|
+
|
|
236
|
+
# Tags / Keywords
|
|
237
|
+
raw_keywords = article_block['keywords'] || (json_ld_blocks[0] && json_ld_blocks[0]['keywords'])
|
|
238
|
+
tags = if raw_keywords.is_a?(Array)
|
|
239
|
+
raw_keywords
|
|
240
|
+
elsif raw_keywords.is_a?(String)
|
|
241
|
+
raw_keywords.split(',').map(&:strip).reject(&:empty?)
|
|
242
|
+
elsif !meta_tags[:tags].empty?
|
|
243
|
+
meta_tags[:tags]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
content = extract_article_content(html)
|
|
247
|
+
word_count = content ? Utils::Normalizer.count_words(content) : nil
|
|
248
|
+
|
|
249
|
+
{
|
|
250
|
+
canonical_url: canonical_url,
|
|
251
|
+
platform: 'medium',
|
|
252
|
+
author: {
|
|
253
|
+
username: canonical_username,
|
|
254
|
+
display_name: display_name
|
|
255
|
+
},
|
|
256
|
+
published_at: published_at,
|
|
257
|
+
updated_at: updated_at,
|
|
258
|
+
tags: tags,
|
|
259
|
+
topic: nil,
|
|
260
|
+
description: description,
|
|
261
|
+
content: content,
|
|
262
|
+
word_count: word_count,
|
|
263
|
+
raw_metadata: {
|
|
264
|
+
meta_tags: meta_tags,
|
|
265
|
+
article_block: article_block
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BlogVerification
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :request_timeout, :max_redirects, :user_agent, :skip_dns_check, :require_https
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@request_timeout = 5.0 # seconds
|
|
9
|
+
@max_redirects = 5
|
|
10
|
+
@user_agent = "BlogVerificationRubyGem/#{BlogVerification::VERSION} (+https://github.com/example/blog_verification)"
|
|
11
|
+
@skip_dns_check = ENV['SKIP_DNS_CHECK'] == 'true'
|
|
12
|
+
@require_https = true
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def configuration
|
|
18
|
+
@configuration ||= Configuration.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def configure
|
|
22
|
+
yield(configuration) if block_given?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reset_configuration!
|
|
26
|
+
@configuration = Configuration.new
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BlogVerification
|
|
4
|
+
module ErrorCodes
|
|
5
|
+
# Top-level verification outcomes
|
|
6
|
+
module VerificationStatus
|
|
7
|
+
VALID = 'valid'
|
|
8
|
+
INVALID = 'invalid'
|
|
9
|
+
UNVERIFIED = 'unverified'
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Individual field check status
|
|
13
|
+
module CheckStatus
|
|
14
|
+
PASSED = 'passed'
|
|
15
|
+
FAILED = 'failed'
|
|
16
|
+
SKIPPED = 'skipped'
|
|
17
|
+
UNVERIFIED = 'unverified'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Standard error codes
|
|
21
|
+
INVALID_REQUEST = 'INVALID_REQUEST'
|
|
22
|
+
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD'
|
|
23
|
+
INVALID_PLATFORM = 'INVALID_PLATFORM'
|
|
24
|
+
INVALID_URL = 'INVALID_URL'
|
|
25
|
+
UNSAFE_URL = 'UNSAFE_URL'
|
|
26
|
+
ARTICLE_NOT_FOUND = 'ARTICLE_NOT_FOUND'
|
|
27
|
+
ARTICLE_ACCESS_DENIED = 'ARTICLE_ACCESS_DENIED'
|
|
28
|
+
AUTHOR_MISMATCH = 'AUTHOR_MISMATCH'
|
|
29
|
+
PUBLICATION_DATE_MISMATCH = 'PUBLICATION_DATE_MISMATCH'
|
|
30
|
+
NOT_IN_CURRENT_QUARTER = 'NOT_IN_CURRENT_QUARTER'
|
|
31
|
+
TAGS_MISMATCH = 'TAGS_MISMATCH'
|
|
32
|
+
TOPIC_MISMATCH = 'TOPIC_MISMATCH'
|
|
33
|
+
SUMMARY_MISMATCH = 'SUMMARY_MISMATCH'
|
|
34
|
+
INSUFFICIENT_CONTENT_LENGTH = 'INSUFFICIENT_CONTENT_LENGTH'
|
|
35
|
+
SOURCE_METADATA_UNAVAILABLE = 'SOURCE_METADATA_UNAVAILABLE'
|
|
36
|
+
SOURCE_RATE_LIMITED = 'SOURCE_RATE_LIMITED'
|
|
37
|
+
SOURCE_TIMEOUT = 'SOURCE_TIMEOUT'
|
|
38
|
+
SOURCE_UNAVAILABLE = 'SOURCE_UNAVAILABLE'
|
|
39
|
+
SOURCE_RESPONSE_INVALID = 'SOURCE_RESPONSE_INVALID'
|
|
40
|
+
INTERNAL_ERROR = 'INTERNAL_ERROR'
|
|
41
|
+
|
|
42
|
+
DEFAULT_ERROR_MESSAGES = {
|
|
43
|
+
INVALID_REQUEST => 'The verification request body is malformed or invalid JSON.',
|
|
44
|
+
MISSING_REQUIRED_FIELD => 'A required request field is missing.',
|
|
45
|
+
INVALID_PLATFORM => 'The requested platform is not supported. Supported platforms: devto, medium.',
|
|
46
|
+
INVALID_URL => 'The submitted URL is invalid, uses an unapproved host, or targets an unsupported path.',
|
|
47
|
+
UNSAFE_URL => 'The submitted URL or a redirect destination targets an unapproved or private network address.',
|
|
48
|
+
ARTICLE_NOT_FOUND => 'The requested article could not be found on the platform.',
|
|
49
|
+
ARTICLE_ACCESS_DENIED => 'The article cannot be verified because of access restrictions or paywalls.',
|
|
50
|
+
AUTHOR_MISMATCH => 'The submitted username does not match the blog author.',
|
|
51
|
+
PUBLICATION_DATE_MISMATCH => "The submitted publication date does not match the blog's publication date.",
|
|
52
|
+
NOT_IN_CURRENT_QUARTER => 'The blog publication date is not in the current quarter.',
|
|
53
|
+
TAGS_MISMATCH => "The submitted tags do not match the blog's tags.",
|
|
54
|
+
TOPIC_MISMATCH => "The submitted topic does not match the blog's topic.",
|
|
55
|
+
SUMMARY_MISMATCH => "The submitted summary does not match the blog's description.",
|
|
56
|
+
INSUFFICIENT_CONTENT_LENGTH => 'The blog content does not meet the minimum required word count of 500 words.',
|
|
57
|
+
SOURCE_METADATA_UNAVAILABLE => 'Required metadata could not be retrieved from the source platform.',
|
|
58
|
+
SOURCE_RATE_LIMITED => 'Verification is temporarily unavailable due to upstream rate limits.',
|
|
59
|
+
SOURCE_TIMEOUT => 'The source platform did not respond within the allowed time.',
|
|
60
|
+
SOURCE_UNAVAILABLE => 'The source platform is temporarily unavailable.',
|
|
61
|
+
SOURCE_RESPONSE_INVALID => 'The response from the source platform could not be parsed.',
|
|
62
|
+
INTERNAL_ERROR => 'An unexpected internal error occurred during verification.'
|
|
63
|
+
}.freeze
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BlogVerification
|
|
4
|
+
module Taxonomy
|
|
5
|
+
CANONICAL_TOPICS = {
|
|
6
|
+
BACKEND_DEVELOPMENT: 'Backend Development',
|
|
7
|
+
FRONTEND_DEVELOPMENT: 'Frontend Development',
|
|
8
|
+
FULL_STACK_DEVELOPMENT: 'Full Stack Development',
|
|
9
|
+
DEVOPS_CLOUD: 'DevOps & Cloud',
|
|
10
|
+
AI_MACHINE_LEARNING: 'Artificial Intelligence & Machine Learning',
|
|
11
|
+
MOBILE_DEVELOPMENT: 'Mobile Development',
|
|
12
|
+
CYBERSECURITY: 'Cybersecurity',
|
|
13
|
+
DATABASE_DATA_ENGINEERING: 'Database & Data Engineering',
|
|
14
|
+
SOFTWARE_ARCHITECTURE: 'Software Architecture & Design',
|
|
15
|
+
PROGRAMMING_LANGUAGES: 'Programming Languages',
|
|
16
|
+
TESTING_QA: 'Testing & Quality Assurance',
|
|
17
|
+
WEB_DEVELOPMENT: 'Web Development'
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# Aliases and tag keywords mapped deterministically to canonical topics
|
|
21
|
+
TAG_TO_TOPIC_MAP = {
|
|
22
|
+
# Backend Development
|
|
23
|
+
'nodejs' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
24
|
+
'node' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
25
|
+
'express' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
26
|
+
'expressjs' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
27
|
+
'api' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
28
|
+
'rest-api' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
29
|
+
'rest' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
30
|
+
'backend' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
31
|
+
'fastapi' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
32
|
+
'django' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
33
|
+
'flask' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
34
|
+
'spring' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
35
|
+
'springboot' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
36
|
+
'nestjs' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
37
|
+
'graphql' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
38
|
+
'microservices' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
39
|
+
'rails' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
40
|
+
'ruby-on-rails' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
41
|
+
|
|
42
|
+
# Frontend Development
|
|
43
|
+
'frontend' => CANONICAL_TOPICS[:FRONTEND_DEVELOPMENT],
|
|
44
|
+
'react' => CANONICAL_TOPICS[:FRONTEND_DEVELOPMENT],
|
|
45
|
+
'reactjs' => CANONICAL_TOPICS[:FRONTEND_DEVELOPMENT],
|
|
46
|
+
'vue' => CANONICAL_TOPICS[:FRONTEND_DEVELOPMENT],
|
|
47
|
+
'vuejs' => CANONICAL_TOPICS[:FRONTEND_DEVELOPMENT],
|
|
48
|
+
'angular' => CANONICAL_TOPICS[:FRONTEND_DEVELOPMENT],
|
|
49
|
+
'svelte' => CANONICAL_TOPICS[:FRONTEND_DEVELOPMENT],
|
|
50
|
+
'css' => CANONICAL_TOPICS[:FRONTEND_DEVELOPMENT],
|
|
51
|
+
'html' => CANONICAL_TOPICS[:FRONTEND_DEVELOPMENT],
|
|
52
|
+
'tailwind' => CANONICAL_TOPICS[:FRONTEND_DEVELOPMENT],
|
|
53
|
+
'nextjs' => CANONICAL_TOPICS[:FRONTEND_DEVELOPMENT],
|
|
54
|
+
|
|
55
|
+
# DevOps & Cloud
|
|
56
|
+
'devops' => CANONICAL_TOPICS[:DEVOPS_CLOUD],
|
|
57
|
+
'cloud' => CANONICAL_TOPICS[:DEVOPS_CLOUD],
|
|
58
|
+
'aws' => CANONICAL_TOPICS[:DEVOPS_CLOUD],
|
|
59
|
+
'gcp' => CANONICAL_TOPICS[:DEVOPS_CLOUD],
|
|
60
|
+
'azure' => CANONICAL_TOPICS[:DEVOPS_CLOUD],
|
|
61
|
+
'docker' => CANONICAL_TOPICS[:DEVOPS_CLOUD],
|
|
62
|
+
'kubernetes' => CANONICAL_TOPICS[:DEVOPS_CLOUD],
|
|
63
|
+
'k8s' => CANONICAL_TOPICS[:DEVOPS_CLOUD],
|
|
64
|
+
'ci-cd' => CANONICAL_TOPICS[:DEVOPS_CLOUD],
|
|
65
|
+
'cicd' => CANONICAL_TOPICS[:DEVOPS_CLOUD],
|
|
66
|
+
'terraform' => CANONICAL_TOPICS[:DEVOPS_CLOUD],
|
|
67
|
+
|
|
68
|
+
# AI & Machine Learning
|
|
69
|
+
'ai' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
70
|
+
'artificial-intelligence' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
71
|
+
'machine-learning' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
72
|
+
'ml' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
73
|
+
'deep-learning' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
74
|
+
'llm' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
75
|
+
'datascience' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
76
|
+
'data-science' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
77
|
+
'nlp' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
78
|
+
'prompt-engineering' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
79
|
+
|
|
80
|
+
# Mobile Development
|
|
81
|
+
'mobile' => CANONICAL_TOPICS[:MOBILE_DEVELOPMENT],
|
|
82
|
+
'android' => CANONICAL_TOPICS[:MOBILE_DEVELOPMENT],
|
|
83
|
+
'ios' => CANONICAL_TOPICS[:MOBILE_DEVELOPMENT],
|
|
84
|
+
'flutter' => CANONICAL_TOPICS[:MOBILE_DEVELOPMENT],
|
|
85
|
+
'react-native' => CANONICAL_TOPICS[:MOBILE_DEVELOPMENT],
|
|
86
|
+
'swift' => CANONICAL_TOPICS[:MOBILE_DEVELOPMENT],
|
|
87
|
+
'kotlin' => CANONICAL_TOPICS[:MOBILE_DEVELOPMENT],
|
|
88
|
+
|
|
89
|
+
# Cybersecurity
|
|
90
|
+
'security' => CANONICAL_TOPICS[:CYBERSECURITY],
|
|
91
|
+
'cybersecurity' => CANONICAL_TOPICS[:CYBERSECURITY],
|
|
92
|
+
'infosec' => CANONICAL_TOPICS[:CYBERSECURITY],
|
|
93
|
+
'auth' => CANONICAL_TOPICS[:CYBERSECURITY],
|
|
94
|
+
'authentication' => CANONICAL_TOPICS[:CYBERSECURITY],
|
|
95
|
+
'oauth' => CANONICAL_TOPICS[:CYBERSECURITY],
|
|
96
|
+
'jwt' => CANONICAL_TOPICS[:CYBERSECURITY],
|
|
97
|
+
'cryptography' => CANONICAL_TOPICS[:CYBERSECURITY],
|
|
98
|
+
|
|
99
|
+
# Database & Data Engineering
|
|
100
|
+
'database' => CANONICAL_TOPICS[:DATABASE_DATA_ENGINEERING],
|
|
101
|
+
'databases' => CANONICAL_TOPICS[:DATABASE_DATA_ENGINEERING],
|
|
102
|
+
'sql' => CANONICAL_TOPICS[:DATABASE_DATA_ENGINEERING],
|
|
103
|
+
'postgresql' => CANONICAL_TOPICS[:DATABASE_DATA_ENGINEERING],
|
|
104
|
+
'postgres' => CANONICAL_TOPICS[:DATABASE_DATA_ENGINEERING],
|
|
105
|
+
'mysql' => CANONICAL_TOPICS[:DATABASE_DATA_ENGINEERING],
|
|
106
|
+
'mongodb' => CANONICAL_TOPICS[:DATABASE_DATA_ENGINEERING],
|
|
107
|
+
'nosql' => CANONICAL_TOPICS[:DATABASE_DATA_ENGINEERING],
|
|
108
|
+
'redis' => CANONICAL_TOPICS[:DATABASE_DATA_ENGINEERING],
|
|
109
|
+
|
|
110
|
+
# Software Architecture & Design
|
|
111
|
+
'architecture' => CANONICAL_TOPICS[:SOFTWARE_ARCHITECTURE],
|
|
112
|
+
'system-design' => CANONICAL_TOPICS[:SOFTWARE_ARCHITECTURE],
|
|
113
|
+
'design-patterns' => CANONICAL_TOPICS[:SOFTWARE_ARCHITECTURE],
|
|
114
|
+
'software-architecture' => CANONICAL_TOPICS[:SOFTWARE_ARCHITECTURE],
|
|
115
|
+
'unix' => CANONICAL_TOPICS[:SOFTWARE_ARCHITECTURE],
|
|
116
|
+
|
|
117
|
+
# Programming Languages
|
|
118
|
+
'programming' => CANONICAL_TOPICS[:PROGRAMMING_LANGUAGES],
|
|
119
|
+
'javascript' => CANONICAL_TOPICS[:PROGRAMMING_LANGUAGES],
|
|
120
|
+
'typescript' => CANONICAL_TOPICS[:PROGRAMMING_LANGUAGES],
|
|
121
|
+
'python' => CANONICAL_TOPICS[:PROGRAMMING_LANGUAGES],
|
|
122
|
+
'ruby' => CANONICAL_TOPICS[:PROGRAMMING_LANGUAGES],
|
|
123
|
+
'rust' => CANONICAL_TOPICS[:PROGRAMMING_LANGUAGES],
|
|
124
|
+
'golang' => CANONICAL_TOPICS[:PROGRAMMING_LANGUAGES],
|
|
125
|
+
'go' => CANONICAL_TOPICS[:PROGRAMMING_LANGUAGES],
|
|
126
|
+
'java' => CANONICAL_TOPICS[:PROGRAMMING_LANGUAGES],
|
|
127
|
+
'csharp' => CANONICAL_TOPICS[:PROGRAMMING_LANGUAGES],
|
|
128
|
+
'cpp' => CANONICAL_TOPICS[:PROGRAMMING_LANGUAGES],
|
|
129
|
+
|
|
130
|
+
# Testing & QA
|
|
131
|
+
'testing' => CANONICAL_TOPICS[:TESTING_QA],
|
|
132
|
+
'qa' => CANONICAL_TOPICS[:TESTING_QA],
|
|
133
|
+
'unit-testing' => CANONICAL_TOPICS[:TESTING_QA],
|
|
134
|
+
'jest' => CANONICAL_TOPICS[:TESTING_QA],
|
|
135
|
+
'cypress' => CANONICAL_TOPICS[:TESTING_QA],
|
|
136
|
+
'rspec' => CANONICAL_TOPICS[:TESTING_QA],
|
|
137
|
+
|
|
138
|
+
# Web Development
|
|
139
|
+
'webdev' => CANONICAL_TOPICS[:WEB_DEVELOPMENT],
|
|
140
|
+
'web-development' => CANONICAL_TOPICS[:WEB_DEVELOPMENT]
|
|
141
|
+
}.freeze
|
|
142
|
+
|
|
143
|
+
# Aliases for topic names to canonical names
|
|
144
|
+
TOPIC_SYNONYMS = {
|
|
145
|
+
'backend' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
146
|
+
'backend development' => CANONICAL_TOPICS[:BACKEND_DEVELOPMENT],
|
|
147
|
+
'frontend' => CANONICAL_TOPICS[:FRONTEND_DEVELOPMENT],
|
|
148
|
+
'frontend development' => CANONICAL_TOPICS[:FRONTEND_DEVELOPMENT],
|
|
149
|
+
'fullstack' => CANONICAL_TOPICS[:FULL_STACK_DEVELOPMENT],
|
|
150
|
+
'full stack' => CANONICAL_TOPICS[:FULL_STACK_DEVELOPMENT],
|
|
151
|
+
'full stack development' => CANONICAL_TOPICS[:FULL_STACK_DEVELOPMENT],
|
|
152
|
+
'devops' => CANONICAL_TOPICS[:DEVOPS_CLOUD],
|
|
153
|
+
'devops & cloud' => CANONICAL_TOPICS[:DEVOPS_CLOUD],
|
|
154
|
+
'cloud' => CANONICAL_TOPICS[:DEVOPS_CLOUD],
|
|
155
|
+
'ai' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
156
|
+
'machine learning' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
157
|
+
'ai & machine learning' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
158
|
+
'artificial intelligence' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
159
|
+
'artificial intelligence & machine learning' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
160
|
+
'data science & ai' => CANONICAL_TOPICS[:AI_MACHINE_LEARNING],
|
|
161
|
+
'mobile' => CANONICAL_TOPICS[:MOBILE_DEVELOPMENT],
|
|
162
|
+
'mobile development' => CANONICAL_TOPICS[:MOBILE_DEVELOPMENT],
|
|
163
|
+
'security' => CANONICAL_TOPICS[:CYBERSECURITY],
|
|
164
|
+
'cybersecurity' => CANONICAL_TOPICS[:CYBERSECURITY],
|
|
165
|
+
'database' => CANONICAL_TOPICS[:DATABASE_DATA_ENGINEERING],
|
|
166
|
+
'database & data engineering' => CANONICAL_TOPICS[:DATABASE_DATA_ENGINEERING],
|
|
167
|
+
'architecture' => CANONICAL_TOPICS[:SOFTWARE_ARCHITECTURE],
|
|
168
|
+
'software architecture' => CANONICAL_TOPICS[:SOFTWARE_ARCHITECTURE],
|
|
169
|
+
'software architecture & design' => CANONICAL_TOPICS[:SOFTWARE_ARCHITECTURE],
|
|
170
|
+
'programming' => CANONICAL_TOPICS[:PROGRAMMING_LANGUAGES],
|
|
171
|
+
'programming languages' => CANONICAL_TOPICS[:PROGRAMMING_LANGUAGES],
|
|
172
|
+
'testing' => CANONICAL_TOPICS[:TESTING_QA],
|
|
173
|
+
'testing & qa' => CANONICAL_TOPICS[:TESTING_QA],
|
|
174
|
+
'testing & quality assurance' => CANONICAL_TOPICS[:TESTING_QA],
|
|
175
|
+
'webdev' => CANONICAL_TOPICS[:WEB_DEVELOPMENT],
|
|
176
|
+
'web development' => CANONICAL_TOPICS[:WEB_DEVELOPMENT]
|
|
177
|
+
}.freeze
|
|
178
|
+
|
|
179
|
+
class << self
|
|
180
|
+
def normalize_topic_string(str)
|
|
181
|
+
return '' unless str.is_a?(String)
|
|
182
|
+
|
|
183
|
+
str.strip.downcase.gsub(/\s+/, ' ')
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def resolve_canonical_topic(topic_str)
|
|
187
|
+
normalized = normalize_topic_string(topic_str)
|
|
188
|
+
return nil if normalized.empty?
|
|
189
|
+
|
|
190
|
+
TOPIC_SYNONYMS[normalized]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def derive_topic_from_metadata(metadata)
|
|
194
|
+
return nil unless metadata.is_a?(Hash)
|
|
195
|
+
|
|
196
|
+
# 1. Authoritative topic/category if present
|
|
197
|
+
topic_val = metadata[:topic] || metadata['topic']
|
|
198
|
+
if topic_val && !topic_val.to_s.strip.empty?
|
|
199
|
+
canonical = resolve_canonical_topic(topic_val.to_s)
|
|
200
|
+
return canonical || topic_val.to_s.strip
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# 2. Derive from tags
|
|
204
|
+
tags = metadata[:tags] || metadata['tags']
|
|
205
|
+
return nil unless tags.is_a?(Array) && !tags.empty?
|
|
206
|
+
|
|
207
|
+
topic_counts = Hash.new(0)
|
|
208
|
+
tags.each do |tag|
|
|
209
|
+
norm_tag = tag.to_s.strip.downcase
|
|
210
|
+
mapped = TAG_TO_TOPIC_MAP[norm_tag]
|
|
211
|
+
topic_counts[mapped] += 1 if mapped
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
return nil if topic_counts.empty?
|
|
215
|
+
|
|
216
|
+
# Pick topic with most occurrences (or first on tie)
|
|
217
|
+
sorted = topic_counts.sort_by { |_topic, count| -count }
|
|
218
|
+
sorted.first[0]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def compare_topics(expected_topic, verified_topic)
|
|
222
|
+
return false if expected_topic.nil? || verified_topic.nil?
|
|
223
|
+
|
|
224
|
+
norm_expected = normalize_topic_string(expected_topic.to_s)
|
|
225
|
+
norm_verified = normalize_topic_string(verified_topic.to_s)
|
|
226
|
+
|
|
227
|
+
return true if norm_expected == norm_verified
|
|
228
|
+
|
|
229
|
+
canonical_expected = resolve_canonical_topic(expected_topic.to_s)
|
|
230
|
+
canonical_verified = resolve_canonical_topic(verified_topic.to_s)
|
|
231
|
+
|
|
232
|
+
if canonical_expected && canonical_verified && canonical_expected == canonical_verified
|
|
233
|
+
return true
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
if canonical_expected && normalize_topic_string(canonical_expected) == norm_verified
|
|
237
|
+
return true
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
if canonical_verified && normalize_topic_string(canonical_verified) == norm_expected
|
|
241
|
+
return true
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
false
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'constants/error_codes'
|
|
4
|
+
|
|
5
|
+
module BlogVerification
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
attr_reader :error_code, :field, :details
|
|
8
|
+
|
|
9
|
+
def initialize(error_code, message = nil, field = nil, details = {})
|
|
10
|
+
@error_code = error_code
|
|
11
|
+
@field = field
|
|
12
|
+
@details = details || {}
|
|
13
|
+
msg = message || ErrorCodes::DEFAULT_ERROR_MESSAGES[error_code] || 'An error occurred.'
|
|
14
|
+
super(msg)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class ValidationError < Error; end
|
|
19
|
+
|
|
20
|
+
class UpstreamError < Error
|
|
21
|
+
attr_reader :status_code
|
|
22
|
+
|
|
23
|
+
def initialize(error_code, message = nil, status_code = nil, details = {})
|
|
24
|
+
@status_code = status_code
|
|
25
|
+
super(error_code, message, nil, details)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class SSRFError < Error
|
|
30
|
+
def initialize(message = 'The target host or IP is private or restricted.')
|
|
31
|
+
super(ErrorCodes::UNSAFE_URL, message, 'url')
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BlogVerification
|
|
4
|
+
module Rails
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
initializer 'blog_verification.active_model_validator' do
|
|
7
|
+
::ActiveSupport.on_load(:active_record) do
|
|
8
|
+
require_relative 'validator'
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|