小报童精选导航站成果展示:

https://re.gloaming.top/

https://xiaobot.thend03.com/

0x0

导航站的详细实操,讲了专栏来源的选择,如何整理获取专栏列表详情,前端样式怎么做,如何部署并生成最终的导航站等。

由于前端能力实在有限,所以本篇文章的重点是在如何获取精选的专栏列表上。

导航站使用一个开源项目改造而来。

小报童简介

小报童是个去中心化的课程平台。

创作者可以在小报童体面而用心的创作,专注创作,不搞套路。

而且专栏的价格大部分都比较便宜。

小报童官方不做社群,不做广告变现,不做算法推荐。

因为小报童是去中心化的,所以官网没有所有的专栏列表,只能通过专栏作者自发性的传播,或者小报童官方的推荐才能找到专栏地址。

所以有人就做了小报童的导航站,用来推荐专栏。

而且小报童这个不需要买专栏,就可以获得分销链接。所以理论上小报童的所有专栏都可以免费获得分销链接。

image-20240721133751669

image-20240721133805735

另外大声的吐槽一下,这样的专栏都是谁在买。

别人通过你的链接买了之后,你就可以获得分销收入。

但是吧,分销站这个东西,可能确实得有点私域流量才行,在pyq发分销的二维码,或者公众号发文章,或者自己的社群里发一下进行推荐。

单纯靠seo的话,目前排名比较靠前的seo做的都挺好的,新站感觉得做持久化运营搜索引擎排名才能上的去。

单搜小报童,官网占了整整一屏,下面的就是一些分销站了。

image-20240721133054830

回到分销站,该如何从01到1做一个分销站出来。

我努力搜索,都没有搜索出一个可用的分销导航模板出来,有点点难。

数据源

做导航站首先要拿到数据,由于小报童的特殊性(去中心化),所以没法从官网得到所有专栏的数据。

可以选择从以下的几种渠道拿到数据

  • 小报童精选公众号,里面会有一些精选专栏的推荐。
  • pyq各种散落的专栏推荐
  • 别人整理好的专栏列表
  • 爬当前已有的小报童导航站的数据

小报童精选

小报童精选是小报童官方的公众号。

虽然是去中心化,但是一点入口都看不到,专栏的推广确实是会受阻。

所以有了小报童精选这个事。

精选文章每期会推荐一些有价值的专栏。所以精选是个获取专栏列表的路子。但是精选只能拿到一部分的专栏。

pyq

pyq加的一些人,会去推荐专栏,他们买了之后,会发他们自己的专属分销海报。

有时候我也会去通过他们的链接买一点,但是这种就是随缘,数量也不会太多。

别人整理好的专栏列表

我在网上冲浪的时候,发现了别人有一份整理好的专栏列表,但是这个可能会有时效性的问题,作者更新不及时,就拿不到增量的专栏列表。

下面这个就是一个民间的版本,上次更新还是在2年前。

https://github.com/qianguyihao/xiaobot-list?tab=readme-ov-file

爬当前已有的导航站的数据

这条路我感觉是最好的,多选几个导航站,把他们的专栏列表都爬一遍,然后去个重,就得到了最新最全的专栏列表了。

数据源的选择

上面介绍了几种数据来源,我这边选择的是爬小报童精选的推荐列表。其实更好的是去爬已有导航站的数据。

不过就精选专栏而言,官方的其实更好,毕竟官方已经做过一轮筛选了,能上推荐的肯定有其优点。

数据整理分类

选择好之后,我们就需要对推荐列表进行分类,订阅号里有目前所有的专栏推荐列表。截止到现在一共有26期。

对推荐列表进行整理就有2种方法。

  • 人工整理
  • 爬虫分类

26期的话,点开26条公众号文章,收集下所有的推荐专栏的二维码,倒也还好。

但是作为一个资深crud boy,能写代码就写代码。不能写代码,就让chatgpt帮我们写代码。

语言选择的是Java(资深java crud仔)。

