付费墙别把试读章节一起藏起来:isAccessibleForFree 与 hasPart 的课程页改造

2026-09-18 08:34:27 14 次浏览
ASP.NET CoreSchema.org付费墙SEOGEO

适用读者:做职业技能课程站、知识付费平台的 .NET 后端与站内搜索负责人,以及负责课程页被 AI 引擎收录的 SEO/GEO 同学。

从一组转化数据说起

今年 3 月我们接手一个职业技能课程站的改造,站内最核心的一门课是「ASP.NET Core 分布式实战」,课程页日均访问约 2000 次,其中试听章节入口占了 60% 以上的点击。但后台数据里有一个刺眼的空白:来自 AI 渠道(各生成式引擎的引用跳转)的访问是 0。同一个时期,这家站的百度搜索自然流量每天还有三四百次。

问题出在课程页的可见性控制上。试听页的模板把前 3 章正文也放进了统一的登录/付费墙组件里——未登录用户看到的整页只有课程简介和购买按钮,正文区域被替换成「登录后查看」。AI 爬虫不带登录态,抓回来的正文就是空的。生成式引擎拿不到可引用的正文片段,自然不会在回答里提到这门课。

试听章节本来就是免费内容,把它藏进付费墙,等于亲手把 AI 渠道的入口关掉了。

问题定位:整页墙 vs 分区墙

我们先用爬虫模拟器抓了一遍课程详情页 course/aspnet-core-distributed,返回的 HTML 里 article 节点内容为空,只有登录提示文案。对比改造思路,其实业内有两类做法:

  • 整页墙(hard paywall):未登录/未付费时整页正文都不输出,适合纯付费专栏;
  • 分区墙(metered / 分段付费墙):免费部分照常输出 HTML,付费部分用 class="paywall" 标记并做前端遮罩或服务端裁剪,适合有试读、试听场景的内容页。

Google 的订阅内容结构化数据文档明确支持后一种:通过 isAccessibleForFree 声明哪部分是付费的,配合 CSS 类名让爬虫知道哪些片段被墙住。这个约定同样被主流 AI 爬虫沿用——它们虽然不一定做完整 schema 校验,但正文抽取器会尊重「带 paywall 标记的区域优先级降低、其余区域正常抽取」的信号。

原理剖析:AI 爬虫的正文抽取与引用单元

要理解为什么整页墙会让引用归零,得看生成式引擎怎么组织素材。AI 爬虫抓到 HTML 后,会先做正文抽取(boilerplate 剥离),再按语义边界把正文切成一段段可独立引用的片段,这个片段就是引用单元(Citation Unit)。粒度通常是段落或小节,每段带上页面 URL 和标题,供生成阶段检索与标注来源。

这套机制下,整页墙会出三个连锁问题:

  1. 正文抽取器看到的 article 内容只剩登录提示,抽取结果近似空集;
  2. 没有正文就没有切片输入,引用单元数量为 0,该页面在向量索引和检索候选里直接缺席;
  3. 即便爬虫反复重抓,拿到的还是同一个空壳,页面会被打上低价值标签,抓取频率下降。

isAccessibleForFree: false 配合 hasPart 的作用,是让爬虫在抓取前就知道这个页面是「部分免费」而不是「无内容」。策略上会从「跳过或降权」转为「抽取免费分区」。当付费区与免费区混排时,解析器一般这样处理:带 paywall 类标记的区域在正文抽取阶段被排除或降权,免费区域正常切片并参与引用;如果页面完全没分区标记,而 schema 又声明了部分付费,保守的解析器会整页降权——这正是只上 schema、不改 HTML 结构时效果差的原因。

结论一句话:schema 声明和 HTML 分区标记必须同时落地,缺一个,解析器都可能按整页墙处理。

下面这个流程图描述了内容可见性的决策路径:

flowchart TD
    A[课程内容节点] --> B{内容类型?}
    B -->|试听/试读章节| C{是否有 HTML 分区标记?}
    B -->|付费章节| D[输出 class=paywall 分区]
    B -->|课程简介/目录| E[全量输出]
    C -->|有 paywall 标记 + schema 声明| F[爬虫抽取免费正文<br>生成引用单元]
    C -->|无标记 全在墙后| G[抽取正文为空<br>引用单元为 0]
    D --> H[不参与正文切片<br>schema 标注 isAccessibleForFree=false]
    E --> F

改造方案

