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,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