选择的框架有如下几个

  • Jsoup, 用于解析html
  • Fastjson2,用于字符串转json处理
  • Hutool,http&二维码识别
  • selenium, 用于浏览器模拟,一些异步加载的页面需要用到这个
  • commons-io,文件操作

下面就是实操了

如何拿到专栏推荐列表

首先是进入小报童精选 公众号的聊天页面,左下角有个往期回顾,这里就是推荐专栏的合集页面。

image-20240721144801453

然后点击右上角,选择复制链接,或者选择使用默认浏览器打开,就可以拿到订阅列表的url

image-20240721144817021

下面就是页面分析了,通过滑动页面猜测这个列表是分页加载的。

可以在控制台看网络请求,也可以看的出来。

uri中有一个关键的key,appmsgalbum, 这个看着像是列表的请求。经过测验,这个确实是分页参数。

浏览器地址栏的链接访问只能访问到第一页的页面。下面就要靠分页查询拿数据了。

下面的分页请求返回的是json数据,第一页是html数据。

分页参数有2个比较关键的key,begin_msgid和begin_itemidx,这个一个是分页参数,一个是消息id,传入页数和id,查询下面的消息

image-20240721150209139

image-20240721152306897

这样的话就可以拿到所有的推荐文章的详细链接了。每一篇文章有一个专题推荐,包含数个专栏的推荐

经过页面元素审查以及debug,得到了如下获取订阅列表每篇文章地址的方法

常量类如下

public class Constants {
    public static final String XIAOBOT_WECHAT_SUBSCRIBE_LIST_URL = "https://mp.weixin.qq.com/mp/appmsgalbum?action=getalbum&__biz=Mzg4MTc0MDcyOA==&scene=1&album_id=2262723582487429130&count=100&uin=&key=&devicetype=iMac+MacBookPro18%2C1+OSX+OSX+14.5+build(23F79)&version=13080811&lang=zh_CN&nettype=WIFI&ascene=0&fontScale=100";

    public static final String LOCAL_CHROME_DRIVER_PATH = "/Users/since/Downloads/chromedriver-mac-arm64/chromedriver";

    public static final String LIST_NEXT_URL_FORMAT = "https://mp.weixin.qq.com/mp/appmsgalbum?action=getalbum&__biz=Mzg4MTc0MDcyOA==&album_id=2262723582487429130&count=100&begin_msgid=%s&begin_itemidx=%s&uin=&key=&pass_ticket=&wxtoken=&devicetype=iMac MacBookPro18,1 OSX OSX 14.5 build(23F79)&clientversion=13080811&__biz=Mzg4MTc0MDcyOA==&appmsg_token=&x5=0&f=json";

}

获取订阅列表方法

import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.thend03.xiaobot.spider.constants.Constants;
import com.thend03.xiaobot.spider.model.ArticleDetail;
import com.thend03.xiaobot.spider.model.WechatModel;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Attributes;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;

import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

/**
 * 小报童专栏精选订阅号列表解析服务
 *
 * @author since
 * @date 2024-07-17 08:12
 */
public class WechatSubscribeListService {