改造定在 4 月的第二周,涉及课程详情页、章节试听页两个模板,后端是 .NET 8 的 ASP.NET Core,页面用 Razor Pages 渲染。方案分三步:

  1. 模板分区:章节正文循环里按 IsFree 字段决定输出方式,免费章节全量输出,付费章节输出占位骨架并加 class="paywall",付费骨架内保留章节标题和前 80 字摘要(摘要本身不设墙);
  2. JSON-LD 声明:在课程页注入 Course 结构化数据,hasPart 逐章声明,配合 isAccessibleForFree
  3. 中间件兜底:统一处理登录态裁剪,避免模板改动遗漏。

第一步:JSON-LD 完整示例

依赖与环境:无第三方依赖,纯原生 JSON-LD;页面框架 ASP.NET Core 8.0,schema 词表使用 schema.org 2024 年以后的稳定版本。以下是一个含 3 个章节的完整声明:

{
  "@context": "https://schema.org",
  "@type": "Course",
  "name": "ASP.NET Core 分布式实战",
  "description": "从零搭建一套可水平扩展的分布式课程商城后端,含 12 个实战章节。",
  "provider": {
    "@type": "Organization",
    "name": "职业技能课程站"
  },
  "isAccessibleForFree": false,
  "hasPart": [
    {
      "@type": "LearningResource",
      "name": "第 1 章 分布式架构总览",
      "isAccessibleForFree": true,
      "url": "https://example-course.cn/course/aspnet-core/chapter-1"
    },
    {
      "@type": "LearningResource",
      "name": "第 2 章 消息队列削峰实战",
      "isAccessibleForFree": true,
      "url": "https://example-course.cn/course/aspnet-core/chapter-2"
    },
    {
      "@type": "LearningResource",
      "name": "第 3 章 分布式锁的三种实现",
      "isAccessibleForFree": false,
      "url": "https://example-course.cn/course/aspnet-core/chapter-3",
      "description": "前 80 字摘要保留可见,正文需购买后访问。"
    }
  ]
}

注意第 3 章:付费章节也要保留 url 和一句可见的摘要描述,这能帮解析器把该章节识别为「存在但受限」,而不是「不存在」。

第二步:按登录态输出分区 HTML

依赖与环境:.NET 8.0,ASP.NET Core Razor Pages,无额外 NuGet 包;登录态走站内已有 Cookie 认证。下面是课程页 Page Model 的核心片段,负责把章节列表渲染成「免费全量 + 付费分区」的 HTML:

// 章节视图模型,模板据此决定渲染方式
// Title:章节标题,任何可见性下都输出
// IsFree:是否为免费试听章节,来自课程章节表
// FullBody:完整正文,仅免费章节或已购用户才有值
// Summary:付费章节的 80 字摘要,永远对爬虫可见
public record ChapterVm(string Title, bool IsFree, string FullBody, string Summary, string Url);

// 课程详情页 Page Model
public class DetailModel : PageModel
{
    // 章节仓储,负责读取课程章节聚合
    private readonly IChapterRepository _chapters;

    // 构造函数注入仓储,站内使用 Scrutor 做的程序集扫描注册
    public DetailModel(IChapterRepository chapters) => _chapters = chapters;

    // 模板绑定的章节列表,每个元素对应一个 section 输出
    public List<ChapterVm> Sections { get; private set; } = [];

    // 是否已登录,供模板判断是否追加登录引导
    public bool IsLoggedIn { get; private set; }

