Files
draletaha/DrSousan.Api/Pages/Blog/Post.cshtml.cs
T
soroush.asadi 1c9d8cdc1b
CI/CD / CI · dotnet build (push) Successful in 16m5s
CI/CD / Deploy · drsousan (push) Successful in 29s
feat(seo): FAQPage structured data on blog posts
Extracts Q/A pairs from the post body (an <h3> ending in the Persian
question mark ؟ followed by the next <p>) and emits FAQPage JSON-LD in
<head>. Makes posts with FAQ sections eligible for FAQ rich results in
Google. Non-question <h3> headings are ignored.

Verified: post with 3 h3s emits exactly 2 Question entries (the plain
heading excluded), valid escaped JSON.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 01:02:44 +03:30

183 lines
6.8 KiB
C#

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using DrSousan.Api.Data;
using DrSousan.Api.Models;
namespace DrSousan.Api.Pages.Blog;
public class PostModel : PageModel
{
private readonly AppDbContext _db;
public PostModel(AppDbContext db) => _db = db;
public BlogPost? Post { get; private set; }
public List<CommentVm> Comments { get; private set; } = new();
public bool CommentSent { get; private set; } = false;
// (question, answer) pairs extracted from the post body for FAQPage JSON-LD
public List<(string Q, string A)> Faqs { get; private set; } = new();
// Comment form binding
[BindProperty]
[Required(ErrorMessage = "نام الزامی است.")]
[MaxLength(100)]
public string CommentAuthor { get; set; } = "";
[BindProperty]
[MaxLength(200)]
[EmailAddress]
public string CommentEmail { get; set; } = "";
[BindProperty]
[Required(ErrorMessage = "متن نظر الزامی است.")]
public string CommentBody { get; set; } = "";
public async Task<IActionResult> OnGetAsync(string slug)
{
var post = await _db.BlogPosts
.Include(p => p.Category)
.FirstOrDefaultAsync(p => p.Slug == slug && p.IsPublished);
if (post is null) return NotFound();
// Increment view count
post.ViewCount++;
await _db.SaveChangesAsync();
Post = post;
Faqs = ExtractFaqs(post.Content);
await LoadCommentsAsync(post.Id);
await SetViewDataAsync(post);
return Page();
}
// Pull FAQ pairs from the body: an <h3> whose text ends with the Persian
// question mark (؟) followed by the next <p>. Drives FAQPage rich results.
private static List<(string, string)> ExtractFaqs(string html)
{
var list = new List<(string, string)>();
if (string.IsNullOrEmpty(html)) return list;
var rx = new System.Text.RegularExpressions.Regex(
@"<h3[^>]*>(?<q>.*?)</h3>\s*<p[^>]*>(?<a>.*?)</p>",
System.Text.RegularExpressions.RegexOptions.Singleline |
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in rx.Matches(html))
{
var q = Strip(m.Groups["q"].Value);
var a = Strip(m.Groups["a"].Value);
if (q.EndsWith("؟") && a.Length > 0) list.Add((q, a));
}
return list;
}
private static string Strip(string s) =>
System.Net.WebUtility.HtmlDecode(
System.Text.RegularExpressions.Regex.Replace(s, "<[^>]*>", "")).Trim();
public async Task<IActionResult> OnPostAsync(string slug)
{
var post = await _db.BlogPosts
.Include(p => p.Category)
.FirstOrDefaultAsync(p => p.Slug == slug && p.IsPublished);
if (post is null) return NotFound();
Post = post;
await LoadCommentsAsync(post.Id);
await SetViewDataAsync(post);
if (!ModelState.IsValid) return Page();
// Sanitise
var body = System.Text.RegularExpressions.Regex.Replace(CommentBody ?? "", "<[^>]*>", "").Trim();
var authorName = System.Text.RegularExpressions.Regex.Replace(CommentAuthor ?? "", "<[^>]*>", "").Trim();
if (string.IsNullOrWhiteSpace(body) || string.IsNullOrWhiteSpace(authorName))
{
ModelState.AddModelError("", "نام و متن نظر الزامی است.");
return Page();
}
_db.Comments.Add(new Comment
{
BlogPostId = post.Id,
AuthorName = authorName,
AuthorEmail = CommentEmail ?? "",
Body = body,
IsApproved = false,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
CommentSent = true;
// Clear form
CommentAuthor = "";
CommentEmail = "";
CommentBody = "";
ModelState.Clear();
return Page();
}
private async Task LoadCommentsAsync(int postId)
{
var raw = await _db.Comments
.Where(c => c.BlogPostId == postId && c.IsApproved && c.ParentId == null)
.OrderBy(c => c.CreatedAt)
.Select(c => new
{
c.Id, c.AuthorName, c.Body, c.CreatedAt, c.IsAdminReply,
Replies = _db.Comments
.Where(r => r.ParentId == c.Id && r.IsApproved)
.OrderBy(r => r.CreatedAt)
.Select(r => new { r.Id, r.AuthorName, r.Body, r.CreatedAt, r.IsAdminReply })
.ToList()
}).ToListAsync();
Comments = raw.Select(c => new CommentVm
{
Id = c.Id, AuthorName = c.AuthorName, Body = c.Body,
CreatedAt = c.CreatedAt, IsAdminReply = c.IsAdminReply,
Replies = c.Replies.Select(r => new CommentVm
{
Id = r.Id, AuthorName = r.AuthorName, Body = r.Body,
CreatedAt = r.CreatedAt, IsAdminReply = r.IsAdminReply
}).ToList()
}).ToList();
}
private async Task SetViewDataAsync(BlogPost post)
{
var metaTitle = string.IsNullOrEmpty(post.MetaTitle) ? post.Title : post.MetaTitle;
var metaDesc = string.IsNullOrEmpty(post.MetaDescription) ? post.Excerpt : post.MetaDescription;
// Avoid duplicating the site name if MetaTitle already contains it
var suffix = " | دکتر سوسن آل‌طه";
ViewData["Title"] = metaTitle.Contains("دکتر سوسن") ? metaTitle : metaTitle + suffix;
ViewData["MetaDesc"] = metaDesc;
ViewData["Keywords"] = post.Keywords;
ViewData["OgImage"] = string.IsNullOrEmpty(post.OgImage) ? post.FeaturedImage : post.OgImage;
ViewData["ArticleType"] = post.ArticleType;
ViewData["Slug"] = post.Slug;
var heroSettings = await _db.SiteSettings
.Where(x => x.Section == "hero" && (x.Key == "name" || x.Key == "image" || x.Key == "tag"))
.ToListAsync();
ViewData["SiteName"] = heroSettings.FirstOrDefault(x => x.Key == "name")?.Value ?? "دکتر سوسن آل‌طه";
ViewData["HeroImage"] = heroSettings.FirstOrDefault(x => x.Key == "image")?.Value ?? "";
ViewData["HeroTag"] = heroSettings.FirstOrDefault(x => x.Key == "tag")?.Value ?? "پزشک عمومی و متخصص زیبایی پوست";
}
// View model for comments
public class CommentVm
{
public int Id { get; set; }
public string AuthorName { get; set; } = "";
public string Body { get; set; } = "";
public DateTime CreatedAt { get; set; }
public bool IsAdminReply { get; set; }
public List<CommentVm> Replies { get; set; } = new();
}
}