    public static List<ArticleDetail> parseArticleList() {

        String content = HttpUtil.downloadString(Constants.XIAOBOT_WECHAT_SUBSCRIBE_LIST_URL, StandardCharsets.UTF_8);

        Document parse = Jsoup.parse(content);
        Elements albumListItem = parse.getElementsByClass("album__list-item");
        System.out.println(1);
        List<WechatModel> list = new ArrayList<>();
        boolean hasNext = true;
        for (Element element : albumListItem) {
            Attributes attributes = element.attributes();
            System.out.println(2);
            if (attributes.isEmpty()) {
                continue;
            }
            String s = attributes.get("data-msgid");
            String s1 = attributes.get("data-itemidx");
            String s2 = attributes.get("data-link");
            String s3 = attributes.get("data-title");
            String s4 = attributes.get("data-pos_num");

            WechatModel wechatModel = new WechatModel();
            wechatModel.setDataMsgId(parseLong(s));
            wechatModel.setDataItemIndex(parseInt(s1));
            wechatModel.setDataLink(s2);
            wechatModel.setDataTitle(s3);
            wechatModel.setDataPosNum(parseInt(s4));
            list.add(wechatModel);
            if (wechatModel.getDataPosNum() == 1) {
                hasNext = false;
            }
        }
        while (hasNext) {
            List<WechatModel> list1 = nextParse(list.get(list.size() - 1));
            if (CollectionUtils.isNotEmpty(list1)) {
                list.addAll(list1);

            }
            if (list1.get(list1.size() - 1).getDataPosNum() == 1) {
                hasNext = false;
            }
        }

        List<String> urlList = new ArrayList<>();
        list.stream().filter(Objects::nonNull).forEach(s -> {
            List<String> urlList1 = WechatDetailService.getUrlList(s.getDataLink());
            if (CollectionUtils.isNotEmpty(urlList1)) {
                urlList.addAll(urlList1);
            }
        });

        List<String> collect = urlList.stream().filter(StringUtils::isNotBlank).filter(s -> s.contains("http") && s.contains("xiaobot.net/p")).map(s -> {
            String[] split = s.split("\\?");
            return split[0];
        }).distinct().collect(Collectors.toList());
        List<ArticleDetail> collect1 = collect.stream().filter(Objects::nonNull).map(url -> {
            String host = null;
            String uniqueId = null;
            try {
                host = WechatDetailService.getHost(url);
            } catch (MalformedURLException e) {


            }
            try {
                uniqueId = WechatDetailService.getArticleUniqueId(url);
            } catch (MalformedURLException e) {
                throw new RuntimeException(e);
            }
            return WechatDetailService.getArticleDetail(host, uniqueId);
        }).collect(Collectors.toList());
        return collect1;
    }