    // 页面入口:courseSlug 来自路由,如 aspnet-core-distributed
    // 改造前这个方法只查一次列表,不区分可见性
    public async Task OnGetAsync(string courseSlug)
    {
        // 读取当前登录态,站内使用 Cookie 认证
        IsLoggedIn = User.Identity?.IsAuthenticated ?? false;

        // 一次性取出全部章节,章节量级在 50 个以内无需分页
        // 查询走只读从库,页面响应 P95 在 180ms 左右
        var chapters = await _chapters.GetByCourseAsync(courseSlug);

        // 逐章决定渲染策略,这是整个改造的核心分支
        // 每一章都必然落入下面三个分支之一
        foreach (var ch in chapters)
        {
            // 免费章节:全量正文直接输出,供 AI 爬虫抽取
            // 对应 JSON-LD 里 hasPart 的 isAccessibleForFree=true
            // 这是引用单元的主要来源,占全章的 30 个
            if (ch.IsFree)
            {
                Sections.Add(new(ch.Title, true, ch.Body, "", ch.PublicUrl));
                continue;
            }

            // 未登录用户访问付费章节:只输出标题与摘要
            // 摘要必须留在 paywall 分区之外,保证引用质量
            if (!IsLoggedIn)
            {
                Sections.Add(new(ch.Title, false, "", ch.Summary80, ch.PublicUrl));
                continue;
            }

            // 已登录但未购课:同样只给摘要,购买后走完整正文
            // 购买记录查询走缓存,命中缓存时不到 2ms
            var purchased = await _chapters.HasPurchaseAsync(User, ch.CourseId);

            // 已购用户输出全文,未购用户回落到摘要分支
            Sections.Add(new(ch.Title, false,
                purchased ? ch.Body : "", ch.Summary80, ch.PublicUrl));
        }
    }
}
<!-- 对应的 Razor 渲染片段,Sections 循环输出 -->
@foreach (var s in Model.Sections)
{
    @* 免费章节:正文全量输出,不带任何遮挡标记 *@
    if (s.IsFree)
    {
        <section class="chapter chapter--free">
            <h2>@s.Title</h2>
            <div class="chapter-body">@Html.Raw(s.FullBody)</div>
        </section>
    }
    else
    {
        @* 付费章节:paywall 类名是给爬虫解析器的分区信号,必须保留 *@
        <section class="chapter chapter--locked">
            <h2>@s.Title</h2>
            <div class="chapter-body paywall">
                <p>@s.Summary</p>
                <a href="/purchase">购买后解锁本章全部内容</a>
            </div>
        </section>
    }
}

改造时模板里原有的整页墙组件 <div class="login-gate">整页遮挡</div> 直接移除,改用上述分区输出。这一步最容易漏的是 CSS:paywall 区域在视觉上仍可加渐变遮罩和模糊,但不要用 display:none——部分解析器会把不可见元素连同标记一起丢弃,摘要也跟着丢了,用视觉遮罩更稳妥。

第三步:中间件兜底裁剪(可选)

如果全站模板多、逐页改造周期长,可以用一个中间件对响应 HTML 做统一处理:识别 data-access="paid" 的区块,未登录时替换为摘要。依赖同样只有 .NET 8 BCL,无需引入 AngleSharp 之类的 HTML 解析包,示例里用字符串定位的方式够用。我们客户的站模板只有两个,直接改模板成本更低就没走这条路,但这段代码在另一个有 40 多个模板的知识付费项目里跑着:

// 付费分区兜底中间件:统一处理模板遗漏的付费区块
public class PaywallFallbackMiddleware
{
    // 区块起始标记,模板侧约定 data-access="paid"
    private const string Marker = "data-access=\"paid\"";

    // 后续管道委托,处理后必须调用
    private readonly RequestDelegate _next;

    // 注入后续管道,注册时放在 UseAuthentication 之后
    public PaywallFallbackMiddleware(RequestDelegate next) => _next = next;

    // 主流程:只拦截 HTML 响应,其余直接放行
    public async Task InvokeAsync(HttpContext context)
    {
        // 先放行请求,拿到下游渲染好的响应体
        await _next(context);

        // 未登录才需要裁剪,登录用户原样返回
        // AI 爬虫不带 Cookie,走的就是这个分支
        var authed = context.User.Identity?.IsAuthenticated ?? false;
        if (authed) return;

        // 只处理 HTML 响应,静态资源和接口不碰
        // 用 StartsWith 而不是等值比较,兼容 charset 参数
        var ct = context.Response.ContentType;
        if (ct == null || !ct.StartsWith("text/html")) return;

        // 响应体替换需要开关 Buffering,Program.cs 里要配套启用
        // 否则 Position 回读会抛出 Stream 不可 Seek 的异常
        context.Response.Body.Position = 0;
        using var reader = new StreamReader(context.Response.Body);
        var html = await reader.ReadToEndAsync();

        // 定位付费区块,把区块内部替换为一行摘要提示
        // Ordinal 比较避免区域性大小写规则干扰
        var start = html.IndexOf(Marker, StringComparison.Ordinal);
        if (start < 0) return;

        // 找到区块结束标签,中间内容全部换成引导文案
        // 这里假设模板统一用 section 包裹,约定要写进前端规范
        var end = html.IndexOf("</section>", start, StringComparison.Ordinal);
        if (end < 0) return;

        // 拼接替换后的 HTML,保留标题与购买引导
        // Marker 长度 +1 是为了吃掉后面的右尖括号
        var head = html[..(start + Marker.Length + 1)];
        var patched = head + "><p>本节为付费内容,<a href=\"/purchase\">购买后查看</a></p></section>";

        // 回写响应体并截断多余长度
        // SetLength 必须在 Position 归零之后调用
        context.Response.Body.Position = 0;
        context.Response.Body.SetLength(0);
        await context.Response.WriteAsync(patched);
    }
}

