open_blog 0.1.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/CHANGELOG.md +52 -0
- data/LICENSE.txt +21 -0
- data/README.md +101 -0
- data/app/assets/builds/open_blog/blog.css +2 -0
- data/app/assets/javascripts/open_blog/controllers/code_copy_controller.js +40 -0
- data/app/assets/javascripts/open_blog/controllers/reading_progress_controller.js +36 -0
- data/app/assets/javascripts/open_blog/controllers/search_controller.js +49 -0
- data/app/assets/javascripts/open_blog/controllers/share_controller.js +48 -0
- data/app/assets/javascripts/open_blog/controllers/theme_controller.js +51 -0
- data/app/assets/javascripts/open_blog/controllers/toc_controller.js +33 -0
- data/app/controllers/concerns/open_blog/reader_controller.rb +49 -0
- data/app/controllers/open_blog/api/v1/adoptions_controller.rb +27 -0
- data/app/controllers/open_blog/api/v1/approvals_controller.rb +15 -0
- data/app/controllers/open_blog/api/v1/authors_controller.rb +62 -0
- data/app/controllers/open_blog/api/v1/base_controller.rb +100 -0
- data/app/controllers/open_blog/api/v1/categories_controller.rb +33 -0
- data/app/controllers/open_blog/api/v1/connections_controller.rb +45 -0
- data/app/controllers/open_blog/api/v1/doctor_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/faq_extractions_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/findings_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/images_controller.rb +22 -0
- data/app/controllers/open_blog/api/v1/pages_controller.rb +67 -0
- data/app/controllers/open_blog/api/v1/posts_controller.rb +114 -0
- data/app/controllers/open_blog/api/v1/previews_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/records_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/redirects_controller.rb +51 -0
- data/app/controllers/open_blog/api/v1/report_controller.rb +20 -0
- data/app/controllers/open_blog/api/v1/resources_controller.rb +39 -0
- data/app/controllers/open_blog/api/v1/series_controller.rb +32 -0
- data/app/controllers/open_blog/api/v1/tags_controller.rb +12 -0
- data/app/controllers/open_blog/api/v1/views_controller.rb +19 -0
- data/app/controllers/open_blog/application_controller.rb +5 -0
- data/app/controllers/open_blog/authors_controller.rb +14 -0
- data/app/controllers/open_blog/categories_controller.rb +15 -0
- data/app/controllers/open_blog/errors_controller.rb +7 -0
- data/app/controllers/open_blog/feeds_controller.rb +22 -0
- data/app/controllers/open_blog/mcp_controller.rb +38 -0
- data/app/controllers/open_blog/media_controller.rb +20 -0
- data/app/controllers/open_blog/pages_controller.rb +12 -0
- data/app/controllers/open_blog/posts_controller.rb +57 -0
- data/app/controllers/open_blog/previews_controller.rb +35 -0
- data/app/controllers/open_blog/search_controller.rb +32 -0
- data/app/controllers/open_blog/series_controller.rb +15 -0
- data/app/controllers/open_blog/sitemaps_controller.rb +10 -0
- data/app/controllers/open_blog/tags_controller.rb +15 -0
- data/app/helpers/open_blog/content_helper.rb +79 -0
- data/app/helpers/open_blog/dates_helper.rb +18 -0
- data/app/helpers/open_blog/faq_helper.rb +59 -0
- data/app/helpers/open_blog/head_helper.rb +75 -0
- data/app/helpers/open_blog/icons_helper.rb +10 -0
- data/app/helpers/open_blog/notices_helper.rb +47 -0
- data/app/helpers/open_blog/pagination_helper.rb +18 -0
- data/app/helpers/open_blog/structured_data_helper.rb +63 -0
- data/app/helpers/open_blog/theme_helper.rb +52 -0
- data/app/helpers/open_blog/url_helper.rb +58 -0
- data/app/jobs/open_blog/publish_scheduled_post_job.rb +14 -0
- data/app/models/concerns/open_blog/immutable.rb +13 -0
- data/app/models/concerns/open_blog/immutable_image_attachment.rb +43 -0
- data/app/models/concerns/open_blog/previewable.rb +38 -0
- data/app/models/open_blog/api_token.rb +34 -0
- data/app/models/open_blog/application_record.rb +5 -0
- data/app/models/open_blog/approval.rb +19 -0
- data/app/models/open_blog/author.rb +11 -0
- data/app/models/open_blog/baseline.rb +26 -0
- data/app/models/open_blog/category.rb +10 -0
- data/app/models/open_blog/connection_declaration.rb +16 -0
- data/app/models/open_blog/faq.rb +22 -0
- data/app/models/open_blog/image.rb +45 -0
- data/app/models/open_blog/page.rb +23 -0
- data/app/models/open_blog/page_view.rb +7 -0
- data/app/models/open_blog/post.rb +132 -0
- data/app/models/open_blog/publication.rb +17 -0
- data/app/models/open_blog/redirect.rb +16 -0
- data/app/models/open_blog/revision.rb +12 -0
- data/app/models/open_blog/series.rb +10 -0
- data/app/models/open_blog/tag.rb +11 -0
- data/app/models/open_blog/tagging.rb +7 -0
- data/app/queries/open_blog/api_post_query.rb +54 -0
- data/app/serializers/open_blog/adoption_result_serializer.rb +9 -0
- data/app/serializers/open_blog/author_serializer.rb +10 -0
- data/app/serializers/open_blog/category_serializer.rb +7 -0
- data/app/serializers/open_blog/doctor_serializer.rb +7 -0
- data/app/serializers/open_blog/error_serializer.rb +7 -0
- data/app/serializers/open_blog/extraction_result_serializer.rb +7 -0
- data/app/serializers/open_blog/findings_serializer.rb +13 -0
- data/app/serializers/open_blog/image_serializer.rb +9 -0
- data/app/serializers/open_blog/page_serializer.rb +9 -0
- data/app/serializers/open_blog/post_serializer.rb +59 -0
- data/app/serializers/open_blog/preview_serializer.rb +11 -0
- data/app/serializers/open_blog/records_serializer.rb +28 -0
- data/app/serializers/open_blog/redirect_serializer.rb +7 -0
- data/app/serializers/open_blog/series_serializer.rb +7 -0
- data/app/serializers/open_blog/tag_serializer.rb +7 -0
- data/app/serializers/open_blog/write_result_serializer.rb +9 -0
- data/app/views/open_blog/attachments/_blob.html.erb +1 -0
- data/app/views/open_blog/feeds/atom.xml.builder +27 -0
- data/app/views/open_blog/sitemaps/show.xml.builder +9 -0
- data/config/locales/open_blog.en.yml +95 -0
- data/config/routes.rb +52 -0
- data/db/migrate/20261003012512_create_open_blog_authors.rb +16 -0
- data/db/migrate/20261003012513_create_open_blog_categories.rb +13 -0
- data/db/migrate/20261003012514_create_open_blog_series.rb +11 -0
- data/db/migrate/20261003012515_create_open_blog_images.rb +17 -0
- data/db/migrate/20261003012516_create_open_blog_posts.rb +47 -0
- data/db/migrate/20261003012517_create_open_blog_faqs.rb +13 -0
- data/db/migrate/20261003012518_create_open_blog_tags.rb +17 -0
- data/db/migrate/20261003012519_create_open_blog_redirects.rb +14 -0
- data/db/migrate/20261003012520_create_open_blog_revisions.rb +14 -0
- data/db/migrate/20261003012521_create_open_blog_approvals.rb +21 -0
- data/db/migrate/20261003012522_create_open_blog_publications.rb +18 -0
- data/db/migrate/20261003012523_create_open_blog_baselines.rb +24 -0
- data/db/migrate/20261003012524_create_open_blog_connection_declarations.rb +13 -0
- data/db/migrate/20261003031410_add_body_image_manifest_to_open_blog_posts.rb +7 -0
- data/db/migrate/20261003164418_create_open_blog_api_tokens.rb +15 -0
- data/db/migrate/20261003175454_create_open_blog_pages.rb +16 -0
- data/db/migrate/20261003182919_create_open_blog_page_views.rb +11 -0
- data/lib/generators/open_blog/admin_suite/admin_suite_generator.rb +27 -0
- data/lib/generators/open_blog/admin_suite/templates/author_resource.rb.tt +29 -0
- data/lib/generators/open_blog/admin_suite/templates/blog_portal.rb.tt +16 -0
- data/lib/generators/open_blog/admin_suite/templates/category_resource.rb.tt +28 -0
- data/lib/generators/open_blog/admin_suite/templates/initializer.rb.tt +19 -0
- data/lib/generators/open_blog/admin_suite/templates/page_resource.rb.tt +32 -0
- data/lib/generators/open_blog/admin_suite/templates/post_resource.rb.tt +58 -0
- data/lib/generators/open_blog/generator_support.rb +64 -0
- data/lib/generators/open_blog/install/install_generator.rb +188 -0
- data/lib/generators/open_blog/install/templates/initializer.rb.tt +16 -0
- data/lib/generators/open_blog/install/templates/sample_cover.png +0 -0
- data/lib/generators/open_blog/install/templates/sample_post.md +54 -0
- data/lib/generators/open_blog/install/templates/theme/blog.css +193 -0
- data/lib/generators/open_blog/install/templates/theme/open_blog_theme.css +82 -0
- data/lib/generators/open_blog/install/templates/theme/syntax.css +358 -0
- data/lib/generators/open_blog/install/templates/theme/theme.css +120 -0
- data/lib/generators/open_blog/install/templates/views/layouts/open_blog.html.erb +16 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/authors/show.html.erb +17 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/categories/show.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/errors/not_found.html.erb +11 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/errors/preview_expired.html.erb +5 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_check.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_copy.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_link.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_monitor.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_moon.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_share.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_sun.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/pages/show.html.erb +5 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_faq.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_highlights.html.erb +7 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_preview_bar.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_series_nav.html.erb +12 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_tags.html.erb +5 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_toc.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/index.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/show.html.erb +29 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/search/show.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/series/show.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_author_box.html.erb +6 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_breadcrumbs.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_call_to_action.html.erb +7 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_featured_card.html.erb +11 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_footer.html.erb +11 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_header.html.erb +7 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_pagination.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_post_card.html.erb +9 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_related_posts.html.erb +10 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_search.html.erb +9 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_share.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_sidebar.html.erb +38 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_theme_toggle.html.erb +10 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/tags/show.html.erb +13 -0
- data/lib/generators/open_blog/views/views_generator.rb +21 -0
- data/lib/open_blog/actor.rb +7 -0
- data/lib/open_blog/admin_suite.rb +73 -0
- data/lib/open_blog/adopt/contract.rb +152 -0
- data/lib/open_blog/adopt/snapshot.rb +35 -0
- data/lib/open_blog/adopt.rb +159 -0
- data/lib/open_blog/api_fields.rb +40 -0
- data/lib/open_blog/approve.rb +18 -0
- data/lib/open_blog/authentication.rb +18 -0
- data/lib/open_blog/build_css.rb +53 -0
- data/lib/open_blog/configuration.rb +185 -0
- data/lib/open_blog/configuration_error.rb +4 -0
- data/lib/open_blog/content_guard.rb +77 -0
- data/lib/open_blog/doctor/files.rb +61 -0
- data/lib/open_blog/doctor/javascript.rb +57 -0
- data/lib/open_blog/doctor.rb +177 -0
- data/lib/open_blog/engine.rb +45 -0
- data/lib/open_blog/errors.rb +40 -0
- data/lib/open_blog/faq_extraction.rb +184 -0
- data/lib/open_blog/findings/body_checks.rb +58 -0
- data/lib/open_blog/findings/check.rb +19 -0
- data/lib/open_blog/findings/link_check.rb +78 -0
- data/lib/open_blog/findings/record_checks.rb +94 -0
- data/lib/open_blog/findings/registry.rb +18 -0
- data/lib/open_blog/findings.rb +27 -0
- data/lib/open_blog/image_fetch.rb +115 -0
- data/lib/open_blog/image_import.rb +164 -0
- data/lib/open_blog/image_resolution.rb +171 -0
- data/lib/open_blog/instructions.rb +7 -0
- data/lib/open_blog/json_feed.rb +36 -0
- data/lib/open_blog/label_policy.rb +41 -0
- data/lib/open_blog/markdown_view.rb +57 -0
- data/lib/open_blog/mcp/definition.rb +28 -0
- data/lib/open_blog/mcp/dispatcher.rb +33 -0
- data/lib/open_blog/mcp/image_input.rb +30 -0
- data/lib/open_blog/mcp/registry.rb +139 -0
- data/lib/open_blog/mcp/schemas.rb +72 -0
- data/lib/open_blog/mcp.rb +24 -0
- data/lib/open_blog/not_found.rb +4 -0
- data/lib/open_blog/operation.rb +36 -0
- data/lib/open_blog/page_views.rb +72 -0
- data/lib/open_blog/pagination.rb +18 -0
- data/lib/open_blog/plain_text.rb +36 -0
- data/lib/open_blog/policy_url.rb +9 -0
- data/lib/open_blog/post_attributes.rb +172 -0
- data/lib/open_blog/post_identity.rb +28 -0
- data/lib/open_blog/publish.rb +7 -0
- data/lib/open_blog/reader_dates.rb +21 -0
- data/lib/open_blog/reader_page.rb +21 -0
- data/lib/open_blog/reader_queries.rb +90 -0
- data/lib/open_blog/record_release.rb +66 -0
- data/lib/open_blog/redirect_target.rb +58 -0
- data/lib/open_blog/remove.rb +43 -0
- data/lib/open_blog/renderer/highlighter.rb +44 -0
- data/lib/open_blog/renderer/markdown.rb +14 -0
- data/lib/open_blog/renderer/rich_text.rb +19 -0
- data/lib/open_blog/renderer/sanitizer.rb +72 -0
- data/lib/open_blog/renderer/scrubber.rb +35 -0
- data/lib/open_blog/renderer/transform.rb +115 -0
- data/lib/open_blog/renderer.rb +51 -0
- data/lib/open_blog/result.rb +11 -0
- data/lib/open_blog/revision_payload.rb +67 -0
- data/lib/open_blog/rich_text_guard.rb +26 -0
- data/lib/open_blog/sample.rb +56 -0
- data/lib/open_blog/save_draft.rb +7 -0
- data/lib/open_blog/search.rb +21 -0
- data/lib/open_blog/sitemap_entries.rb +66 -0
- data/lib/open_blog/surface_report/expected_content.rb +27 -0
- data/lib/open_blog/surface_report/http.rb +92 -0
- data/lib/open_blog/surface_report/page_checks.rb +303 -0
- data/lib/open_blog/surface_report/reach_checks.rb +141 -0
- data/lib/open_blog/surface_report/record_checks.rb +248 -0
- data/lib/open_blog/surface_report/robots.rb +49 -0
- data/lib/open_blog/surface_report.rb +223 -0
- data/lib/open_blog/syntax_css.rb +55 -0
- data/lib/open_blog/unpublish.rb +13 -0
- data/lib/open_blog/version.rb +3 -0
- data/lib/open_blog/write_post.rb +205 -0
- data/lib/open_blog.rb +91 -0
- data/lib/tasks/open_blog.rake +71 -0
- data/skills/open-blog-adopt/SKILL.md +12 -0
- data/skills/open-blog-install/SKILL.md +10 -0
- data/skills/open-blog-policy-pages/SKILL.md +10 -0
- data/skills/open-blog-publish/SKILL.md +14 -0
- data/skills/open-blog-report/SKILL.md +10 -0
- data/skills/open-blog-update/SKILL.md +16 -0
- metadata +515 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "json"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module OpenBlog
|
|
6
|
+
class SurfaceReport
|
|
7
|
+
class PageChecks
|
|
8
|
+
PAGE_IDS = %w[E1 E3 E5 T3 T7 T8 T9 T10 T11 T12 T13 T15 T16 T17].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(context)
|
|
11
|
+
@context = context
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
@context.posts.each { |post| check_post(post) } unless site_scope?
|
|
16
|
+
check_policies
|
|
17
|
+
check_sitemap
|
|
18
|
+
check_feeds
|
|
19
|
+
check_lists
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def site_scope?
|
|
25
|
+
@context.respond_to?(:scope) && @context.scope == :site
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def record(id, status, reason, **options)
|
|
29
|
+
@context.record(id, status, reason, **options)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def outcome(id, valid, reason, **options)
|
|
33
|
+
record(id, valid ? :pass : :fail, reason, **options)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def unavailable(page)
|
|
37
|
+
page.status.to_i == 0 ? :not_verified : :fail
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def check_post(post)
|
|
41
|
+
page = @context.post_page(post)
|
|
42
|
+
unless page.status == 200
|
|
43
|
+
PAGE_IDS.each { |id| record(id, unavailable(page), "The post page could not be read successfully.", post: post) }
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
doc, article = page.document, page.article || {}
|
|
47
|
+
visible_author = doc.at_css(".ob-byline-author")
|
|
48
|
+
author = article["author"]
|
|
49
|
+
author = author.first if author.is_a?(Array)
|
|
50
|
+
author = {} unless author.is_a?(Hash)
|
|
51
|
+
outcome("E1", text(visible_author).present? && author["name"].present?, "Visible and structured author names are required.", post: post)
|
|
52
|
+
author_url = resolve(page.url, visible_author&.[]("href")) if text(visible_author).present?
|
|
53
|
+
author_page = @context.page(author_url)
|
|
54
|
+
record("E3", author_url ? (author_page.status == 200 ? :pass : unavailable(author_page)) : :fail,
|
|
55
|
+
"The visible author link must open successfully.", post: post)
|
|
56
|
+
check_publication_date(post, doc)
|
|
57
|
+
canonicals = doc.css("head link[rel~='canonical']")
|
|
58
|
+
canonical = canonicals.first&.[]("href")
|
|
59
|
+
expected = post.canonical_url.presence || @context.absolute(post.path)
|
|
60
|
+
if canonicals.length != 1 || !absolute_http?(canonical) || canonical != expected
|
|
61
|
+
record("T3", :fail, "One absolute canonical link must identify this post or its original.", post: post)
|
|
62
|
+
else
|
|
63
|
+
target = @context.page(canonical)
|
|
64
|
+
record("T3", target.status == 200 ? :pass : unavailable(target), "The canonical destination must open successfully.", post: post)
|
|
65
|
+
end
|
|
66
|
+
{ "T7" => "head title", "T8" => "head meta[name='description']" }.each do |id, selector|
|
|
67
|
+
value = metadata(doc, selector)
|
|
68
|
+
duplicates = @context.posts.count { |other| metadata(@context.post_page(other).document, selector) == value }
|
|
69
|
+
outcome(id, value.present? && duplicates == 1, "The page needs a nonempty, distinct #{id == 'T7' ? 'title' : 'description'}.", post: post)
|
|
70
|
+
end
|
|
71
|
+
levels = doc.css("h1,h2,h3,h4,h5,h6").map { |node| node.name[1].to_i }
|
|
72
|
+
outcome("T9", levels.each_cons(2).none? { |a, b| b > a + 1 }, "Heading levels must progress without skipping a level.", post: post)
|
|
73
|
+
h1 = doc.css("h1")
|
|
74
|
+
outcome("T10", h1.length == 1 && text(h1.first) == post.title.squish, "One main heading must contain the post title.", post: post)
|
|
75
|
+
lang = doc.at_css("html")&.[]("lang").to_s
|
|
76
|
+
outcome("T11", language_tag?(lang), "The document needs a language tag.", post: post)
|
|
77
|
+
viewport = doc.at_css("head meta[name='viewport']")&.[]("content").to_s
|
|
78
|
+
outcome("T12", viewport.match?(/(?:\A|[,;\s])width\s*=\s*device-width(?:\z|[,;\s])/i), "The viewport declaration must use device width.", post: post)
|
|
79
|
+
outcome("T13", doc.css("img").all? { |image| image.key?("alt") }, "Every image needs an alt attribute, including decorative images.", post: post)
|
|
80
|
+
structured = page.articles.length == 1 && article["headline"].present? && author["name"].present? && author["@type"].present? &&
|
|
81
|
+
timestamp(article["datePublished"]) && timestamp(article["dateModified"]) && (!(post.cover_image || post.social_image) || article["image"].present?)
|
|
82
|
+
outcome("T15", structured, post.published_at ? "Article data needs its title, author and publication dates, plus an image when used." : "The publication date is unknown in the imported history.", post: post)
|
|
83
|
+
published, modified = timestamp(article["datePublished"]), timestamp(article["dateModified"])
|
|
84
|
+
visible_published, visible_modified = visible_date(doc, :published), visible_date(doc, :updated)
|
|
85
|
+
agrees = author["name"].to_s.squish == text(visible_author) && text(visible_author).present? && article["headline"].to_s.squish == post.title.squish &&
|
|
86
|
+
published && modified && published <= @context.now && modified <= @context.now && visible_published == published.to_date &&
|
|
87
|
+
(!doc.at_css(".ob-date--updated time") || visible_modified == modified.to_date)
|
|
88
|
+
outcome("T16", agrees, "Structured author and dates must agree with visible content and cannot be in the future.", post: post)
|
|
89
|
+
outcome("T17", %w[title type image url].all? { |field| doc.at_css("head meta[property='og:#{field}']")&.[]("content").present? },
|
|
90
|
+
"Sharing metadata needs a title, type, image and URL.", post: post)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def check_publication_date(post, doc)
|
|
94
|
+
first = post.publications.find_by(entry_type: "first")&.occurred_at
|
|
95
|
+
baseline = post.baseline
|
|
96
|
+
date = first || baseline&.first_published_at || baseline&.declared_first_published_at
|
|
97
|
+
unless date
|
|
98
|
+
return record("E5", :not_verified, "A reliable first publication date is unavailable.", post: post)
|
|
99
|
+
end
|
|
100
|
+
marks = !first && baseline&.first_published_at.nil? && baseline&.declared_first_published_at ? [ "publisher declaration" ] : []
|
|
101
|
+
outcome("E5", visible_date(doc, :published) == date.to_date, "The labeled publication date must match the recorded first publication.", post: post, marks: marks)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def check_policies
|
|
105
|
+
{ "E4" => :responsible_party, "E8" => :corrections, "E16" => :editorial, "E17" => :ai_use }.each do |id, kind|
|
|
106
|
+
url = @context.absolute(OpenBlog.policy_url(kind, config: @context.config))
|
|
107
|
+
page = @context.page(url)
|
|
108
|
+
status = url ? (page.status == 200 ? :pass : unavailable(page)) : (@context.config.public_base_url.present? ? :fail : :not_verified)
|
|
109
|
+
if id == "E4" && @context.posts.any? && !site_scope?
|
|
110
|
+
@context.posts.each do |post|
|
|
111
|
+
post_page = @context.post_page(post)
|
|
112
|
+
result = post_page.status == 200 ? status : unavailable(post_page)
|
|
113
|
+
if result == :pass
|
|
114
|
+
linked = post_page.document.css("a[href]").any? { |node| resolve(post_page.url, node["href"]) == url }
|
|
115
|
+
result = :fail unless linked
|
|
116
|
+
end
|
|
117
|
+
record(id, result, "The responsible-party page must open and be linked from the post.", post: post)
|
|
118
|
+
end
|
|
119
|
+
else
|
|
120
|
+
if id == "E4" && status == :pass
|
|
121
|
+
statuses = @context.posts.map do |post|
|
|
122
|
+
source = @context.post_page(post)
|
|
123
|
+
next unavailable(source) unless source.status == 200
|
|
124
|
+
source.document.css("a[href]").any? { |node| resolve(source.url, node["href"]) == url } ? :pass : :fail
|
|
125
|
+
end
|
|
126
|
+
status = combine(statuses)
|
|
127
|
+
end
|
|
128
|
+
record(id, status, "The configured policy page must open successfully.")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def check_sitemap
|
|
134
|
+
page = @context.page(@context.absolute("#{OpenBlog.mount_path.chomp('/')}/sitemap.xml"))
|
|
135
|
+
return record("T5", unavailable(page), "The sitemap could not be read successfully.") unless page.status == 200
|
|
136
|
+
xml = Nokogiri::XML(page.body) { |options| options.strict.nonet }
|
|
137
|
+
entries = xml.xpath("//*[local-name()='url']").to_h do |entry|
|
|
138
|
+
[ entry.at_xpath("./*[local-name()='loc']")&.text, entry.at_xpath("./*[local-name()='lastmod']")&.text ]
|
|
139
|
+
end
|
|
140
|
+
valid = xml.root&.name == "urlset" && @context.posts.all? do |post|
|
|
141
|
+
url = @context.absolute(post.path)
|
|
142
|
+
canonical = post.canonical_url.presence || url
|
|
143
|
+
if origin(canonical) != origin(url)
|
|
144
|
+
!entries.key?(url)
|
|
145
|
+
else
|
|
146
|
+
value = entries[url]
|
|
147
|
+
modified = modified_at(post)
|
|
148
|
+
entries.key?(url) && (value.blank? || (calendar_date(value) == modified&.to_date && modified.present?))
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
outcome("T5", valid, "Sitemap membership and modification dates must match public posts.")
|
|
152
|
+
rescue Nokogiri::XML::SyntaxError
|
|
153
|
+
record("T5", :fail, "The sitemap is not valid XML.")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def check_feeds
|
|
157
|
+
pages = [ @context.page(@context.absolute(OpenBlog.mount_path)) ] + @context.posts.map { |post| @context.post_page(post) }
|
|
158
|
+
statuses = pages.map do |page|
|
|
159
|
+
next unavailable(page) unless page.status == 200
|
|
160
|
+
links = page.document.css("head link[rel~='alternate']").select { |link| %w[application/atom+xml application/feed+json].include?(link["type"]) }
|
|
161
|
+
next :fail if links.empty?
|
|
162
|
+
checks = links.map do |link|
|
|
163
|
+
feed = @context.page(resolve(page.url, link["href"]))
|
|
164
|
+
next unavailable(feed) unless feed.status == 200
|
|
165
|
+
valid_feed?(feed, link["type"]) ? :pass : :fail
|
|
166
|
+
end
|
|
167
|
+
combine(checks)
|
|
168
|
+
end
|
|
169
|
+
record("T6", combine(statuses), "Feed discovery, required fields and the newest post must be available.")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def valid_feed?(page, type)
|
|
173
|
+
newest = ReaderQueries.posts.first
|
|
174
|
+
url = newest && @context.absolute(newest.path)
|
|
175
|
+
if type == "application/feed+json"
|
|
176
|
+
feed = JSON.parse(page.body)
|
|
177
|
+
return false unless feed.is_a?(Hash)
|
|
178
|
+
items = feed["items"]
|
|
179
|
+
feed["version"] == "https://jsonfeed.org/version/1.1" && feed["title"].is_a?(String) && items.is_a?(Array) &&
|
|
180
|
+
items.all? { |item| item.is_a?(Hash) && item["id"].is_a?(String) && (item["content_html"].is_a?(String) || item["content_text"].is_a?(String)) } &&
|
|
181
|
+
(!url || items.any? { |item| item["id"] == url })
|
|
182
|
+
else
|
|
183
|
+
xml = Nokogiri::XML(page.body) { |options| options.strict.nonet }
|
|
184
|
+
ns = { "a" => "http://www.w3.org/2005/Atom" }
|
|
185
|
+
xml.root&.name == "feed" && xml.root&.namespace&.href == ns["a"] && %w[id title updated].all? { |field| xml.at_xpath("/a:feed/a:#{field}", ns)&.text.present? } &&
|
|
186
|
+
xml.xpath("/a:feed/a:entry", ns).all? { |entry| %w[id title updated].all? { |field| entry.at_xpath("a:#{field}", ns)&.text.present? } &&
|
|
187
|
+
(entry.at_xpath("a:author/a:name", ns)&.text.present? || xml.at_xpath("/a:feed/a:author/a:name", ns)&.text.present?) } &&
|
|
188
|
+
(!url || xml.xpath("/a:feed/a:entry/a:id", ns).any? { |entry| entry.text == url })
|
|
189
|
+
end
|
|
190
|
+
rescue JSON::ParserError, Nokogiri::XML::SyntaxError
|
|
191
|
+
false
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def check_lists
|
|
195
|
+
return record("T21", :not_verified, "A primary list type is not configured.") unless @context.config.primary_list_type
|
|
196
|
+
pages = list_urls.map { |url, primary| [ @context.page(url), primary ] }
|
|
197
|
+
policies = OpenBlog::Page.published.reject { |page| @context.config.policy_urls[page.kind.to_sym].present? }
|
|
198
|
+
visible = @context.posts.map { |post| @context.post_page(post) } + pages.map(&:first) +
|
|
199
|
+
policies.map { |page| @context.page(@context.absolute(page.path)) }
|
|
200
|
+
indexed = visible.select { |page| page.status == 200 && !robots(page).include?("noindex") }
|
|
201
|
+
statuses = pages.map do |page, primary|
|
|
202
|
+
next unavailable(page) unless page.status == 200
|
|
203
|
+
directives = robots(page)
|
|
204
|
+
next :fail if primary && directives.include?("noindex")
|
|
205
|
+
next(directives.include?("nofollow") ? :fail : :pass) if directives.include?("noindex")
|
|
206
|
+
unique = [ "head title", "head meta[name='description']" ].all? do |selector|
|
|
207
|
+
value = metadata(page.document, selector)
|
|
208
|
+
value.present? && indexed.count { |other| metadata(other.document, selector) == value } == 1
|
|
209
|
+
end
|
|
210
|
+
next :fail unless unique
|
|
211
|
+
allowed = %w[Googlebot bingbot].map { |agent| @context.robots_allowed?(page.url, agent) }
|
|
212
|
+
allowed.include?(false) ? :fail : (allowed.include?(nil) ? :not_verified : :pass)
|
|
213
|
+
end
|
|
214
|
+
record("T21", combine(statuses), "List visibility, crawler access and distinct metadata must agree with the configured list type.")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def list_urls
|
|
218
|
+
mount = OpenBlog.mount_path.chomp("/")
|
|
219
|
+
featured = ReaderQueries.featured
|
|
220
|
+
paths = pagination_urls(mount.presence || "/", Post.listed.count - (featured ? 1 : 0), true)
|
|
221
|
+
{ category: Category, tag: Tag, author: Author, series: Series }.each do |kind, model|
|
|
222
|
+
model.find_each do |record|
|
|
223
|
+
path = "#{mount}/#{@context.config.route_segments.fetch(kind)}/#{record.slug}"
|
|
224
|
+
paths.concat(pagination_urls(path, record.posts.listed.count, @context.config.primary_list_type.to_s.singularize == kind.to_s))
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
paths
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def pagination_urls(path, count, primary)
|
|
231
|
+
total = [ (count.to_f / @context.config.posts_per_page).ceil, 1 ].max
|
|
232
|
+
(1..total).map { |number| [ @context.absolute(number == 1 ? path : "#{path}?page=#{number}"), primary ] }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def robots(page)
|
|
236
|
+
(page.document.css("meta[name='robots']").map { |node| node["content"] }.join(",") + "," + page.headers.to_h.find { |key, _| key.to_s.downcase == "x-robots-tag" }&.last.to_s).downcase.split(/[\s,]+/)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def modified_at(post)
|
|
240
|
+
post.publications.where(entry_type: %w[substantive correction]).maximum(:occurred_at) || post.baseline&.last_modified_at ||
|
|
241
|
+
post.publications.find_by(entry_type: "first")&.occurred_at || post.baseline&.first_published_at || post.baseline&.declared_first_published_at
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def combine(statuses)
|
|
245
|
+
statuses.include?(:fail) ? :fail : (statuses.include?(:not_verified) ? :not_verified : :pass)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def metadata(doc, selector)
|
|
249
|
+
node = doc.at_css(selector)
|
|
250
|
+
node&.name == "meta" ? node["content"].to_s.squish : text(node)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def text(node)
|
|
254
|
+
return "" unless node && ![ node, *node.ancestors ].any? { |parent| parent.key?("hidden") || parent["aria-hidden"] == "true" }
|
|
255
|
+
node.text.squish
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def visible_date(doc, kind)
|
|
259
|
+
wrapper = doc.at_css(".ob-date--#{kind}")
|
|
260
|
+
return unless wrapper && text(wrapper.at_css("span")).present? && text(wrapper.at_css("time")).present?
|
|
261
|
+
timestamp(wrapper.at_css("time")&.[]("datetime"))&.to_date
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def language_tag?(value)
|
|
265
|
+
value.match?(/\A(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4}|[a-z]{5,8})(?:-[a-z]{4})?(?:-(?:[a-z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)\z/i)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def calendar_date(value)
|
|
269
|
+
timestamp(value)&.to_date || Date.iso8601(value.to_s)
|
|
270
|
+
rescue ArgumentError
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def timestamp(value)
|
|
275
|
+
Time.iso8601(value.to_s)
|
|
276
|
+
rescue ArgumentError
|
|
277
|
+
nil
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def absolute_http?(value)
|
|
281
|
+
uri = URI.parse(value.to_s)
|
|
282
|
+
uri.is_a?(URI::HTTP) && uri.host.present? && uri.userinfo.nil?
|
|
283
|
+
rescue URI::InvalidURIError
|
|
284
|
+
false
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def resolve(base, path)
|
|
288
|
+
return unless base.present? && path.present?
|
|
289
|
+
url = URI.join(base, path).to_s
|
|
290
|
+
url if absolute_http?(url)
|
|
291
|
+
rescue URI::InvalidURIError
|
|
292
|
+
nil
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def origin(url)
|
|
296
|
+
uri = URI.parse(url.to_s)
|
|
297
|
+
[ uri.scheme, uri.host&.downcase, uri.port ]
|
|
298
|
+
rescue URI::InvalidURIError
|
|
299
|
+
nil
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module OpenBlog
|
|
4
|
+
class SurfaceReport
|
|
5
|
+
class ReachChecks
|
|
6
|
+
REDIRECTS = [ 301, 302, 303, 307, 308 ].freeze
|
|
7
|
+
SIGN_IN_PATH = %r{(?:\A|/)(?:login|log-in|signin|sign-in|sign_in|sign-on)(?:/|\z)}i
|
|
8
|
+
|
|
9
|
+
def initialize(context)
|
|
10
|
+
@context = context
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
unless @context.reach
|
|
15
|
+
%w[T1 T2 T14].each { |key| @context.record(key, "not_verified", "Network checks were not requested.") }
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
@context.posts.each do |post|
|
|
19
|
+
page = @context.post_page(post)
|
|
20
|
+
if page.status == 0
|
|
21
|
+
%w[T1 T2 T14].each { |key| record(key, "not_verified", "The public page could not be fetched.", post) }
|
|
22
|
+
next
|
|
23
|
+
end
|
|
24
|
+
crawler_check(post, page)
|
|
25
|
+
transport_check(post, page)
|
|
26
|
+
link_check(post, page)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def crawler_check(post, page)
|
|
33
|
+
if page.status != 200 || page.document.at_css("[data-open-blog-content]").nil? || page.document.at_css("[data-open-blog-content]").text.strip.empty? || noindex?(page)
|
|
34
|
+
return record("T1", "fail", "The page is unavailable, empty, or excludes search indexing.", post)
|
|
35
|
+
end
|
|
36
|
+
allowed = %w[Googlebot bingbot].map { |agent| @context.robots_allowed?(page.url, agent) }
|
|
37
|
+
if allowed.include?(false)
|
|
38
|
+
record("T1", "fail", "A search crawler is blocked by robots rules.", post)
|
|
39
|
+
elsif allowed.include?(nil)
|
|
40
|
+
record("T1", "not_verified", "Crawler access rules could not be checked.", post)
|
|
41
|
+
else
|
|
42
|
+
record("T1", "pass", "The page permits both checked search crawlers.", post)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def noindex?(page)
|
|
47
|
+
values = page.document.css("meta[name]").filter_map do |node|
|
|
48
|
+
node["content"] if %w[robots googlebot bingbot].include?(node["name"].to_s.downcase)
|
|
49
|
+
end
|
|
50
|
+
header = page.headers["x-robots-tag"].to_s
|
|
51
|
+
agent = nil
|
|
52
|
+
header.split(",").each do |part|
|
|
53
|
+
if part.include?(":")
|
|
54
|
+
prefix, directive = part.split(":", 2)
|
|
55
|
+
if prefix.strip.match?(/\A[a-z_-]+\z/i)
|
|
56
|
+
agent = prefix.strip.downcase
|
|
57
|
+
part = directive
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
values << part if agent.nil? || %w[googlebot bingbot].include?(agent)
|
|
61
|
+
end
|
|
62
|
+
values.any? { |value| value.to_s.match?(/\b(?:noindex|none)\b/i) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def transport_check(post, page)
|
|
66
|
+
secure = URI(page.url)
|
|
67
|
+
secure.scheme = "https"
|
|
68
|
+
secure.port = nil if secure.port == 80
|
|
69
|
+
secured = secure.to_s == page.url ? page : @context.page(secure.to_s)
|
|
70
|
+
plain = secure.dup
|
|
71
|
+
plain.scheme = "http"
|
|
72
|
+
plain.port = nil if plain.port == 443
|
|
73
|
+
response = @context.page(plain.to_s)
|
|
74
|
+
if secured.status == 0 || response.status == 0
|
|
75
|
+
return record("T2", "not_verified", "Both transport variants could not be fetched.", post)
|
|
76
|
+
end
|
|
77
|
+
destination = resolve(response.url, response.headers["location"])
|
|
78
|
+
passed = secured.status == 200 && REDIRECTS.include?(response.status) && destination && URI(destination).scheme == "https"
|
|
79
|
+
record("T2", passed ? "pass" : "fail", passed ? "HTTPS is available and HTTP redirects to HTTPS." : "HTTPS or the HTTP upgrade is missing.", post)
|
|
80
|
+
rescue URI::InvalidURIError
|
|
81
|
+
record("T2", "fail", "The transport redirect has an invalid destination.", post)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def link_check(post, page)
|
|
85
|
+
unless page.status == 200
|
|
86
|
+
return record("T14", "not_verified", "Links require an available page.", post)
|
|
87
|
+
end
|
|
88
|
+
links = page.document.css("a[href]").filter_map { |node| resolve(page.url, node["href"]) }.uniq
|
|
89
|
+
links.select! { |url| origin(url) == origin(page.url) }
|
|
90
|
+
statuses = links.map { |url| check_link(url) }
|
|
91
|
+
failed = statuses.select { |entry| entry[:result] == "fail" }
|
|
92
|
+
unknown = statuses.select { |entry| entry[:result] == "not_verified" }
|
|
93
|
+
status = failed.any? ? "fail" : unknown.any? ? "not_verified" : "pass"
|
|
94
|
+
reasons = { "pass" => "Same-site links meet the response checks.", "fail" => "Some same-site links failed the response checks.", "not_verified" => "Some link destinations could not be classified or fetched." }
|
|
95
|
+
record("T14", status, reasons.fetch(status), post, details: statuses.reject { |entry| entry[:result] == "pass" })
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def check_link(url)
|
|
99
|
+
response = @context.page(url)
|
|
100
|
+
return { url: url, result: "not_verified" } if response.status == 0
|
|
101
|
+
declaration = @context.config.sign_in_destinations
|
|
102
|
+
declared = Array(declaration).any? { |prefix| URI(url).path.start_with?(prefix) }
|
|
103
|
+
redirected = REDIRECTS.include?(response.status)
|
|
104
|
+
if redirected
|
|
105
|
+
destination = resolve(response.url, response.headers["location"])
|
|
106
|
+
return { url: url, result: "fail" } unless destination
|
|
107
|
+
response = @context.page(destination)
|
|
108
|
+
end
|
|
109
|
+
return { url: url, result: "not_verified" } if response.status == 0
|
|
110
|
+
sign_in = URI(response.url).path.match?(SIGN_IN_PATH) || response.document.at_css('input[type="password"]') || [ 401, 403 ].include?(response.status)
|
|
111
|
+
result = if !declared && sign_in && declaration.nil?
|
|
112
|
+
"not_verified"
|
|
113
|
+
elsif declared
|
|
114
|
+
([ 200, 401, 403 ].include?(response.status) || (redirected && sign_in)) && !REDIRECTS.include?(response.status) ? "pass" : "fail"
|
|
115
|
+
else
|
|
116
|
+
response.status == 200 && !sign_in ? "pass" : "fail"
|
|
117
|
+
end
|
|
118
|
+
{ url: url, result: result }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def resolve(base, reference)
|
|
122
|
+
return unless reference.is_a?(String) && reference.present?
|
|
123
|
+
uri = URI.join(base, reference)
|
|
124
|
+
return unless uri.is_a?(URI::HTTP) && uri.host.present? && uri.userinfo.nil?
|
|
125
|
+
uri.fragment = nil
|
|
126
|
+
uri.to_s
|
|
127
|
+
rescue URI::InvalidURIError, URI::BadURIError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def origin(url)
|
|
132
|
+
uri = URI(url)
|
|
133
|
+
[ uri.scheme, uri.host&.downcase, uri.port ]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def record(key, status, reason, post, details: nil)
|
|
137
|
+
@context.record(key, status, reason, post: post, details: details)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|