    public static long parseLong(String dataMsgId) {
        try {
            return Long.parseLong(dataMsgId);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return -1;
    }

    public static int parseInt(String itemIndex) {
        try {
            return Integer.parseInt(itemIndex);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return -1;
    }

    public static List<WechatModel> nextParse(WechatModel wechatModel) {
        List<WechatModel> list = new ArrayList<>();
        String format = String.format(Constants.LIST_NEXT_URL_FORMAT, wechatModel.getDataMsgId(), wechatModel.getDataItemIndex());
        String content = HttpUtil.downloadString(format, StandardCharsets.UTF_8);
        JSONObject jsonObject = JSON.parseObject(content);
        JSONObject getalbumResp = jsonObject.getJSONObject("getalbum_resp");
        JSONArray articleList = getalbumResp.getJSONArray("article_list");
        for (int i = 0; i < articleList.size(); i++) {
            Object o = articleList.get(i);
            if (o instanceof JSONObject) {
                JSONObject json = (JSONObject) o;
                long msgid = json.getLongValue("msgid");
                int itemidx = json.getIntValue("itemidx");
                int posNum = json.getIntValue("pos_num");
                String title = json.getString("title");
                String link = json.getString("url");
                WechatModel nextWechatModel = new WechatModel();
                nextWechatModel.setDataMsgId(msgid);
                nextWechatModel.setDataItemIndex(itemidx);
                nextWechatModel.setDataLink(link);
                nextWechatModel.setDataTitle(title);
                nextWechatModel.setDataPosNum(posNum);
                list.add(nextWechatModel);
            }
        }
        return list;
    }
}

如何拿到专栏地址

在上一步 我们拿到了26篇精选推荐的文章地址列表,这一步我们需要解析每一篇文章详情,拿到这一批推荐的专栏合集。

每一篇文章里都会推荐1~N篇文章,数量不等。

我拿几篇文章分析了一下,每篇文章的专栏没有特定的标签,但是由于是公众号文章,所以专栏都是以图片二维码的形式提供的。

即能拿到所有的专栏二维码图片即可。

序号26的这篇文章为例

推荐的专栏以文字介绍+二维码的图片格式提供出来。

这篇文章一共推荐了4个专栏,理论上我们拿到这4个专栏的图片二维码就可以了。

image-20240725084710014

打开浏览器的控制台,进行元素审查,在img标签里的data-src字段可以拿到图片地址

image-20240725090035806

现在的获取图片地址的方法过于生硬了,需要从script里解析变量重重硬解析才能拿到图片地址

List<String> result = new ArrayList<>();
        String content = HttpUtil.downloadString(wechatUrl, StandardCharsets.UTF_8);
        Document parse = Jsoup.parse(content);
        Elements scripts = parse.getElementsByTag("script");
        for (Element script : scripts) {
            List<Node> nodes = script.childNodes();
            for (Node node : nodes) {
                if (node instanceof DataNode) {
                    DataNode dataNode = (DataNode) node;
                    String wholeData = dataNode.getWholeData();
                    if (wholeData.contains("cdn_url")) {
                        String[] split = wholeData.split("var");
                        for (int i = 0; i < split.length; i++) {
                            String s = split[i];
                            if (s.contains("picturePageInfoList") && s.contains("http")) {
                                String[] split1 = s.split("picturePageInfoList");
                                String cdnUrl = split1[1];
                                String s1 = cdnUrl.replaceAll("\\[", "")
                                        .replaceAll("]", "")
                                        .replaceAll("\\{", "")
                                        .replaceAll("}", "")
                                        .replaceAll("'", "")
                                        .replaceAll("\"", "");
                                String[] split2 = s1.split(",");
                                for (int j = 0; j < split2.length; j++) {
                                    if (split2[j].contains("cdn_url")) {
                                        String[] split3 = split2[j].split("cdn_url:");
                                        result.add(split3[1]);
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }

其实换个方法,一句话就可以提取所有图片地址了, 使用select方法,标签+属性即可。

image-20240725090232398

拿到图片地址之后,使用hutool进行二维码识别,报错了就忽略异常,有二维码的能成功解析,能正常解析的就是二维码地址了。

    public static String hutoolOcr(String imageUrl) throws IOException, URISyntaxException {
        String finalUrl = "";
        File file = HttpUtil.downloadFileFromUrl(imageUrl, "static/" + COUNT.getAndIncrement() + ".png");
        try {
            finalUrl = QrCodeUtil.decode(file);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return finalUrl;
    }

这样就拿到每期精选的所有的推荐专栏列表了

如何获取专栏的详情

拿到专栏地址之后,就是去处理专栏了,我们上一步拿到的只是专栏的地址,专栏标题、作者、专栏头像、专栏介绍、专栏的已购数、文章数,都需要从专栏里获取。

打开控制台,这几个接口把所有的数据都返回了,且返回的是json数据,所以看着很美好。

但是这几个接口都是要签名的,整个页面是异步加载的,无法使用jsoup进行解析。而且也不知道签名规则,所以这条路行不通。

image-20240725091033955

后来经过一番搜索,可以使用selenium模拟浏览器,等待页面异步加载完成,这样就可以使用jsoup解析页面了。

使用selenium需要在下载chrome驱动到本地,chrome驱动需要和自己的chrome浏览器版本相等或相近,版本不要差太多。

image-20240725091920785

114之前的版本在https://chromedriver.storage.googleapis.com/index.html下载

127之后的版本在https://googlechromelabs.github.io/chrome-for-testing/下载

115-126的暂时没找到,随着时间推移,这个最新的版本也可能会发生变化

记得是下载chromedriver

image-20240725105043283

拿到driver之后,就可以使用代码模拟浏览器的页面异步加载过程,得到完整的页面了,然后就可以使用jsoup解析页面元素

public static String getArticleFullPage(String url) {
        // 设置 ChromeDriver 路径
        System.setProperty("webdriver.chrome.driver", Constants.LOCAL_CHROME_DRIVER_PATH);

        // 设置 Chrome 选项
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--headless"); // 无头模式

        // 启动 Chrome 浏览器
        WebDriver driver = new ChromeDriver(options);

        // 访问目标网页
        driver.get(url);

        String pageContent = null;

        // 等待网页加载完成(根据需要调整等待时间)
        try {
            Thread.sleep(2000);
            // 获取网页内容
            pageContent = driver.getPageSource();

            // 打印网页内容
            System.out.println(pageContent);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            driver.quit();
        }
        return pageContent;
    }

整体解析文章详情的方法如下

public static ArticleDetail getArticleDetail(String host, String uniqueId) {
        String url = "https://" + host + "/p/" + uniqueId;

        String articleFullPage = getArticleFullPage(url);
        Document document = Jsoup.parse(articleFullPage);
        Elements title = document.getElementsByTag("title");
        Elements intro = document.getElementsByClass("intro");
        Elements description = document.getElementsByClass("description");
        Elements posts = document.getElementsByClass("posts");
        Elements elementsByClass = document.getElementsByClass("paper");
        Elements num = document.getElementsByClass("num");
        Elements avatar = document.getElementsByClass("avatar");

        String articleTitle = getArticleTitle(title);
        String articleDescription = getArticleDescription(description);
        String articleIntro = getArticleIntro(intro);
        Map<String, Integer> articleNum = getArticleNum(num);
        String articleAvatar = getArticleAvatar(avatar);

        ArticleDetail articleDetail = new ArticleDetail();
        articleDetail.setAvatarBase64(articleAvatar);
//        articleDetail.setGmtCreate(createdAt);
        articleDetail.setIntroduction(articleIntro);
//        articleDetail.setFreePostCount(freePostCount);
        articleDetail.setPostCount(articleNum.get("post"));
        articleDetail.setTitle(articleTitle);
        articleDetail.setSubscriberCount(articleNum.get("reader"));
        articleDetail.setUrl(url);

        System.out.println(1);
        return articleDetail;
    }

    public static String getHost(String articleUrl) throws MalformedURLException {
        java.net.URL url = new URL(articleUrl);
        return url.getHost();
    }

    public static String getArticleUniqueId(String articleUrl) throws MalformedURLException {
        java.net.URL url = new URL(articleUrl);
        String path = url.getPath();

        return path.replaceAll("/p/", "");
    }


    public static String getArticleFullPage(String url) {
        // 设置 ChromeDriver 路径
        System.setProperty("webdriver.chrome.driver", Constants.LOCAL_CHROME_DRIVER_PATH);

        // 设置 Chrome 选项
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--headless"); // 无头模式

        // 启动 Chrome 浏览器
        WebDriver driver = new ChromeDriver(options);

        // 访问目标网页
        driver.get(url);

        String pageContent = null;

        // 等待网页加载完成(根据需要调整等待时间)
        try {
            Thread.sleep(2000);
            // 获取网页内容
            pageContent = driver.getPageSource();

            // 打印网页内容
            System.out.println(pageContent);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            driver.quit();
        }
        return pageContent;
    }

    /**
     * 专栏的标题
     * @param title title element
     * @return title
     */
    public static String getArticleTitle(Elements title) {
        if (Objects.isNull(title)) {
            return null;
        }
        if (title.isEmpty()) {
            return null;
        }
        List<String> result = new ArrayList<>();
        for (Element element : title) {
            List<Node> nodes = element.childNodes();
            if (CollectionUtils.isEmpty(nodes)) {
                continue;
            }
            List<String> collect = nodes.stream().filter(Objects::nonNull).map(node -> {
                if (node instanceof TextNode) {
                    return ((TextNode) node).text();
                }
                return null;
            }).collect(Collectors.toList());
            result.addAll(collect);
        }
        return StringUtils.join(result, ",");
    }

    public static String getArticleDescription(Elements description) {
        if (Objects.isNull(description)) {
            return null;
        }
        if (description.isEmpty()) {
            return null;
        }
        List<String> result = new ArrayList<>();
        Iterator<Element> iterator = description.iterator();
        while (iterator.hasNext()) {
            Element next = iterator.next();
            List<String> list = parseNode(next);
            result.add(StringUtils.join(list, ""));
        }
        String join = StringUtils.join(result, "");
        return join.replaceAll("<p></p>", "\r\n\n");
    }

    public static String getArticleIntro(Elements intro) {
        if (Objects.isNull(intro)) {
            return null;
        }
        if (intro.isEmpty()) {
            return null;
        }
        List<String> result = new ArrayList<>();
        for (Element next : intro) {
            List<String> list = parseNode(next);
            result.add(StringUtils.join(list, ""));
        }
        String join = StringUtils.join(result, "");
        return join.replaceAll("<br>", "\r\n");
    }

    public static Map<String, Integer> getArticleNum(Elements num) {
        if (Objects.isNull(num)) {
            return Collections.emptyMap();
        }
        if (num.isEmpty()) {
            return Collections.emptyMap();
        }
        Element reader = num.get(0);
        Element post = num.get(1);

        TextNode node = (TextNode) reader.childNodes().get(0);
        String text = node.text();


        TextNode postNode = (TextNode) post.childNodes().get(0);
        String text1 = postNode.text();
        Map<String, Integer> map = new HashMap<>(4);
        map.put("reader", Integer.parseInt(text));
        map.put("post", Integer.parseInt(text1));
        return map;
    }

    public static String getArticleAvatar(Elements avatar) {
        if (Objects.isNull(avatar)) {
            return null;
        }
        if (avatar.isEmpty()) {
            return null;
        }
        Element element = avatar.get(0);
        String avatarUrl = element.attributes().get("src");
        byte[] bytes = HttpUtil.downloadBytes(avatarUrl);
        return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bytes);
    }

    /**
     * 解析node详情,需要递归,text node/element
     * @param element element
     * @return list
     */
    public static List<String> parseNode(Element element) {
        if (Objects.isNull(element)) {
            return null;
        }
        List<Node> nodes = element.childNodes();
        if (CollectionUtils.isEmpty(nodes)) {
            return null;
        }
        List<String> result = new ArrayList<>();
        for (Node node : nodes) {
            List<Node> textNodeList = node.childNodes();
            if (CollectionUtils.isEmpty(textNodeList)) {
                if (node instanceof TextNode) {
                    result.add(((TextNode) node).text());
                } else {
                    result.add(node.toString());
                }
                continue;
            }
            List<String> subList = new ArrayList<>();
            for (Node text : textNodeList) {
                if (Objects.isNull(text)) {
                    continue;
                }
                if (text instanceof TextNode) {
                    subList.add(((TextNode) text).text());
                } else if (text instanceof Element) {
                    List<String> list = parseNode((Element) text);
                    if (CollectionUtils.isNotEmpty(list)) {
                        subList.add(StringUtils.join(list, ""));
                    }
                } else {
                    subList.add(text.toString());
                }
            }
            result.add(StringUtils.join(subList, ""));
        }
        return result;
    }

前端页面展示

由于我是前端废,找了个开源的导航项目当模板,找我的前端朋友改了一下。

开源项目地址如下: https://github.com/jic999/moon-web-start

经过修改的前端项目地址如下: https://github.com/gloaming123/moon-web-start

后端代码项目地址如下: https://github.com/thend03/xiaobot-spider

这个导航项目自动github pages部署,可以直接在仓库的actions点击运行,可以在job的运行日志里得到页面访问地址。

为了优化加载速度,我这边生成的详情,将专栏的头像转成了base64,前端再将base64转成本地图片,优化了加载速度。

总结

经过一系列的勾兑,就拿到了专栏的详情。

总得来说,后端就是使用了http+jsoup解析了页面,没有什么难的。对api熟悉的话速度会更快一点。

没有涉及大规模的爬虫,算是一次简单的尝试吧。

对我来说难的果然还是前端呀,需要我的前端合伙人持续发力,实现前端页面了。

前端地址,那个专属分销码需要自己替换,由于我不懂前端,没法给出详细的使用说明,我自己的分销码是我的前端朋友帮我专门写到一个分支里了。

另外爬精选列表,只能拿到部分上推荐的专栏,其他不在推荐里的,要么去其他的导航站爬,要么靠其他散落的地方传播了。

小报童的专栏价格都不大贵,适合当知识付费的小册来购买,有的可以来点启发我觉得就能值回票价了。