// Program.cs 注册:放在认证中间件之后、静态文件之前
// 顺序错了会导致登录态读不到,全站内容都被误裁剪
app.UseMiddleware<PaywallFallbackMiddleware>();

两种路线怎么选:模板少于 10 个就直接改模板,改动可控、可测试;模板多或者第三方模板无法改动,再上中间件兜底。中间件方案的代价是响应体缓冲带来的内存开销,大流量站要做压测。

关键字段对照表

字段 / 结构 所在层级 取值与写法 作用
isAccessibleForFree Course 根节点 false(整课需购买) 告诉引擎整课不是免费资源,但不等于无内容
isAccessibleForFree hasPart 子节点 逐章 true / false 精确到章节声明可见性,试听章节标记为免费
hasPart Course 根节点 LearningResource 数组 枚举章节,是分区声明与章节级引用的挂载点
LearningResource hasPart 元素 name / url / 摘要 让每章成为可独立检索与引用的最小单元
class="paywall" HTML 分区 付费正文容器 爬虫解析器的分区墙信号,配合 schema 生效

改造前后爬虫视角对比

flowchart LR
    subgraph 改造前
        A1[AI 爬虫请求课程页] --> A2[登录墙拦截] --> A3[正文为空] --> A4[引用单元 0 条<br>AI 引用 0 次]
    end
    subgraph 改造后
        B1[AI 爬虫请求课程页] --> B2[读取分区 HTML] --> B3[免费章节正常抽取<br>付费区按 paywall 跳过] --> B4[引用单元 40+ 条<br>AI 渠道开始导流]
    end

改造效果数据

上线后我们跟踪了 8 周(4 月中旬到 6 月上旬),以「ASP.NET Core 分布式实战」这门课的详情页与 3 个试听章节页为观测对象,周均口径:

指标 改造前(3 月周均) 改造后第 8 周 变化
AI 渠道引用次数 0 37 从 0 到有
试听页到购课页转化率 1.8% 3.1% +1.3 个百分点
爬虫抓取正文覆盖率(章节级) 0%(3/56 章为空) 46%(26/56 章有正文切片) 覆盖免费章节为主

试听转化率的提升逻辑不难理解:AI 引擎引用带来的用户带着明确问题来的,落地到试听章节后阅读完成度更高。第 6 周时其中一个试听章节单日引用跳转到过 21 次,当周该章节的购课转化是全站均值的 2.4 倍。需要说明的是,第 8 周的 46% 覆盖率对应的就是 30 个免费章节加上课程简介、目录页的切片,付费章节按设计仍不参与正文切片。

误区澄清与收尾

改造过程中踩过的两个坑值得提前说:

  • 只加 schema 不改 HTML。有一版我们只注入了 JSON-LD,正文仍在整页墙后面,两周下来引用依然为零。解析器不会无条件相信声明,它需要 HTML 里的免费正文与分区标记互相印证;
  • 把试听摘要也藏进墙里。有一版模板连章节标题和 80 字摘要都进了付费分区,结果引用单元切出来全是空泛的短句,引用质量差,后来把摘要挪出 paywall 区域才恢复。

趋势上,随着生成式引擎对内容许可与访问控制的建模越来越细,isAccessibleForFree 这类字段会从「SEO 附加项」变成课程类站点的基础设施——它本质上是把商业策略(哪些免费、哪些收费)翻译成机器可读的契约。与其等引擎猜,不如自己声明。

技术上收个尾:这套改造的完整清单是——模板分区输出免费正文、付费区加 class="paywall" 并保留摘要、JSON-LD 的 Course 根节点与 hasPart 逐章声明可见性、避免 display:none。四个动作按顺序做完,AI 爬虫的引用单元就能稳定产出。如果你的课程站也在为 AI 渠道零引用发愁,欢迎在评论区贴出你的页面结构,一起看看墙砌在哪里合适。

参考与延伸

  • schema.org isAccessibleForFree 属性定义:https://schema.org/isAccessibleForFree
  • Google Search Central 订阅内容(paywall)结构化数据文档:https://developers.google.com/search/docs/appearance/structured-data/paywalled-content
  • schema.org Course 类型定义:https://schema.org/Course

关键词:isAccessibleForFree, hasPart, 付费墙, Course Schema, 引用单元, 生成式引擎优化, AI优化AIO

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。