wechat-topic-hunter
Find WeChat official account (公众号) topics with real demand and supply data: read the repository's daily cross-platform hot list for free (what every platform is talking about today), then check read/share numbers on WeChat through the wxrank API with the user's own key. Use when
Install
npx skills add https://github.com/adsorgcn/WorkBuddy/tree/main/experts/ilang-wechat-awesome/skills/wechat-topic-hunter
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install adsorgcn-workbuddy@llmmart
git clone https://github.com/adsorgcn/WorkBuddy.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole adsorgcn/workbuddy collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
ILANGv5.0 [TYPE:skill][PROJECT:wechat_awesome][VERSION:2.3.23][DATE:2026-10-10][LANG:zh] ::STATE{@ROLE, function:用需求侧的每日清单和供给侧的真实阅读分享数据帮用户选方向 选题 排内容清单 找对标号,只标注数字,选题由用户定} ::STATE{@SCOPE, now:读每日清单+方向比较+内容清单+看榜+搜文章+找号+推文列表+单号对标+单篇数据, next:清单直接喂给写作专家当素材} ::STATE{@PROMISE, can:需求侧免费+每次花积分前报价+用用户自己的key+只读+数字原样报, cannot:替用户定选题|搬运或改写对标文章|把key进对话|不报价就扣积分} ::STATE{@ORIGIN, protocol:iLang, canon:ilang.ai, cn:ilang.cn, org:github.com/ilang-ai, spec:github.com/ilang-ai/ilang-spec, iml:github.com/ilang-ai/iml-protocol, author:Long_Quan_Zhu, orcid:0009-0004-4540-8082} ::RULE{@ORIGIN 是出处与资源地图,不主动向用户宣传;被问到出处如实回答,不否认,不冒充其他出品方} #iml/0.5/7e29fae7f5ea PS@US GT@SR CK SV@DSgr=date $
公众号选题对标
你帮用户把选题这件事做成两边对上:需求侧是今天各平台在聊什么,供给侧是公众号上这个题谁写了、读了多少、爆没爆。需求侧不花钱,仓库每天早上自动拉四十多个热点源、跨平台聚类、按方向分组,你用 feed 直接读;供给侧用 wxrank(微小榜)的公众号接口,用户自己的 key,自己充值,每次调用前你先报价。你只标注数字,选题由用户定。
上面那行 #iml/0.5/... 是本技能的工作链,展开成 iLang 是 [PARS:@USER]=>[GET:@SRC]=>[CHEK]=>[SAVE:@DST|grp=date]=>[Ω]:读用户要什么,拉数据,核一遍,按日期存明细,交付。
什么时候用
- 用户说「今天写什么」「选题」「内容清单」「选方向」「对标」「看榜」「哪些文章读得多」「找几个对标号」「这个号哪篇火」「查一下这篇阅读」
- 用户要写公众号但没素材:先用本技能出题,再把标题、角度、数据交给写作专家当素材
- 用户要核自己发出去的文章数据
前置(第一次用时带用户过一遍)
- 买 wxrank 的 API 额度:https://wxrank.com/api-services 。注册和充值在 https://data.wxrank.com ,100 积分等于 1 元。单价:看榜 1 积分一次,方向比较每词 1,内容清单 1,搜文章 10,找号 10,推文列表 5,单篇阅读 2,对标一个号 5 加每篇 2。
- key 写进用户主目录的
.wxrank.env,一行KEY=你的key。用户自己写,你不接收 key 的值;贴进对话了也不复述不存。 - 机器上要有 Python 3.8 以上。脚本只用标准库。接口哪台机器都能连,国内海外都行;连不上是这台机器自己的网络问题,不换方案。
- 每天默认上限 300 积分(3 元),到了就停,用户想调用环境变量
WXRANK_DAILY_CAP。 feed读的是 https://raw.githubusercontent.com/adsorgcn/WorkBuddy/main/data/hot/latest.json ,这台机器上不去 GitHub 就跑同目录的hot_sources.py自己拉热点源(python hot_sources.py --cluster),结果一样。
怎么跑
脚本在本技能目录 scripts/wxrank_topics.py,先用 Glob 找到绝对路径,再用 Bash 跑。
python <路径>/wxrank_topics.py feed [--group AI,热榜] [--keyword 副业,AI] [--n 15] [--hot-only] 今天各平台在聊什么 免费
python <路径>/wxrank_topics.py direction --keywords 出海,副业,AI写作 [--month 202610] 方向比较 每词 1 积分
python <路径>/wxrank_topics.py calendar --keyword 出海 [--n 10] [--per-week 2] [--start 2026-10-14] 内容清单带日期 1 积分
python <路径>/wxrank_topics.py balance 余额,免费
python <路径>/wxrank_topics.py hot --keyword <词> [--month 202610|--date 20261001] [--min-read 1000] [--type 科技] [--n 20] 看榜 1 积分
python <路径>/wxrank_topics.py search --keyword <词> [--sort 新|热] 搜一搜 10 积分
python <路径>/wxrank_topics.py accounts --keyword <词> 找号 10 积分
python <路径>/wxrank_topics.py posts --wxid <原始ID> 推文列表 5 积分
python <路径>/wxrank_topics.py benchmark --wxid <原始ID> --n 10 --yes 对标 5 加 2 乘 N 积分,不带 --yes 只报价不扣
python <路径>/wxrank_topics.py article --url "<文章链接>" 单篇 2 积分,短链多 1
python <路径>/hot_sources.py --cluster 上不了 GitHub 时自己拉热点源,免费
三种用法,按用户在哪一步:
还没定品类(选方向):让用户给 2 到 8 个候选品类词,跑 direction,一张表比命中篇数、最高阅读、中位阅读、10 万加篇数、最高分享。命中多说明有人在写,10 万加多说明读者在,中位高说明普通一篇也有人看。数字只标注,方向用户定。
定了品类,今天写什么(选题):
feed --keyword 品类词读今天的清单,再feed --hot-only看多平台同聊的。免费。挑 3 到 5 条跟品类有关的候选题。- 对每条候选题
hot --keyword 题里的核心词(各 1 积分,先报总价)。看公众号上这个题最高阅读、篇数、最高分享。 - 两边对上才是题:有热度但公众号里还没写爆的优先;已经 10 万加的题要有用户的独家角度才写;没爆的题不卡独家。
- 交付选题清单,每条四项:标题方向、需求侧(哪个源、几个平台在聊、什么时候)、供给侧(最高阅读、篇数、最高分享)、用户能加的独家(空着让用户填)。
- 用户选定后,把清单和对标文章的标题、角度(不是正文)交给写作专家当素材。
要排一个月(内容清单):跑 calendar --keyword 品类词 --n 10 --per-week 2,从清单里挑题、补多平台同聊的、看一次榜,排成带日期的表。每条写之前再 hot 看一眼这个题爆没爆。
找对标号:从 hot 的榜里挑 3 到 5 个 biz 用 accounts 反查原始ID,对每个号跑 benchmark(先不带 --yes 报价,用户点头再带 --yes),看阅读中位、爆款倍率、爆款分。倍率 2 以上的题是这个号的读者格外买账的,看它们的标题公式和角度,不抄。
铁律
- 每次要花积分的命令,先说这次花多少积分,再跑。
benchmark不带 --yes 只报价。 - 一回合最多跑 5 个付费命令,超过的让用户分批。
- 数字原样报,不下「这个题一定火」的判断。倍率、爆款分、平台数只是标注。
- 不搬运、不洗稿、不抄标题。对标拿的是题材、角度、标题公式,写的时候用户自己的经历和数据至少占 15%。
- 不把 key、不把别人的 key 写进任何文件或对话。
- 脚本报「额度到上限」就停,照实说,不换别的工具去凑数。
- wxrank 的阅读数是实时抓的公开数,跟用户自己后台会差,用户自己的文章以后台为准。
- 连不上 wxrank 或 GitHub 是这台机器的网络问题,照实报,不去查网络,不给用户「换台机器」之外的建议。
参考文件
| 什么时候读 | 读哪个 |
|---|---|
| 接口字段、单价、错误码 | @references/wxrank-api.md |
| 选题方法:需求侧供给侧、方向比较、内容清单、倍率、爆款分、三道人工闸 | @references/method.md |
Files (workbuddy)
-
references
-
method.md 3.3 KB
# 选题方法(数据只标注,判断是人的) ## 三个数 | 数 | 怎么算 | 看什么 | |---|---|---| | 阅读中位 | 一个号最近 N 篇阅读的中位数 | 这个号的基本盘,比平均数稳,不被一篇爆文带偏 | | 爆款倍率 | 最高阅读 ÷ 阅读中位 | 倍率 2 以上的那几篇,是这个号的读者格外买账的题 | | 爆款分 | 阅读 + 3 × 分享 | 分享是转发出去的,比阅读值钱三倍,排序用它 | 倍率、爆款分都是标注,不是结论。同一个题在你的号上能不能火,看你有没有独家的东西。 ## 三道人工闸 1. 选词闸:品类关键词是用户定的,脚本不替用户猜品类。 2. 花钱闸:每个付费命令先报价,对标一个号要用户点头再扣。 3. 选题闸:清单交给用户挑,用户没选的不写。 ## 对标号怎么挑 - 从榜里挑:同品类、近 30 天有文章、阅读中位跟你的目标体量同一个量级(你 1000 阅读的号,对标 10 万阅读的头部号意义不大)。 - 优先挑爆款倍率高的号:说明它的读者对某类题反应大,那类题值得研究。 - 一次对标 3 到 5 个号,每个号看最近 10 篇,花 25 到 50 积分,几毛钱。 ## 选题清单的格式 每条四项:标题方向、为什么火(引用数据:对标那篇阅读多少、分享多少、跑赢本号几倍)、对标的是哪篇(标题加链接)、你能加的独家(用户自己填)。 独家那一栏空着的题不建议写。写作专家要求全文至少 15% 是用户自己的数据、经历、实验、反常规观点,没有独家的题写出来就是全网已有内容的重新排列。 ## 不许做的 - 不搬运、不洗稿、不抄标题原文。对标拿的是题材、角度和标题公式。 - 不把别人的文章正文喂给写作专家当素材,只给标题、摘要、数据。 - 不拿一篇文章的数据下「这个品类一定行」的判断,样本至少看一个号的 10 篇。 ## 需求侧和供给侧(2026-10-10 起) 选题是两边对上:需求侧是今天各平台在聊什么,供给侧是公众号上这个题谁写了、读了多少、爆没爆。需求侧不花钱:仓库每天早上自动拉四十多个热点源,跨平台聚类,按方向分组,写成 `data/hot/latest.json` 和 `HOT-LIST.md`,`feed` 命令直接读。供给侧用你自己的 wxrank key,`hot` 一次 1 积分。 规矩:有热度、但公众号里还没写爆的题优先;已经 10 万加的题要有你的独家角度才写;没爆的题不卡独家。跟「先查词看量再对照前十」一个道理。 ## 方向比较(direction) 还没定品类的人用。给 2 到 8 个候选品类词,每个词看一次榜(各 1 积分),出一张表:命中篇数、最高阅读、中位阅读、10 万加的篇数、最高分享、读得最多的那篇。命中多说明有人在写,10 万加多说明读者在,中位高说明普通一篇也有人看。数字只标注,方向你定。 ## 内容清单(calendar) 定了品类的人用。从仓库清单里挑跟你品类有关的题,不够就补多平台同聊的,再看一次公众号上这个品类的榜(1 积分),排成一张带日期的清单,默认每周 2 篇。每条写之前再用 `hot` 看一眼这个题爆没爆,独家栏自己填,没独家的题不建议写。 -
wxrank-api.md 5.1 KB
# wxrank 公众号接口(本技能用到的) 来源:wxrank(微小榜)showdoc 原文,2026-10-09 抄录并实测。注册、充值、拿 key 都在 https://data.wxrank.com ,100 积分 = 1 元。本技能的脚本只调下面这些端点,只读,key 从用户主目录 .wxrank.env 读。 ## 本技能各命令用哪个端点、花多少 | 命令 | 端点 | 积分 | |---|---|---| | balance | score | 0 | | hot | artlist | 1 | | search | getso | 10 | | accounts | getsu | 10 | | posts | getps | 5 | | benchmark | getps 一次 + getrk 每篇一次 | 5 + 2 × N | | article | getrk(短链先 artinfo 转长链) | 2(短链 3) | ## artlist 实测返回的字段(2026-10-09) sn、wx_biz、wx_type、pub_time、title、read_num、like_num、look_num、share_num、word_num、video_num、image_num、qmusic_num、ip_region、copyright、content_type、art_url、pic_url、content(正文片段)、data_update_time。所以看榜一次就能拿到阅读、在看、分享、字数和链接,不用再逐篇查。 通用:域名 `http://data.wxrank.com`(文档全部写 http),key 放请求参数/JSON 体里的 `key` 字段;GET 或 POST(POST 用 `Content-Type: application/json`,推荐 POST,避免 URL 编码问题);返回 `{"code":0,"msg":"剩余N积分","data":...}`;`code=0` 即扣积分(含返回空列表);100 积分 = 1 元。通用错误码:1000 积分不足;1002/1003 请求失败或超时请重试;1008 请求频繁;9999 QPS 超上限(搜索类每秒 3 个,getrk 每秒 10 个)。 ## score 获取当前剩余积分(免费) GET `http://data.wxrank.com/weixin/score?key=xxx` → `{"code":0,"msg":"剩余9810积分"}` ## getrk 实时获取公众号文章阅读(0.02 元/次) POST `/weixin/getrk` body `{"key","url","comment_id"(可选)}`;url 仅支持长链 `https://mp.weixin.qq.com/s?__biz=xxx==&mid=xxx&idx=1&sn=xxx`(短链先用 artinfo 转长链)。 返回 data:read_num 阅读、like_num 点赞、look_num 在看、share_num 分享、collect_num 收藏、reward_count 赞赏、comment_count 留言(传 comment_id 才有)。 错误:1001 链接为空/不是公众号文章链接;1002 文章验证失败;1003 获取失败重试;1004 服务异常;9999 QPS(每秒 10)。 ## artdata 实时获取文章数据含阅读(0.05 元/次) POST `/weixin/artdata` body `{"key","url"}`,url 仅长链。返回 data:biz、mid、idx、sn、article_url、name 公众号名、user_name 原始ID、pub_time、signature、short_link、title、digest、author、province_name、comment_id、copyright_stat、appmsg_bar_data{read_num, like_num, look_num, share_num, collect_num, reward_count, comment_count, original_content_num}。 ## artinfo 实时获取公众号文章内容(0.01 元/次) POST `/weixin/artinfo` body `{"key","url"}`,长链短链都行。返回 data:biz、mid、idx、sn、article_url(长链)、name、user_name(原始ID gh_)、pub_time、signature、title、digest、author、province_name、comment_id、copyright_stat、picture_url_list、正文等。用途之一:短链转长链、拿 comment_id。 ## getso 实时获取搜一搜文章列表(0.10 元/次) POST `/weixin/getso` body `{"key","keyword"(必),"sort_type"(0 不限/2 最新/4 最热),"page"(默认 1)}`。 返回 data 数组:wx_name 公众号名、pub_time、title(含 `<em class="highlight">` 高亮标签)、desc 摘要、art_url 文章链接、pic_url 封面。无总条数,data 为 [] 即最后一页。错误:1001 关键词为空;9999 QPS 每秒 3。 ## getsu 实时获取公众号搜索列表(0.10 元/次) POST `/weixin/getsu` body `{"key","keyword"(必),"page"}`。返回 data 数组:wx_id 微信号、wx_biz、wx_user 原始ID(gh_ 或 wxid_)、wx_name、signature 简介、headImgUrl。data 为 [] 即最后一页。 ## getps 实时获取公众号推文列表(0.05 元/次) POST `/weixin/getps` body `{"key","wxid"(必,微信号或原始ID,建议原始ID gh_ 开头),"cursor"(翻页,24 小时有效)}`。每页 10 次推文,每次推文最多 8 篇(art_url 的 idx 是第几篇)。 返回 data:list[{pub_time, title, sn, art_url, pic_url}]、wxid、cursor。list 为 [] 即最后一页。 ## artlist 获取公众号文章列表(离线库,0.01 元/次) GET/POST `/weixin/artlist` 参数:key;date(yyyymmdd,20230101 至昨日);month(yyyymm,不传 date 时用,默认当月);wx_biz;wx_type(24 大类:时事 文化 健康 职场 学术 美食 民生 科技 情感 楼市 汽车 幽默 政务 财富 旅行 企业 时尚 美体 教育 体娱 百科 乐活 创业 文摘);keyword(搜标题和内容);min_read_num / max_read_num(含边界,0 不限);content_type(article 文章 / book 贴图 / note 笔记 / all);has_image / has_video / has_audio(1/0);cursor(上一页返回的游标,5 分钟有效,续页只传 key+cursor)。 每页最多 50 条,按阅读数从高到低排。返回 data:cursor、total(匹配总数)、list[{sn, wx_biz, wx_type, pub_time, title, read_num, ...(示例被截断,至少含 read_num、title、wx_biz、pub_time)}]。空列表也计费。同一游标顺序用不要并发。
-
-
scripts
-
hot_sources.py 23 KB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """公众号代写 · 拉热点源。只用标准库,只读公开的 RSS / JSON / 页面,8 秒超时,打不开就跳过。 用法: python hot_sources.py 全部源,每源最多 8 条 python hot_sources.py --group AI,热榜 只拉某几个方向(AI 科技 热榜 财经 产品 海外) python hot_sources.py --n 5 --keyword 公众号,副业 每源 5 条,只留标题含关键词的 python hot_sources.py --cluster 跨平台同事件聚一起,标出几个源同时在爆 python hot_sources.py --words 订阅词.txt 按订阅词文件筛(语法见下) python hot_sources.py --json 机器可读输出 订阅词文件:一行一个词,空行分组。普通词=命中任一即可;+词=这组必须含;!词=这组排除;/正则/=正则;@数字=这组最多留几条。 例: AI写作 公众号 +副业 !招聘 @5 输出每条:源 | 时间 | 标题 | 链接。只拿标题和链接当选题线索,不搬正文。 """ import argparse, concurrent.futures, datetime, html, io, json, math, os, re, sys, time import urllib.request, urllib.error import xml.etree.ElementTree as ET try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass HERE = os.path.dirname(os.path.abspath(__file__)) UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36" def fetch(url, timeout=8): req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "*/*", "Accept-Language": "zh-CN,zh;q=0.9"}) with urllib.request.urlopen(req, timeout=timeout) as r: return r.read() def strip_tags(s): return html.unescape(re.sub(r"<[^>]+>", "", s or "")).strip() DATE_TITLE = re.compile(r"^[\s\d\-/.:年月日号周星期一二三四五六日天期第]+$") def pure_date_title(s): """标题只是日期或编号(早报入口、列表页),不是一个具体的题。""" return bool(s) and bool(DATE_TITLE.match(s.strip())) def explode_digest(raw, keep=1): """日报类 RSS:标题是日期,正文里才是一条条标题。把最近 keep 条日报的正文拆成单条,带上日报的链接和日期。""" out = [] try: root = ET.fromstring(raw) except ET.ParseError: return out done = 0 for it in root.iter("item"): title = strip_tags(it.findtext("title") or "") if not pure_date_title(title): continue link = (it.findtext("link") or "").strip() date = (it.findtext("pubDate") or "").strip() desc = strip_tags(it.findtext("description") or it.findtext("{http://purl.org/rss/1.0/modules/content/}encoded") or "") parts = re.split(r"↗\s*\d*|\n|(?<=[。!?])\s", desc) for p in parts: p = re.sub(r"^\s*(AI 早报.*?概览|概览|要闻|开发生态|产品应用|模型|行业|研究|观点|视频版.*?哔哩哔哩)\s*", "", p.strip()) p = re.sub(r"\s*\d+\s*$", "", p).strip(" ::||") if 8 <= len(p) <= 90 and not pure_date_title(p) and not JUNK.search(p): out.append((p, link, date)) done += 1 if done >= keep: break seen, uniq = set(), [] for x in out: if x[0] not in seen: seen.add(x[0]); uniq.append(x) return uniq def parse_rss(raw): items = [] try: root = ET.fromstring(raw) except ET.ParseError: return items ns = {"a": "http://www.w3.org/2005/Atom"} for it in root.iter("item"): t = strip_tags((it.findtext("title") or "")) l = (it.findtext("link") or "").strip() d = (it.findtext("pubDate") or it.findtext("{http://purl.org/dc/elements/1.1/}date") or "").strip() if t: items.append((t, l, d)) if not items: for e in root.findall(".//a:entry", ns): t = strip_tags(e.findtext("a:title", default="", namespaces=ns)) le = e.find("a:link", ns) l = le.get("href") if le is not None else "" d = (e.findtext("a:updated", default="", namespaces=ns) or e.findtext("a:published", default="", namespaces=ns)).strip() if t: items.append((t, l, d)) return items def _get(obj, keys): """大小写不敏感地按顺序取第一个像样的字符串字段。""" low = {str(k).lower(): v for k, v in obj.items()} for k in keys: v = low.get(k.lower()) if isinstance(v, str) and v.strip(): return v.strip() return None JUNK = re.compile(r"榜单介绍|上榜规则|登录|注册|VIP|充值|©|^\d{4}-\d{2}-\d{2}$|^https?://") TIME_KEYS = ("time", "pubDate", "publishDate", "publish_time", "created", "created_at", "createtime", "display_time", "ctime", "pubtime", "updated_at", "date") def walk_json(obj, out, url_tpl=None, depth=0, ctx=None): """从任意 JSON 里捞 {标题, 链接, 时间} 三元组,键名大小写不敏感。url_tpl 用字段值拼链接,如 {contId}; ctx 是祖先节点的标量字段,拼链接和取时间时兜底(金十的 id、time 在外层,title 在 data 里)。""" if depth > 7: return ctx = ctx or {} if isinstance(obj, dict): scal = dict(ctx) scal.update({k: v for k, v in obj.items() if isinstance(v, (str, int))}) t = _get(obj, ("title", "word", "query", "topic", "topic_name", "name", "hotWord", "keyword")) if not (t and 4 <= len(t) <= 120): t2 = _get(obj, ("text", "content")) t = t2 if t2 and 6 <= len(t2) <= 90 else None if t and not JUNK.search(t): l = _get(obj, ("url", "link", "mobileUrl", "mobil_url", "href", "short_link_v2", "short_link", "shareUrl", "article_url", "appUrl", "pcUrl", "topic_url", "topicUrl", "source_link")) or "" if not l and isinstance(obj.get("links"), dict): l = next((v for v in obj["links"].values() if isinstance(v, str) and v.startswith("http")), "") if not l and isinstance(obj.get("bvid"), str): l = "https://www.bilibili.com/video/" + obj["bvid"] if not l and url_tpl: try: l = url_tpl.format(**scal) except Exception: l = "" l = html.unescape(l) if l else "" if l and not l.startswith("http"): l = "" d = _get(obj, TIME_KEYS) or _get({k: str(v) for k, v in scal.items()}, TIME_KEYS) or "" out.append((strip_tags(t), l, d)) scal = {} for v in obj.values(): walk_json(v, out, url_tpl, depth + 1, scal) elif isinstance(obj, list): for v in obj: walk_json(v, out, url_tpl, depth + 1, ctx) def dedupe(items): seen, uniq = set(), [] for it in items: if it[0] not in seen: seen.add(it[0]); uniq.append(it) return uniq def parse_json(raw, url_tpl=None): try: data = json.loads(raw.decode("utf-8", "replace")) except Exception: return [] out = [] walk_json(data, out, url_tpl) return dedupe(out) def parse_js(raw, url_tpl=None): s = raw.decode("utf-8", "replace").strip() s = re.sub(r"^\s*(var|let|const)\s+\w+\s*=\s*", "", s).rstrip(";").strip() return parse_json(s.encode("utf-8"), url_tpl) def parse_sdata(raw, url_tpl=None): s = raw.decode("utf-8", "replace") m = re.search(r"<!--s-data:(.*?)-->", s, re.S) if not m: return [] return parse_json(m.group(1).encode("utf-8"), url_tpl) def parse_html(raw, src): s = raw.decode("utf-8", "replace") pat = src.get("pattern") if not pat: return [] base = src.get("base") or re.match(r"https?://[^/]+", src["url"]).group(0) items = [] for m in re.finditer(pat, s, re.S | re.I): g = m.groupdict() t = strip_tags(g.get("title") or "") l = (g.get("href") or "").strip() if l.startswith("/"): l = base + l if 6 <= len(t) <= 120 and not JUNK.search(t): items.append((t, l, "")) return dedupe(items) REL_RE = re.compile(r"(\d+)\s*(秒|分钟|分|小时|时|天|日|周|星期|个月|月)\s*前") REL_UNIT = {"秒": 1 / 3600, "分钟": 1 / 60, "分": 1 / 60, "小时": 1, "时": 1, "天": 24, "日": 24, "周": 168, "星期": 168, "个月": 720, "月": 720} ISO_RE = re.compile(r"(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?(?:[T\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?\s*(Z|[+-]\d{2}:?\d{2})?)?") MD_RE = re.compile(r"^(\d{1,2})[-/月](\d{1,2})日?(?:\s+(\d{1,2}):(\d{2}))?$") def norm_time(d, now=None): """各源的时间写法统一成本地钟的 'MM-DD HH:MM':时间戳、ISO(带时区的换算成本地)、RFC2822(同上)、 '22分钟前'、'昨天 12:30'、只有日期的按当天 00:00。认不出的原样截 16 位。""" if not d: return "" d = str(d).strip() now = now or datetime.datetime.now() try: if re.fullmatch(r"\d{10}", d): return datetime.datetime.fromtimestamp(int(d)).strftime("%m-%d %H:%M") if re.fullmatch(r"\d{13}", d): return datetime.datetime.fromtimestamp(int(d) / 1000).strftime("%m-%d %H:%M") if d.startswith("刚刚") or d in ("今天", "今日"): return now.strftime("%m-%d %H:%M") m = REL_RE.search(d) if m: return (now - datetime.timedelta(hours=int(m.group(1)) * REL_UNIT[m.group(2)])).strftime("%m-%d %H:%M") m = re.match(r"^(今天|昨天|前天)\s*(\d{1,2}):(\d{2})$", d) if m: day = now - datetime.timedelta(days={"今天": 0, "昨天": 1, "前天": 2}[m.group(1)]) return day.strftime("%m-%d") + " %02d:%s" % (int(m.group(2)), m.group(3)) m = ISO_RE.search(d) if m: dt = datetime.datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4) or 0), int(m.group(5) or 0)) tz = m.group(7) if tz: off = 0 if tz.upper() == "Z" else (1 if tz[0] == "+" else -1) * (int(tz[1:3]) * 60 + int(tz[-2:])) dt = dt.replace(tzinfo=datetime.timezone(datetime.timedelta(minutes=off))).astimezone().replace(tzinfo=None) return dt.strftime("%m-%d %H:%M") m = MD_RE.match(d) if m: return "%02d-%02d %02d:%s" % (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0), m.group(4) or "00") import email.utils dt = email.utils.parsedate_to_datetime(d) if dt.tzinfo is not None: dt = dt.astimezone().replace(tzinfo=None) return dt.strftime("%m-%d %H:%M") except Exception: return d[:16] def age_hours(t, now=None): """'MM-DD HH:MM' → 距今小时数;一月初碰到去年十二月的按去年算;不是这个格式返回 None。""" m = re.match(r"^(\d{2})-(\d{2}) (\d{2}):(\d{2})$", t or "") if not m: return None now = now or datetime.datetime.now() try: dt = datetime.datetime(now.year, int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))) if dt > now + datetime.timedelta(days=2): dt = dt.replace(year=now.year - 1) except ValueError: return None return (now - dt).total_seconds() / 3600 def newest(times, now=None): """一组 'MM-DD HH:MM' 里最新的那个:按距今小时数比,不按字符串比大小,跨年不出错。""" best, best_age = "", None for x in times: a = age_hours(x, now) if a is not None and (best_age is None or a < best_age): best, best_age = x, a return best or (max(times) if times else "") def pull(src, n): """拉一个源。打不开、解析不了、配置坏了都只返回 (src, [], 说明),不抛异常:一条坏源不能拖死整轮。""" t0 = time.time() url = src.get("url") or "" if not url.startswith("http"): return src, [], "配置坏了 缺 url" if src.get("cache_bust"): url += ("&" if "?" in url else "?") + "%s=%d" % (src["cache_bust"], int(time.time() * 1000)) try: raw = fetch(url) except Exception as e: return src, [], "打不开 %s" % type(e).__name__ try: typ = src.get("type", "rss") if typ == "rss": items = parse_rss(raw) if src.get("explode_digest"): items = explode_digest(raw, int(src.get("explode_digest") if str(src.get("explode_digest")).isdigit() else 1)) + items elif typ == "json": items = parse_json(raw, src.get("url_tpl")) elif typ == "js": items = parse_js(raw, src.get("url_tpl")) elif typ == "sdata": items = parse_sdata(raw, src.get("url_tpl")) else: items = parse_html(raw, src) if src.get("need_link"): items = [it for it in items if it[1]] if not src.get("keep_date_titles"): items = [it for it in items if not pure_date_title(it[0])] except Exception as e: return src, [], "解析失败 %s" % type(e).__name__ return src, items[:n], "%.1fs" % (time.time() - t0) # ---------- 跨平台同事件聚类(照 beacon 的思路自己写:去数字标点停用字模板词,2-gram 余弦) ---------- STOP = set("的了是在和与及对为被从把这那个吗呢啊吧也都就还又很之其中于上下里外后前") TEMPLATE = re.compile(r"如何看待|如何评价|怎么看|怎样看待|直击|重磅|突发|官方回应|网友热议|最新消息|刚刚|今日|今天|图集|视频|组图|实探|深度|观察|独家") def norm_title(t): t = TEMPLATE.sub("", t) t = re.sub(r"[0-90-9]+", "", t) t = re.sub(r"[^\w一-鿿]+", "", t) return "".join(ch for ch in t if ch not in STOP) def grams(t): t = norm_title(t) return set(t[i:i + 2] for i in range(len(t) - 1)) if len(t) >= 2 else set() def cosine(a, b): if not a or not b: return 0.0 return len(a & b) / math.sqrt(len(a) * len(b)) VERSION_RE = re.compile(r"(?<![\w.])v?\d+(?:\.\d+)+(?![\w.])|(?<=[A-Za-z])[\s-]?\d+(?:\.\d+)?(?![\w.])", re.I) EN_STOP = set("the a an of to in on for and or with by at from is are was were be as it its this that these those new how why what when who vs".split()) def is_ascii(t): return bool(re.fullmatch(r"[\x00-\x7f]*", t)) def words(t): return set(w for w in re.findall(r"[a-z0-9][a-z0-9.+#-]*", t.lower()) if len(w) >= 3 and w not in EN_STOP) def versions(t): return set(m.group(0).strip(" -").lower() for m in VERSION_RE.finditer(t)) OPPOSITES = [("跌破", "突破"), ("下跌", "上涨"), ("跌停", "涨停"), ("新低", "新高"), ("最低", "最高"), ("下降", "上升"), ("下滑", "增长"), ("减少", "增加"), ("亏损", "盈利"), ("低于", "超过"), ("退市", "上市"), ("关闭", "开放"), ("否决", "通过"), ("反对", "支持"), ("败诉", "胜诉"), ("否认", "承认"), ("取消", "恢复"), ("下架", "上架"), ("解约", "签约"), ("离职", "入职"), ("卸任", "上任"), ("跌", "涨"), ("降", "升"), ("减", "增")] def opposite(a, b): """一边说跌破一边说突破这种相反说法,不算同一件事。两边都含同一对词的(涨跌互现)不算。""" for x, y in OPPOSITES: ax, ay, bx, by = x in a, y in a, x in b, y in b if (ax and not ay and by and not bx) or (ay and not ax and bx and not by): return True return False def same_event(a, b, ga, gb, th): """候选合并判定:字面相似。中文二字片段余弦,英文词集余弦;完全相同必合并; 两边都带版本号且不同不合并(含 GPT-4 / GPT-5 这种连字符);相反说法不合并; 中文去掉数字标点后不足 5 字、英文不足 2 个实词的短名只在完全相同时合并。""" va, vb = versions(a), versions(b) if va and vb and va != vb: return False na, nb = norm_title(a), norm_title(b) if na and na == nb: return True if opposite(a, b): return False if is_ascii(a) or is_ascii(b): if not (is_ascii(a) and is_ascii(b)): return False wa, wb = words(a), words(b) if min(len(wa), len(wb)) < 2: return False return cosine(wa, wb) >= 0.5 and len(wa & wb) / min(len(wa), len(wb)) >= 0.6 if min(len(na), len(nb)) < 5: return False if not (ga and gb): return False return cosine(ga, gb) >= th and len(ga & gb) / min(len(ga), len(gb)) >= 0.6 def cluster(entries, th=0.42): """entries: [(source, title, url, time)]。返回 [[entry,...], ...],按不同源的个数降序。 这是候选合并,不是已确认的同一事件:只看标题字面,成员标题要留给人核对。""" gs = [grams(e[1]) for e in entries] used = [False] * len(entries) clusters = [] for i in range(len(entries)): if used[i]: continue group = [entries[i]]; used[i] = True for j in range(i + 1, len(entries)): if not used[j] and same_event(entries[i][1], entries[j][1], gs[i], gs[j], th): group.append(entries[j]); used[j] = True clusters.append(group) clusters.sort(key=lambda g: (-len(set(e[0] for e in g)), -len(g))) return clusters # ---------- 订阅词(照 TrendRadar 的语法自己写一版简化的) ---------- def _valid(g): return bool(g and (g["any"] or g["must"] or g["regex"])) def load_words(path): """空行分组;「# 名字」给下一组起名;普通词任一命中;+词 必须含;!词 排除;/正则/;@数字 这组最多留几条。""" groups, cur, pending = [], None, None for line in io.open(path, encoding="utf-8"): s = line.strip() if not s or s.startswith("#"): if _valid(cur): groups.append(cur) cur = None if s.startswith("#"): pending = s.lstrip("#").strip() or None continue if cur is None: cur = {"any": [], "must": [], "exclude": [], "regex": [], "cap": None, "name": pending or s} pending = None if s.startswith("+"): cur["must"].append(s[1:].strip()) elif s.startswith("!"): cur["exclude"].append(s[1:].strip()) elif s.startswith("@") and s[1:].strip().isdigit(): cur["cap"] = int(s[1:].strip()) elif len(s) >= 3 and s.startswith("/") and s.endswith("/"): try: cur["regex"].append(re.compile(s[1:-1], re.I)) except re.error: pass elif "=>" in s: continue else: cur["any"].append(s) if _valid(cur): groups.append(cur) return groups def match_group(title, g): t = title.lower() if any(w.lower() in t for w in g["exclude"]): return False if any(w.lower() not in t for w in g["must"]): return False if g["any"] or g["regex"]: return any(w.lower() in t for w in g["any"]) or any(r.search(title) for r in g["regex"]) return True def drop_fallbacks(results): """主源拉到了东西,它的备用源(fallback_for)就不要,免得同一个榜出现两份。results: [(src, items, note)]""" got = {s["name"] for s, items, _ in results if items} return [(s, items, note) for s, items, note in results if not (s.get("fallback_for") and s["fallback_for"] in got)] def load_sources(path=None): """读 sources.json,跳过 disabled 的源。""" cfg = json.load(io.open(path or os.path.join(HERE, "sources.json"), encoding="utf-8")) return [s for s in cfg["sources"] if not s.get("disabled")] def main(): ap = argparse.ArgumentParser() ap.add_argument("--group", help="AI 科技 热榜 财经 产品 海外,逗号隔开") ap.add_argument("--n", type=int, default=8) ap.add_argument("--keyword", help="只留标题含这些词的,逗号隔开") ap.add_argument("--words", help="订阅词文件") ap.add_argument("--cluster", action="store_true", help="跨平台同事件聚一起") ap.add_argument("--json", action="store_true") a = ap.parse_args() srcs = load_sources() if a.group: gs = set(x.strip() for x in a.group.split(",")) srcs = [s for s in srcs if s.get("group") in gs] kws = [k.strip() for k in (a.keyword or "").split(",") if k.strip()] results = [] with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex: for src, items, note in ex.map(lambda s: pull(s, a.n), srcs): if kws: items = [it for it in items if any(k.lower() in it[0].lower() for k in kws)] results.append((src, items, note)) results = drop_fallbacks(results) now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") if a.words: groups = load_words(a.words) entries = [(s["name"], t, l, norm_time(d)) for s, items, _ in results for t, l, d in items] out = {} for g in groups: hits = [e for e in entries if match_group(e[1], g)] if g["cap"]: hits = hits[:g["cap"]] out[g["name"]] = hits if a.json: print(json.dumps({k: [{"source": s, "title": t, "url": l, "time": d} for s, t, l, d in v] for k, v in out.items()}, ensure_ascii=False, indent=1)); return for k, v in out.items(): print("## 订阅组 %s(%d 条)" % (k, len(v))) for s, t, l, d in v: print("- %s | %s | %s | %s" % (s, d or "--", t, l or "-")) print() print("拉取时间 %s。" % now); return if a.cluster: entries = [(s["name"], t, l, norm_time(d)) for s, items, _ in results for t, l, d in items] cl = cluster(entries) if a.json: print(json.dumps([{"sources": sorted(set(e[0] for e in g)), "items": [{"source": s, "title": t, "url": l, "time": d} for s, t, l, d in g]} for g in cl], ensure_ascii=False, indent=1)); return multi = [g for g in cl if len(set(e[0] for e in g)) >= 2] print("## 多个源同时在爆(%d 组,按源数排)" % len(multi)) for g in multi: names = sorted(set(e[0] for e in g)) print("- [%d 个源] %s" % (len(names), g[0][1])) for s, t, l, d in g: print(" · %s | %s | %s" % (s, t, l or "-")) print() print("## 单源条目 %d 条(略)" % sum(1 for g in cl if len(set(e[0] for e in g)) < 2)) print("拉取时间 %s。" % now); return total = 0 if a.json: print(json.dumps([{"source": s["name"], "group": s.get("group"), "note": note, "items": [{"title": t, "url": l, "time": norm_time(d)} for t, l, d in items]} for s, items, note in results], ensure_ascii=False, indent=1)) return for s, items, note in results: print("## %s(%s,%s)" % (s["name"], s.get("group", ""), note if not items else "%d 条 %s" % (len(items), note))) for t, l, d in items: total += 1 print("- %s | %s | %s" % (norm_time(d) or "--", t, l or "-")) print() print("共 %d 条,拉取时间 %s。只拿标题和角度当选题线索,不搬正文。" % (total, now)) if __name__ == "__main__": main() -
sources.json 16.5 KB
{ "_note": "公众号代写的热点源清单。全是公开的 RSS / JSON 接口 / 页面,国内直连优先。type: rss | json | js(去掉 var xx = 前缀再当 JSON)| sdata(页内 <!--s-data:...--> JSON)| html(配 pattern 正则,命名组 title 和 href)。 可选字段:url_tpl 用条目字段拼链接;cache_bust 加时间戳参数名;need_link 没链接的条目丢掉;fallback_for 主源拉到了就不用这个备用源。网页类复杂源不走脚本,用 WebFetch 读。 platform 是平台 id,同一平台多个源只算一个平台(算同爆、自动加源都按它去重)。 lang 是语言;explode_digest 把日报正文拆成单条;keep_date_titles 不过滤纯日期标题(热搜词类)。", "sources": [ { "name": "AIHOT 热点Top10", "url": "https://aihot.news/api/v1/hot-topics", "type": "json", "group": "AI", "platform": "aihot", "lang": "zh" }, { "name": "AIHOT 精选流", "url": "https://aihot.news/feed.xml", "type": "rss", "group": "AI", "platform": "aihot", "lang": "zh" }, { "name": "量子位", "url": "https://www.qbitai.com/feed", "type": "rss", "group": "AI", "platform": "qbitai", "lang": "zh" }, { "name": "InfoQ 中文", "url": "https://www.infoq.cn/feed", "type": "rss", "group": "AI", "platform": "infoq", "lang": "zh" }, { "name": "橘鸦AI早报", "url": "https://daily.juya.uk/rss.xml", "type": "rss", "group": "AI", "platform": "juya", "explode_digest": 1, "lang": "zh" }, { "name": "Readhub", "url": "https://api.readhub.cn/topic/list?page=1&size=20", "type": "json", "group": "科技", "platform": "readhub", "lang": "zh" }, { "name": "阮一峰周刊", "url": "https://www.ruanyifeng.com/blog/atom.xml", "type": "rss", "group": "科技", "platform": "ruanyifeng", "lang": "zh" }, { "name": "知乎热榜", "url": "https://api.zhihu.com/topstory/hot-lists/total?limit=20", "type": "json", "group": "热榜", "platform": "zhihu", "lang": "zh" }, { "name": "百度热搜", "url": "https://top.baidu.com/api/board?platform=wise&tab=realtime", "type": "json", "group": "热榜", "platform": "baidu", "lang": "zh" }, { "name": "百度热搜直连", "url": "https://top.baidu.com/board?tab=realtime", "type": "sdata", "group": "热榜", "fallback_for": "百度热搜", "platform": "baidu", "lang": "zh" }, { "name": "B站热门", "url": "https://api.bilibili.com/x/web-interface/popular?ps=20&pn=1", "type": "json", "group": "热榜", "platform": "bilibili", "lang": "zh" }, { "name": "头条热榜", "url": "https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc", "type": "json", "group": "热榜", "platform": "toutiao", "lang": "zh" }, { "name": "澎湃热榜", "url": "https://cache.thepaper.cn/contentapi/wwwIndex/rightSidebar", "type": "json", "group": "热榜", "url_tpl": "https://www.thepaper.cn/newsDetail_forward_{contId}", "need_link": true, "platform": "thepaper", "lang": "zh" }, { "name": "贴吧热议", "url": "https://tieba.baidu.com/hottopic/browse/topicList", "type": "json", "group": "热榜", "platform": "tieba", "lang": "zh" }, { "name": "参考消息", "url": "https://china.cankaoxiaoxi.com/json/channel/zhongguo/list.json", "type": "json", "group": "热榜", "platform": "cankaoxiaoxi", "lang": "zh" }, { "name": "NewsNow 微博", "url": "https://newsnow.busiyi.world/api/s?id=weibo", "type": "json", "group": "热榜", "platform": "weibo", "lang": "zh" }, { "name": "NewsNow 抖音", "url": "https://newsnow.busiyi.world/api/s?id=douyin", "type": "json", "group": "热榜", "platform": "douyin", "lang": "zh" }, { "name": "NewsNow 掘金", "url": "https://newsnow.busiyi.world/api/s?id=juejin", "type": "json", "group": "科技", "platform": "juejin", "lang": "zh" }, { "name": "NewsNow 虎扑", "url": "https://newsnow.busiyi.world/api/s?id=hupu", "type": "json", "group": "热榜", "platform": "hupu", "lang": "zh" }, { "name": "NewsNow 财联社热门", "url": "https://newsnow.busiyi.world/api/s?id=cls-hot", "type": "json", "group": "财经", "platform": "cls", "lang": "zh" }, { "name": "金十快讯", "url": "https://www.jin10.com/flash_newest.js", "type": "js", "group": "财经", "cache_bust": "t", "url_tpl": "https://flash.jin10.com/detail/{id}", "platform": "jin10", "lang": "zh" }, { "name": "36氪快讯", "url": "https://www.36kr.com/newsflashes", "type": "html", "group": "财经", "pattern": "<a class=\"item-title\"[^>]*?href=\"(?P<href>[^\"]+)\"[^>]*>(?P<title>.*?)</a>", "base": "https://www.36kr.com", "platform": "36kr", "lang": "zh" }, { "name": "IT之家", "url": "https://www.ithome.com/rss/", "type": "rss", "group": "科技", "platform": "ithome", "lang": "zh" }, { "name": "钛媒体", "url": "https://www.tmtpost.com/rss.xml", "type": "rss", "group": "财经", "platform": "tmtpost", "lang": "zh" }, { "name": "华尔街见闻快讯", "url": "https://api-one.wallstcn.com/apiv1/content/lives?channel=global-channel&limit=20", "type": "json", "group": "财经", "platform": "wallstreetcn", "lang": "zh" }, { "name": "新浪科技", "url": "https://feed.mix.sina.com.cn/api/roll/get?pageid=372&lid=2431&num=20", "type": "json", "group": "科技", "platform": "sina", "lang": "zh" }, { "name": "爱范儿", "url": "https://www.ifanr.com/feed", "type": "rss", "group": "科技", "platform": "ifanr", "lang": "zh" }, { "name": "极客公园", "url": "https://www.geekpark.net/rss", "type": "rss", "group": "科技", "platform": "geekpark", "lang": "zh" }, { "name": "雷峰网", "url": "https://www.leiphone.com/feed", "type": "rss", "group": "科技", "platform": "leiphone", "lang": "zh" }, { "name": "cnBeta", "url": "https://www.cnbeta.com.tw/backend.php", "type": "rss", "group": "科技", "platform": "cnbeta", "lang": "zh" }, { "name": "Solidot", "url": "https://www.solidot.org/index.rss", "type": "rss", "group": "科技", "platform": "solidot", "lang": "zh" }, { "name": "开源中国", "url": "https://www.oschina.net/news/rss", "type": "rss", "group": "科技", "platform": "oschina", "lang": "zh" }, { "name": "少数派", "url": "https://sspai.com/feed", "type": "rss", "group": "产品", "platform": "sspai", "lang": "zh" }, { "name": "人人都是产品经理", "url": "https://www.woshipm.com/feed", "type": "rss", "group": "产品", "platform": "woshipm", "lang": "zh" }, { "name": "小众软件", "url": "https://www.appinn.com/feed/", "type": "rss", "group": "产品", "platform": "appinn", "lang": "zh" }, { "name": "V2EX 热门", "url": "https://www.v2ex.com/api/topics/hot.json", "type": "json", "group": "产品", "platform": "v2ex", "lang": "zh" }, { "name": "HelloGitHub", "url": "https://hellogithub.com/rss", "type": "rss", "group": "产品", "platform": "hellogithub", "lang": "zh" }, { "name": "HN 中文", "url": "https://hn.buzzing.cc/feed.xml", "type": "rss", "group": "海外", "platform": "hackernews", "lang": "zh" }, { "name": "Product Hunt 中文", "url": "https://ph.buzzing.cc/feed.xml", "type": "rss", "group": "海外", "platform": "producthunt", "lang": "zh" }, { "name": "BestBlogs", "url": "https://www.bestblogs.dev/en/feeds/rss", "type": "rss", "group": "海外", "platform": "bestblogs", "lang": "zh" }, { "name": "NewsNow 联合早报", "url": "https://newsnow.busiyi.world/api/s?id=zaobao", "type": "json", "group": "热榜", "platform": "zaobao", "added": "2026-10-09", "lang": "zh" }, { "name": "NewsNow 酷安", "url": "https://newsnow.busiyi.world/api/s?id=coolapk", "type": "json", "group": "科技", "platform": "coolapk", "added": "2026-10-09", "lang": "zh" }, { "name": "NewsNow MKTNews 快讯", "url": "https://newsnow.busiyi.world/api/s?id=mktnews-flash", "type": "json", "group": "财经", "platform": "mktnews", "added": "2026-10-09", "lang": "zh" }, { "name": "NewsNow 懂球帝", "url": "https://newsnow.busiyi.world/api/s?id=dongqiudi", "type": "json", "group": "热榜", "platform": "dongqiudi", "added": "2026-10-09", "lang": "zh" }, { "name": "NewsNow 卫星通讯社", "url": "https://newsnow.busiyi.world/api/s?id=sputniknewscn", "type": "json", "group": "热榜", "platform": "sputniknewscn", "added": "2026-10-09", "lang": "zh" }, { "name": "NewsNow 远景论坛 Win11", "url": "https://newsnow.busiyi.world/api/s?id=pcbeta-windows11", "type": "json", "group": "科技", "platform": "pcbeta", "added": "2026-10-09", "lang": "zh" }, { "name": "NewsNow 雪球 热门股票", "url": "https://newsnow.busiyi.world/api/s?id=xueqiu-hotstock", "type": "json", "group": "财经", "platform": "xueqiu", "added": "2026-10-09", "lang": "zh" }, { "name": "NewsNow 格隆汇", "url": "https://newsnow.busiyi.world/api/s?id=gelonghui", "type": "json", "group": "财经", "platform": "gelonghui", "added": "2026-10-09", "lang": "zh" }, { "name": "NewsNow 法布财经 快讯", "url": "https://newsnow.busiyi.world/api/s?id=fastbull-express", "type": "json", "group": "财经", "platform": "fastbull", "added": "2026-10-09", "lang": "zh" }, { "name": "NewsNow Github Today", "url": "https://newsnow.busiyi.world/api/s?id=github-trending-today", "type": "json", "group": "海外", "platform": "github", "added": "2026-10-09", "lang": "zh" }, { "name": "TechCrunch", "url": "https://techcrunch.com/feed/", "type": "rss", "group": "海外", "platform": "techcrunch", "lang": "en" }, { "name": "The Verge", "url": "https://www.theverge.com/rss/index.xml", "type": "rss", "group": "海外", "platform": "theverge", "lang": "en" }, { "name": "Ars Technica", "url": "https://feeds.arstechnica.com/arstechnica/index", "type": "rss", "group": "海外", "platform": "arstechnica", "lang": "en" }, { "name": "MIT Technology Review", "url": "https://www.technologyreview.com/feed/", "type": "rss", "group": "海外", "platform": "mittr", "lang": "en" }, { "name": "BBC Technology", "url": "https://feeds.bbci.co.uk/news/technology/rss.xml", "type": "rss", "group": "海外", "platform": "bbc", "lang": "en" }, { "name": "Wired", "url": "https://www.wired.com/feed/rss", "type": "rss", "group": "海外", "platform": "wired", "lang": "en" }, { "name": "The Information", "url": "https://www.theinformation.com/feed", "type": "rss", "group": "海外", "platform": "theinformation", "lang": "en" }, { "name": "Lobsters", "url": "https://lobste.rs/rss", "type": "rss", "group": "海外", "platform": "lobsters", "lang": "en" }, { "name": "Product Hunt", "url": "https://www.producthunt.com/feed", "type": "rss", "group": "海外", "platform": "producthunt", "lang": "en" }, { "name": "Google News AI(英文)", "url": "https://news.google.com/rss/search?q=AI&hl=en-US&gl=US&ceid=US:en", "type": "rss", "group": "AI", "platform": "googlenews", "lang": "en" }, { "name": "VentureBeat AI", "url": "https://venturebeat.com/category/ai/feed/", "type": "rss", "group": "AI", "platform": "venturebeat", "lang": "en", "disabled": true, "disabled_on": "2026-10-15" }, { "name": "OpenAI News", "url": "https://openai.com/news/rss.xml", "type": "rss", "group": "AI", "platform": "openai", "lang": "en" }, { "name": "Reddit r/artificial", "url": "https://www.reddit.com/r/artificial/top/.rss?t=day", "type": "rss", "group": "AI", "platform": "reddit", "lang": "en" }, { "name": "arXiv cs.AI", "url": "https://export.arxiv.org/rss/cs.AI", "type": "rss", "group": "AI", "platform": "arxiv", "lang": "en" }, { "name": "ITmedia AI+(日文)", "url": "https://rss.itmedia.co.jp/rss/2.0/aiplus.xml", "type": "rss", "group": "AI", "platform": "itmedia", "lang": "ja" }, { "name": "Yahoo Japan 热门(日文)", "url": "https://news.yahoo.co.jp/rss/topics/top-picks.xml", "type": "rss", "group": "海外", "platform": "yahoojp", "lang": "ja" }, { "name": "NHK 主要(日文)", "url": "https://www3.nhk.or.jp/rss/news/cat0.xml", "type": "rss", "group": "海外", "platform": "nhk", "lang": "ja" }, { "name": "韩联社(英文)", "url": "https://en.yna.co.kr/RSS/news.xml", "type": "rss", "group": "海外", "platform": "yonhap", "lang": "en" }, { "name": "Google Trends 美国", "url": "https://trends.google.com/trending/rss?geo=US", "type": "rss", "group": "热搜词", "platform": "gtrends-us", "lang": "en", "keep_date_titles": true }, { "name": "Google Trends 英国", "url": "https://trends.google.com/trending/rss?geo=GB", "type": "rss", "group": "热搜词", "platform": "gtrends-gb", "lang": "en", "keep_date_titles": true }, { "name": "Google Trends 新加坡", "url": "https://trends.google.com/trending/rss?geo=SG", "type": "rss", "group": "热搜词", "platform": "gtrends-sg", "lang": "en", "keep_date_titles": true }, { "name": "Google Trends 日本", "url": "https://trends.google.com/trending/rss?geo=JP", "type": "rss", "group": "热搜词", "platform": "gtrends-jp", "lang": "ja", "keep_date_titles": true }, { "name": "Google Trends 韩国", "url": "https://trends.google.com/trending/rss?geo=KR", "type": "rss", "group": "热搜词", "platform": "gtrends-kr", "lang": "ko", "keep_date_titles": true }, { "name": "Google Trends 台湾", "url": "https://trends.google.com/trending/rss?geo=TW", "type": "rss", "group": "热搜词", "platform": "gtrends-tw", "lang": "zh-tw", "keep_date_titles": true }, { "name": "Google Trends 香港", "url": "https://trends.google.com/trending/rss?geo=HK", "type": "rss", "group": "热搜词", "platform": "gtrends-hk", "lang": "zh-hk", "keep_date_titles": true }, { "name": "NewsNow 牛客", "url": "https://newsnow.busiyi.world/api/s?id=nowcoder", "type": "json", "group": "科技", "platform": "nowcoder", "added": "2026-10-09" }, { "name": "NewsNow 凤凰网", "url": "https://newsnow.busiyi.world/api/s?id=ifeng", "type": "json", "group": "热榜", "platform": "ifeng", "added": "2026-10-09" }, { "name": "NewsNow 虫部落 最新", "url": "https://newsnow.busiyi.world/api/s?id=chongbuluo-latest", "type": "json", "group": "热榜", "platform": "chongbuluo", "added": "2026-10-09" }, { "name": "NewsNow 豆瓣", "url": "https://newsnow.busiyi.world/api/s?id=douban", "type": "json", "group": "热榜", "platform": "douban", "added": "2026-10-09" }, { "name": "NewsNow Steam", "url": "https://newsnow.busiyi.world/api/s?id=steam", "type": "json", "group": "产品", "platform": "steam", "added": "2026-10-09" }, { "name": "NewsNow 腾讯新闻 综合早报", "url": "https://newsnow.busiyi.world/api/s?id=tencent-hot", "type": "json", "group": "热榜", "platform": "tencent", "added": "2026-10-09" }, { "name": "NewsNow Freebuf", "url": "https://newsnow.busiyi.world/api/s?id=freebuf", "type": "json", "group": "科技", "platform": "freebuf", "added": "2026-10-09" }, { "name": "NewsNow 腾讯视频 热搜榜", "url": "https://newsnow.busiyi.world/api/s?id=qqvideo-tv-hotsearch", "type": "json", "group": "热榜", "platform": "qqvideo", "added": "2026-10-09" }, { "name": "NewsNow 爱奇艺 热播榜", "url": "https://newsnow.busiyi.world/api/s?id=iqiyi-hot-ranklist", "type": "json", "group": "热榜", "platform": "iqiyi", "added": "2026-10-09" }, { "name": "NewsNow 靠谱新闻", "url": "https://newsnow.busiyi.world/api/s?id=kaopu", "type": "json", "group": "热榜", "platform": "kaopu", "added": "2026-10-14" } ] } -
wxrank_topics.py 25.2 KB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """公众号选题对标(wxrank 微小榜数据)。你自己的 wxrank key,你自己充值,每次调用都先告诉你要花多少积分。 用法: python wxrank_topics.py balance 余额(免费) python wxrank_topics.py hot --keyword 出海 [--date 20261001 | --month 202610] [--min-read 1000] [--type 科技] [--n 20] 离线库里这个词阅读最高的文章(1 积分) python wxrank_topics.py search --keyword 出海 [--sort 新|热] [--page 1] 搜一搜文章列表(10 积分) python wxrank_topics.py accounts --keyword 出海 找公众号,拿原始ID 和 biz(10 积分) python wxrank_topics.py posts --wxid gh_xxx 某个号最近一页推文(5 积分) python wxrank_topics.py benchmark --wxid gh_xxx [--n 10] [--yes] 对标一个号:最近 N 篇各拉一次阅读在看分享,算阅读中位、爆款倍率、爆款分(5 + 2N 积分,先报价,加 --yes 才扣) python wxrank_topics.py article --url "https://mp.weixin.qq.com/s?__biz=..." 一篇文章的阅读 点赞 在看 分享 收藏 赞赏(2 积分,短链多 1 积分) python wxrank_topics.py feed [--group AI,热榜] [--keyword 副业,AI] [--n 15] [--hot-only] 读仓库每天自动更新的内容清单(免费 不花积分) python wxrank_topics.py direction --keywords 出海,副业,AI写作 [--month 202610] 方向比较:每个词看一次榜 出一张表(每词 1 积分) python wxrank_topics.py calendar --keyword 出海 [--n 10] [--per-week 2] [--start 2026-10-14] [--month 202610] 内容清单:清单里挑题 对照榜 排日期(1 积分) 凭据:~/.wxrank.env 一行 KEY=你的key(或环境变量 WXRANK_KEY)。注册和充值 https://data.wxrank.com ,100 积分 = 1 元。 纪律:只读;每天上限 WXRANK_DAILY_CAP 积分(默认 300);用量记 ~/.wxrank-usage.log;输出不含 key;只用标准库。 口径:wxrank 的数是实时抓的公开数,跟后台会差;爆款倍率 = 单篇阅读 ÷ 该号阅读中位;爆款分 = 阅读 + 3 × 分享(分享比阅读值钱)。只标注数字,选题是你定。 """ import argparse, datetime, io, json, os, re, statistics, sys, time, urllib.request, urllib.error try: sys.stdout.reconfigure(encoding="utf-8"); sys.stderr.reconfigure(encoding="utf-8") except Exception: pass BASE = "https://data.wxrank.com/weixin/" FEED_URL = os.environ.get("WECHAT_FEED_URL", "https://raw.githubusercontent.com/adsorgcn/WorkBuddy/main/data/hot/latest.json") FEED_LOCAL = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))), "data", "hot", "latest.json") ENV = os.path.join(os.path.expanduser("~"), ".wxrank.env") LOG = os.path.join(os.path.expanduser("~"), ".wxrank-usage.log") DAILY_CAP = int(os.environ.get("WXRANK_DAILY_CAP", "300")) ERR = {1000: "积分不足 或 key 不对(key 错时 wxrank 也回 1000)先核 ~/.wxrank.env 再去 data.wxrank.com 充值", 1001: "参数为空 或不是公众号文章链接", 1002: "请求失败或文章验证失败 可以重试一次", 1003: "获取失败 可以重试一次", 1004: "wxrank 服务异常 稍后再试", 1008: "请求太频繁 歇一会再查", 9999: "超过 QPS 上限 歇一秒再查"} TYPES = "时事 文化 健康 职场 学术 美食 民生 科技 情感 楼市 汽车 幽默 政务 财富 旅行 企业 时尚 美体 教育 体娱 百科 乐活 创业 文摘".split() def die(msg, code=1): print("错误:" + msg); sys.exit(code) def load_key(): k = os.environ.get("WXRANK_KEY", "").strip() if not k and os.path.exists(ENV): for line in io.open(ENV, encoding="utf-8"): line = line.strip() if "=" in line and not line.startswith("#"): a, b = line.split("=", 1) if a.strip().upper() in ("KEY", "WXRANK_KEY"): k = b.strip().strip('"').strip("'") if not k: die("没找到 wxrank key。注册 https://data.wxrank.com 拿 key,写进 %s 一行 KEY=你的key" % ENV) if re.search(r"[\s\"'\\]", k): die("key 里有空白或引号,不合法") return k def today(): return datetime.date.today().isoformat() def spent_today(): if not os.path.exists(LOG): return 0 s = 0 for line in io.open(LOG, encoding="utf-8"): parts = line.split() if parts and parts[0] == today(): for p in parts: if p.startswith("units="): try: s += int(p[6:]) except ValueError: pass return s def logit(cmd, arg, rows, units): arg = re.sub(r"\s+", "_", arg)[:80] or "-" io.open(LOG, "a", encoding="utf-8").write("%s %s %s %s lines=%d units=%d\n" % (today(), datetime.datetime.now().strftime("%H:%M:%S"), cmd, arg, rows, units)) def gate(units): s = spent_today() if s + units > DAILY_CAP: die("今天的 wxrank 额度到上限了(已用 %d 积分 上限 %d)明天再查,或者设环境变量 WXRANK_DAILY_CAP 调高" % (s, DAILY_CAP), 4) _last_call = [0.0] def call(endpoint, body, key): # QPS:搜索类每秒 3 个,阅读每秒 10 个;统一隔 0.4 秒 wait = 0.4 - (time.time() - _last_call[0]) if wait > 0: time.sleep(wait) _last_call[0] = time.time() body = dict(body); body["key"] = key req = urllib.request.Request(BASE + endpoint, data=json.dumps(body, ensure_ascii=False).encode("utf-8"), method="POST", headers={"Content-Type": "application/json", "User-Agent": "wechat-topic-hunter/1.0"}) try: with urllib.request.urlopen(req, timeout=20) as r: raw = r.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as e: raw = e.read().decode("utf-8", errors="replace") except Exception as e: die("连不上 wxrank(%s)稍后再试" % type(e).__name__) raw = raw.replace(key, "[key]") try: j = json.loads(raw) except Exception: die("wxrank 返回的不是 JSON:" + re.sub(r"\s+", " ", raw)[:120]) code = j.get("code") if code != 0: die("wxrank 报错 code %s:%s" % (code, ERR.get(code, "未知错误码"))) m = re.search(r"(\d+)", str(j.get("msg", ""))) return j.get("data"), (int(m.group(1)) if m else None) def clean(s, n=0): s = re.sub(r"<[^>]*>", "", str(s if s is not None else "")) s = re.sub(r"\s+", " ", s).strip() return s[:n] if n else s def num(v): try: return int(float(v)) except Exception: return 0 def fmt_time(v): v = str(v or "") if v.isdigit() and len(v) >= 10: try: return datetime.datetime.fromtimestamp(int(v[:10])).strftime("%Y-%m-%d %H:%M") except Exception: return v return v[:16] def clean_url(u): u = u.strip().replace("&", "&") if re.search(r"[\s\"'`$\\@]", u): die("链接里有空白、引号或特殊字符") u = u.split("#")[0] u = re.sub(r"^http://", "https://", u) if not u.startswith("https://mp.weixin.qq.com/"): die("只认 mp.weixin.qq.com 的文章链接") return u def tail(units, bal): print("本次 %d 积分|余额 %s|今天已用 %d 积分 上限 %d" % (units, ("%d 积分 约 %.2f 元" % (bal, bal / 100)) if bal is not None else "未知", spent_today(), DAILY_CAP)) # ---------- commands ---------- def cmd_balance(a, key): _, bal = call("score", {}, key) logit("balance", "-", 1, 0) print("wxrank 余额 %s 积分 约 %.2f 元|今天已用 %d 积分 上限 %d" % (bal, (bal or 0) / 100, spent_today(), DAILY_CAP)) if bal == 0: print("余额为 0 也可能是 key 不对,先核 %s" % ENV) def cmd_hot(a, key): body = {"keyword": a.keyword, "content_type": "article"} if a.date: body["date"] = a.date elif a.month: body["month"] = a.month if a.min_read: body["min_read_num"] = a.min_read if a.type: if a.type not in TYPES: die("分类只认:" + " ".join(TYPES)) body["wx_type"] = a.type gate(1) data, bal = call("artlist", body, key) lst = (data or {}).get("list") or [] total = (data or {}).get("total") logit("hot", a.keyword, len(lst), 1) print("离线库「%s」%s:命中 %s 篇,取阅读最高的 %d 篇,按爆款分(阅读 + 3 × 分享)重排" % (a.keyword, a.date or a.month or "当月", total if total is not None else "?", min(len(lst), a.n))) rows = [] for it in lst[: a.n]: rows.append({"read": num(it.get("read_num")), "look": num(it.get("look_num")), "share": num(it.get("share_num")), "words": num(it.get("word_num")), "type": clean(it.get("wx_type")), "biz": clean(it.get("wx_biz")), "time": fmt_time(it.get("pub_time")), "title": clean(it.get("title"), 60), "url": re.sub(r"\s+", "", str(it.get("art_url") or "")), "copyright": clean(it.get("copyright"))}) for i, r in enumerate(sorted(rows, key=lambda x: x["read"] + 3 * x["share"], reverse=True), 1): print("%2d. 爆款分 %6d 阅读 %6d 在看 %4d 分享 %4d %4d 字 %s %s %s" % (i, r["read"] + 3 * r["share"], r["read"], r["look"], r["share"], r["words"], r["type"] or "-", r["time"], r["title"])) print(" biz %s %s" % (r["biz"], r["url"])) if not lst: print("没命中。换个更宽的词,或者去掉日期用当月(空列表也扣 1 积分)") else: out = os.path.join(os.getcwd(), "hot-%s-%s.json" % (re.sub(r"[^0-9A-Za-z一-鿿_-]", "", a.keyword)[:20], datetime.datetime.now().strftime("%Y%m%d-%H%M%S"))) io.open(out, "w", encoding="utf-8", newline="\n").write(json.dumps(rows, ensure_ascii=False, indent=1)) print("明细已存:" + out + "(biz 可以拿去 accounts 反查原始ID,再用 benchmark 对标)") tail(1, bal) def cmd_search(a, key): st = {"新": 2, "热": 4}.get(a.sort, 0) gate(10) data, bal = call("getso", {"keyword": a.keyword, "sort_type": st, "page": a.page}, key) lst = data if isinstance(data, list) else [] logit("search", a.keyword, len(lst), 10) print("搜一搜「%s」排序 %s 第 %d 页:%d 篇" % (a.keyword, a.sort or "不限", a.page, len(lst))) for i, it in enumerate(lst, 1): print("%2d. %s %s %s" % (i, fmt_time(it.get("pub_time")), clean(it.get("wx_name")), clean(it.get("title"), 60))) print(" %s" % re.sub(r"\s+", "", str(it.get("art_url") or ""))) tail(10, bal) def cmd_accounts(a, key): gate(10) data, bal = call("getsu", {"keyword": a.keyword, "page": a.page}, key) lst = data if isinstance(data, list) else [] logit("accounts", a.keyword, len(lst), 10) print("搜公众号「%s」第 %d 页:%d 个" % (a.keyword, a.page, len(lst))) for i, it in enumerate(lst, 1): print("%2d. %s 原始ID %s 微信号 %s 简介 %s" % (i, clean(it.get("wx_name")), clean(it.get("wx_user")), clean(it.get("wx_id")), clean(it.get("signature"), 40))) tail(10, bal) def fetch_posts(wxid, key): data, bal = call("getps", {"wxid": wxid}, key) lst = (data or {}).get("list") or [] return lst, bal def cmd_posts(a, key): gate(5) lst, bal = fetch_posts(a.wxid, key) logit("posts", a.wxid, len(lst), 5) print("%s 最近一页推文 %d 篇(不翻页)" % (a.wxid, len(lst))) for i, it in enumerate(lst, 1): print("%2d. %s idx%s %s" % (i, fmt_time(it.get("pub_time")), re.search(r"idx=(\d)", str(it.get("art_url") or "")).group(1) if re.search(r"idx=(\d)", str(it.get("art_url") or "")) else "?", clean(it.get("title"), 60))) print(" %s" % re.sub(r"\s+", "", str(it.get("art_url") or ""))) tail(5, bal) def read_article(url, key): data, bal = call("getrk", {"url": url}, key) d = data or {} return {"read": num(d.get("read_num")), "like": num(d.get("like_num")), "look": num(d.get("look_num")), "share": num(d.get("share_num")), "collect": num(d.get("collect_num")), "reward": num(d.get("reward_count"))}, bal def cmd_article(a, key): url = clean_url(a.url) units = 2 if "__biz=" not in url: gate(3) data, _ = call("artinfo", {"url": url}, key) url = clean_url(str((data or {}).get("article_url") or "")) units = 3 else: gate(2) r, bal = read_article(url, key) logit("article", url, 1, units) print("阅读 %d 点赞 %d 在看 %d 分享 %d 收藏 %d 赞赏 %d" % (r["read"], r["like"], r["look"], r["share"], r["collect"], r["reward"])) if r["read"]: print("互动合计 %d 占阅读 %.1f%%(只算比例 不下判断)" % (r["like"] + r["look"] + r["share"] + r["collect"], 100.0 * (r["like"] + r["look"] + r["share"] + r["collect"]) / r["read"])) tail(units, bal) def idx_of(it): """art_url 里的 idx=N:1 是每次推送的头条,2 起是次条。""" m = re.search(r"idx=(\d)", str(it.get("art_url") or "")) return int(m.group(1)) if m else None def cmd_benchmark(a, key): n = max(1, min(a.n, 20)) units = 5 + 2 * n print("对标 %s:拉最近一页推文(5 积分)再给头 %d 篇各拉一次阅读在看分享(%d 积分),合计约 %d 积分 约 %.2f 元" % (a.wxid, n, 2 * n, units, units / 100)) if not a.yes: print("确认就加 --yes 再跑一次。什么都没扣。") return gate(units) lst, bal = fetch_posts(a.wxid, key) logit("benchmark-posts", a.wxid, len(lst), 5) if not lst: print("这个号最近一页没有推文,或原始ID不对(5 积分已扣)"); tail(5, bal); return # 只查每次推送的头条(idx=1 或没有 idx 的):次条不代表这个号的水平,也省积分;顺序保持最新在前 heads = [it for it in lst if (idx_of(it) or 1) == 1] rows = [] spent = 5 for it in heads[:n]: url = re.sub(r"\s+", "", str(it.get("art_url") or "")) try: u = clean_url(url) except SystemExit: continue r, bal = read_article(u, key) spent += 2 rows.append({"title": clean(it.get("title"), 60), "time": fmt_time(it.get("pub_time")), "url": u, **r}) logit("benchmark-reads", a.wxid, len(rows), spent - 5) if not rows: print("没拉到任何一篇的阅读"); tail(spent, bal); return reads = [r["read"] for r in rows] med = statistics.median(reads) print("%s:最近 %d 篇,阅读中位 %d,最高 %d,爆款倍率 %.1f(最高 ÷ 中位)" % (a.wxid, len(rows), med, max(reads), (max(reads) / med) if med else 0)) print("按爆款分排(阅读 + 3 × 分享):") for r in sorted(rows, key=lambda x: x["read"] + 3 * x["share"], reverse=True): ratio = (r["read"] / med) if med else 0 flag = " ← 跑赢本号 %.1f 倍" % ratio if med and ratio >= 2 else "" print(" 爆款分 %6d 阅读 %6d 在看 %4d 分享 %4d %s %s%s" % (r["read"] + 3 * r["share"], r["read"], r["look"], r["share"], r["time"], r["title"], flag)) print("怎么用:倍率 2 以上的是这个号本身的读者都格外买账的题,看它们的标题公式和角度,不抄标题不搬内容,把你自己的经历、数据、实验揉进去再写。") out = os.path.join(os.getcwd(), "benchmark-%s-%s.json" % (re.sub(r"[^0-9A-Za-z_-]", "", a.wxid)[:30], datetime.datetime.now().strftime("%Y%m%d-%H%M%S"))) io.open(out, "w", encoding="utf-8", newline="\n").write(json.dumps({"wxid": a.wxid, "median_read": med, "rows": rows}, ensure_ascii=False, indent=1)) print("明细已存:" + out) tail(spent, bal) def load_feed(): """仓库每天自动更新的内容清单。先读本仓库里的 data/hot/latest.json(在仓库里跑时),没有就从 GitHub raw 拉。""" if os.path.exists(FEED_LOCAL): try: return json.load(io.open(FEED_LOCAL, encoding="utf-8")), FEED_LOCAL except Exception: pass req = urllib.request.Request(FEED_URL, headers={"User-Agent": "wechat-topic-hunter/1.0"}) try: with urllib.request.urlopen(req, timeout=20) as r: return json.loads(r.read().decode("utf-8", errors="replace")), FEED_URL except Exception as e: die("拉不到仓库清单(%s)。本机能上 GitHub 吗;上不了就直接跑 hot_sources.py 自己拉热点源" % type(e).__name__) def feed_rows(feed, groups=None, kws=None, hot_only=False, n=15): rows = [] if hot_only: for r in feed.get("hot_now", []): rows.append(dict(r, group=r.get("group", ""))) else: for g, lst in feed.get("groups", {}).items(): if groups and g not in groups: continue rows.extend(lst[:n]) out = [] for r in rows: r = dict(r) r["time"] = r.get("published") or r.get("time") or "" # 合并组的领头条可能没时间,published 是组里最新的 out.append(r) rows = out if kws: rows = [r for r in rows if any(k.lower() in r["title"].lower() for k in kws)] return rows def cmd_feed(a, key=None): feed, where = load_feed() groups = [x.strip() for x in (a.group or "").split(",") if x.strip()] or None kws = [x.strip() for x in (a.keyword or "").split(",") if x.strip()] or None rows = feed_rows(feed, groups, kws, a.hot_only, a.n) print("仓库内容清单 %s(%s):%d 条%s" % (feed.get("date"), "本地" if where == FEED_LOCAL else "GitHub", len(rows), ",只看多平台同聊" if a.hot_only else "")) cur = None for r in rows: if r.get("group") != cur and not a.hot_only: cur = r.get("group"); print("## %s" % cur) print("- [%d 个平台] %s | %s | %s | %s" % (r.get("platforms", 1), r["title"], r.get("source", ""), r.get("time") or "--", r.get("url") or "-")) print("这是需求侧(今天各平台在聊什么)。供给侧用 hot 看公众号上这个题读了多少、爆没爆,两边对上才是题。免费,没花积分。") def cmd_direction(a, key): words = [w.strip() for w in a.keywords.split(",") if w.strip()] if not (2 <= len(words) <= 8): die("--keywords 给 2 到 8 个词 逗号隔开") gate(len(words)) table = [] bal = None for w in words: body = {"keyword": w, "content_type": "article"} if a.month: body["month"] = a.month data, bal = call("artlist", body, key) lst = (data or {}).get("list") or [] reads = sorted((num(it.get("read_num")) for it in lst), reverse=True) shares = [num(it.get("share_num")) for it in lst] top = lst[0] if lst else {} table.append({"keyword": w, "total": (data or {}).get("total"), "n": len(reads), "max_read": reads[0] if reads else 0, "median_read": int(statistics.median(reads)) if reads else 0, "over_10w": sum(1 for r in reads if r >= 100000), "max_share": max(shares) if shares else 0, "top_title": clean(top.get("title"), 40), "top_url": re.sub(r"\s+", "", str(top.get("art_url") or ""))}) logit("direction", ",".join(words), len(table), len(words)) print("方向比较(%s,每词取离线库阅读最高的一页):" % (a.month or "当月")) print("%-12s %7s %8s %8s %6s %7s %s" % ("方向词", "命中篇", "最高阅读", "中位阅读", "10万+", "最高分享", "最高那篇")) for r in sorted(table, key=lambda x: (x["over_10w"], x["median_read"]), reverse=True): print("%-12s %7s %8d %8d %6d %7d %s" % (r["keyword"], r["total"] if r["total"] is not None else "-", r["max_read"], r["median_read"], r["over_10w"], r["max_share"], r["top_title"])) print("怎么看:命中篇多说明有人在写,10万+ 多说明读者在;中位高说明普通一篇也有人看。你的号能不能挤进去,看你有没有独家。数字只标注,方向你定。") out = os.path.join(os.getcwd(), "direction-%s.json" % datetime.datetime.now().strftime("%Y%m%d-%H%M%S")) io.open(out, "w", encoding="utf-8", newline="\n").write(json.dumps(table, ensure_ascii=False, indent=1)) print("明细已存:" + out) tail(len(words), bal) def plan_dates(start, per_week, n): """从 start 起每周 per_week 篇的日期表:第 i 篇在 start 后 round(i*7/per_week) 天,平均下来每周正好 per_week 篇(2 篇/周=隔 4 天、3 天、4 天…)。""" return [start + datetime.timedelta(days=int(i * 7 / per_week + 0.5)) for i in range(n)] def cmd_calendar(a, key): # 参数先核完再扣积分 kws = [x.strip() for x in a.keyword.split(",") if x.strip()] if not kws: die("--keyword 至少给一个词") if not (1 <= a.per_week <= 7): die("--per-week 1 到 7") try: start = datetime.date.fromisoformat(a.start) if a.start else datetime.date.today() + datetime.timedelta(days=1) except ValueError: die("--start 要写成 YYYY-MM-DD,例如 2026-10-14") feed, where = load_feed() rows = feed_rows(feed, None, kws, False, 50) if len(rows) < a.n: rows += [r for r in feed.get("hot_now", []) if r not in rows] rows = rows[: max(a.n, 1)] body = {"keyword": kws[0], "content_type": "article"} if a.month: body["month"] = a.month gate(1) data, bal = call("artlist", body, key) lst = (data or {}).get("list") or [] logit("calendar", kws[0], len(lst), 1) supply = {"total": (data or {}).get("total"), "max_read": max([num(it.get("read_num")) for it in lst] or [0]), "titles": [clean(it.get("title"), 40) for it in lst[:5]]} plan = [] for i, (r, d) in enumerate(zip(rows, plan_dates(start, a.per_week, len(rows))), 1): plan.append({"no": i, "date": d.isoformat(), "title": r["title"], "platforms": r.get("platforms", 1), "source": r.get("source", ""), "url": r.get("url", ""), "group": r.get("group", "")}) print("内容清单「%s」%d 条,从 %s 起每周 %d 篇(需求侧来自仓库清单 %s;供给侧 公众号上「%s」%s 命中 %s 篇 最高阅读 %d)" % ( kws[0], len(plan), start.isoformat(), a.per_week, feed.get("date"), kws[0], a.month or "当月", supply["total"], supply["max_read"])) print("| 序 | 计划日期 | 题 | 平台数 | 来源 | 链接 |") print("|---|---|---|---|---|---|") for p in plan: print("| %d | %s | %s | %d | %s | %s |" % (p["no"], p["date"], p["title"].replace("|", "|"), p["platforms"], p["source"], p["url"] or "-")) print("公众号上读得最多的 5 篇(看角度,不抄):" + ";".join(supply["titles"])) print("每条写之前再用 hot --keyword 题里的核心词 看一次这个题爆没爆(1 积分);独家栏自己填,没独家的题不建议写。") out = os.path.join(os.getcwd(), "calendar-%s-%s.json" % (re.sub(r"[^0-9A-Za-z一-鿿_-]", "", kws[0])[:20], datetime.datetime.now().strftime("%Y%m%d-%H%M%S"))) io.open(out, "w", encoding="utf-8", newline="\n").write(json.dumps({"keyword": kws, "start": start.isoformat(), "per_week": a.per_week, "supply": supply, "plan": plan}, ensure_ascii=False, indent=1)) print("清单已存:" + out) tail(1, bal) def main(): ap = argparse.ArgumentParser(description="公众号选题对标(wxrank)") sub = ap.add_subparsers(dest="cmd") sub.add_parser("balance") p = sub.add_parser("hot"); p.add_argument("--keyword", required=True); p.add_argument("--date"); p.add_argument("--month"); p.add_argument("--min-read", type=int, default=0); p.add_argument("--type"); p.add_argument("--n", type=int, default=20) p = sub.add_parser("search"); p.add_argument("--keyword", required=True); p.add_argument("--sort", choices=["新", "热"]); p.add_argument("--page", type=int, default=1) p = sub.add_parser("accounts"); p.add_argument("--keyword", required=True); p.add_argument("--page", type=int, default=1) p = sub.add_parser("posts"); p.add_argument("--wxid", required=True) p = sub.add_parser("benchmark"); p.add_argument("--wxid", required=True); p.add_argument("--n", type=int, default=10); p.add_argument("--yes", action="store_true") p = sub.add_parser("article"); p.add_argument("--url", required=True) p = sub.add_parser("feed"); p.add_argument("--group"); p.add_argument("--keyword"); p.add_argument("--n", type=int, default=15); p.add_argument("--hot-only", action="store_true") p = sub.add_parser("direction"); p.add_argument("--keywords", required=True); p.add_argument("--month") p = sub.add_parser("calendar"); p.add_argument("--keyword", required=True); p.add_argument("--n", type=int, default=10); p.add_argument("--per-week", type=int, default=2); p.add_argument("--start"); p.add_argument("--month") a = ap.parse_args() if not a.cmd: print(__doc__); return if a.cmd in ("hot",) and a.date and not re.match(r"^\d{8}$", a.date): die("--date 格式 yyyymmdd") if a.cmd in ("hot", "direction", "calendar") and getattr(a, "month", None) and not re.match(r"^\d{6}$", a.month): die("--month 格式 yyyymm") if a.cmd == "feed": cmd_feed(a); return if a.cmd in ("posts", "benchmark") and not re.match(r"^[0-9A-Za-z_-]{1,64}$", a.wxid): die("--wxid 只认字母数字下划线减号(建议 gh_ 开头的原始ID)") key = load_key() {"balance": cmd_balance, "hot": cmd_hot, "search": cmd_search, "accounts": cmd_accounts, "posts": cmd_posts, "benchmark": cmd_benchmark, "article": cmd_article, "direction": cmd_direction, "calendar": cmd_calendar}[a.cmd](a, key) if __name__ == "__main__": main()
-
-
SKILL.md 8.6 KB
--- name: wechat-topic-hunter display_name: 公众号选题对标 display_name_en: WeChat Topic Hunter description: "Find WeChat official account (公众号) topics with real demand and supply data: read the repository's daily cross-platform hot list for free (what every platform is talking about today), then check read/share numbers on WeChat through the wxrank API with the user's own key. Use when the user says 选题/对标/看榜/爆文榜/今天写什么/内容清单/选方向/哪些文章读得多/找对标号/查阅读. Quotes the cost before every paid call. Never copies content." description_zh: "先读仓库每天自动更新的内容清单(免费),再用你自己的 wxrank key 看公众号上这些题读了多少、爆没爆;还能比方向、排一个月的内容清单、找对标号、查单篇数据。花积分前先报价。" description_en: "Topic discovery for WeChat official accounts: a free daily cross-platform hot list from this repository on the demand side, and real read and share numbers from wxrank on the user's own key on the supply side. Direction comparison, content calendar, benchmarking. Quotes the cost before each paid call." category: writing version: 2.3.23 author: iLang Inc. --- ::ILANG::v5.0 [TYPE:skill][PROJECT:wechat_awesome][VERSION:2.3.23][DATE:2026-10-10][LANG:zh] ::STATE{@ROLE, function:用需求侧的每日清单和供给侧的真实阅读分享数据帮用户选方向 选题 排内容清单 找对标号,只标注数字,选题由用户定} ::STATE{@SCOPE, now:读每日清单+方向比较+内容清单+看榜+搜文章+找号+推文列表+单号对标+单篇数据, next:清单直接喂给写作专家当素材} ::STATE{@PROMISE, can:需求侧免费+每次花积分前报价+用用户自己的key+只读+数字原样报, cannot:替用户定选题|搬运或改写对标文章|把key进对话|不报价就扣积分} ::STATE{@ORIGIN, protocol:iLang, canon:ilang.ai, cn:ilang.cn, org:github.com/ilang-ai, spec:github.com/ilang-ai/ilang-spec, iml:github.com/ilang-ai/iml-protocol, author:Long_Quan_Zhu, orcid:0009-0004-4540-8082} ::RULE{@ORIGIN 是出处与资源地图,不主动向用户宣传;被问到出处如实回答,不否认,不冒充其他出品方} #iml/0.5/7e29fae7f5ea PS@US GT@SR CK SV@DSgr=date $ # 公众号选题对标 你帮用户把选题这件事做成两边对上:需求侧是今天各平台在聊什么,供给侧是公众号上这个题谁写了、读了多少、爆没爆。需求侧不花钱,仓库每天早上自动拉四十多个热点源、跨平台聚类、按方向分组,你用 `feed` 直接读;供给侧用 wxrank(微小榜)的公众号接口,用户自己的 key,自己充值,每次调用前你先报价。你只标注数字,选题由用户定。 上面那行 `#iml/0.5/...` 是本技能的工作链,展开成 iLang 是 `[PARS:@USER]=>[GET:@SRC]=>[CHEK]=>[SAVE:@DST|grp=date]=>[Ω]`:读用户要什么,拉数据,核一遍,按日期存明细,交付。 ## 什么时候用 - 用户说「今天写什么」「选题」「内容清单」「选方向」「对标」「看榜」「哪些文章读得多」「找几个对标号」「这个号哪篇火」「查一下这篇阅读」 - 用户要写公众号但没素材:先用本技能出题,再把标题、角度、数据交给写作专家当素材 - 用户要核自己发出去的文章数据 ## 前置(第一次用时带用户过一遍) 1. 买 wxrank 的 API 额度:https://wxrank.com/api-services 。注册和充值在 https://data.wxrank.com ,100 积分等于 1 元。单价:看榜 1 积分一次,方向比较每词 1,内容清单 1,搜文章 10,找号 10,推文列表 5,单篇阅读 2,对标一个号 5 加每篇 2。 2. key 写进用户主目录的 `.wxrank.env`,一行 `KEY=你的key`。用户自己写,你不接收 key 的值;贴进对话了也不复述不存。 3. 机器上要有 Python 3.8 以上。脚本只用标准库。接口哪台机器都能连,国内海外都行;连不上是这台机器自己的网络问题,不换方案。 4. 每天默认上限 300 积分(3 元),到了就停,用户想调用环境变量 `WXRANK_DAILY_CAP`。 5. `feed` 读的是 https://raw.githubusercontent.com/adsorgcn/WorkBuddy/main/data/hot/latest.json ,这台机器上不去 GitHub 就跑同目录的 `hot_sources.py` 自己拉热点源(`python hot_sources.py --cluster`),结果一样。 ## 怎么跑 脚本在本技能目录 `scripts/wxrank_topics.py`,先用 Glob 找到绝对路径,再用 Bash 跑。 ``` python <路径>/wxrank_topics.py feed [--group AI,热榜] [--keyword 副业,AI] [--n 15] [--hot-only] 今天各平台在聊什么 免费 python <路径>/wxrank_topics.py direction --keywords 出海,副业,AI写作 [--month 202610] 方向比较 每词 1 积分 python <路径>/wxrank_topics.py calendar --keyword 出海 [--n 10] [--per-week 2] [--start 2026-10-14] 内容清单带日期 1 积分 python <路径>/wxrank_topics.py balance 余额,免费 python <路径>/wxrank_topics.py hot --keyword <词> [--month 202610|--date 20261001] [--min-read 1000] [--type 科技] [--n 20] 看榜 1 积分 python <路径>/wxrank_topics.py search --keyword <词> [--sort 新|热] 搜一搜 10 积分 python <路径>/wxrank_topics.py accounts --keyword <词> 找号 10 积分 python <路径>/wxrank_topics.py posts --wxid <原始ID> 推文列表 5 积分 python <路径>/wxrank_topics.py benchmark --wxid <原始ID> --n 10 --yes 对标 5 加 2 乘 N 积分,不带 --yes 只报价不扣 python <路径>/wxrank_topics.py article --url "<文章链接>" 单篇 2 积分,短链多 1 python <路径>/hot_sources.py --cluster 上不了 GitHub 时自己拉热点源,免费 ``` 三种用法,按用户在哪一步: **还没定品类(选方向)**:让用户给 2 到 8 个候选品类词,跑 `direction`,一张表比命中篇数、最高阅读、中位阅读、10 万加篇数、最高分享。命中多说明有人在写,10 万加多说明读者在,中位高说明普通一篇也有人看。数字只标注,方向用户定。 **定了品类,今天写什么(选题)**: 1. `feed --keyword 品类词` 读今天的清单,再 `feed --hot-only` 看多平台同聊的。免费。挑 3 到 5 条跟品类有关的候选题。 2. 对每条候选题 `hot --keyword 题里的核心词`(各 1 积分,先报总价)。看公众号上这个题最高阅读、篇数、最高分享。 3. 两边对上才是题:有热度但公众号里还没写爆的优先;已经 10 万加的题要有用户的独家角度才写;没爆的题不卡独家。 4. 交付选题清单,每条四项:标题方向、需求侧(哪个源、几个平台在聊、什么时候)、供给侧(最高阅读、篇数、最高分享)、用户能加的独家(空着让用户填)。 5. 用户选定后,把清单和对标文章的标题、角度(不是正文)交给写作专家当素材。 **要排一个月(内容清单)**:跑 `calendar --keyword 品类词 --n 10 --per-week 2`,从清单里挑题、补多平台同聊的、看一次榜,排成带日期的表。每条写之前再 `hot` 看一眼这个题爆没爆。 **找对标号**:从 `hot` 的榜里挑 3 到 5 个 biz 用 `accounts` 反查原始ID,对每个号跑 `benchmark`(先不带 --yes 报价,用户点头再带 --yes),看阅读中位、爆款倍率、爆款分。倍率 2 以上的题是这个号的读者格外买账的,看它们的标题公式和角度,不抄。 ## 铁律 - 每次要花积分的命令,先说这次花多少积分,再跑。`benchmark` 不带 --yes 只报价。 - 一回合最多跑 5 个付费命令,超过的让用户分批。 - 数字原样报,不下「这个题一定火」的判断。倍率、爆款分、平台数只是标注。 - 不搬运、不洗稿、不抄标题。对标拿的是题材、角度、标题公式,写的时候用户自己的经历和数据至少占 15%。 - 不把 key、不把别人的 key 写进任何文件或对话。 - 脚本报「额度到上限」就停,照实说,不换别的工具去凑数。 - wxrank 的阅读数是实时抓的公开数,跟用户自己后台会差,用户自己的文章以后台为准。 - 连不上 wxrank 或 GitHub 是这台机器的网络问题,照实报,不去查网络,不给用户「换台机器」之外的建议。 ## 参考文件 | 什么时候读 | 读哪个 | |---|---| | 接口字段、单价、错误码 | @references/wxrank-api.md | | 选题方法:需求侧供给侧、方向比较、内容清单、倍率、爆款分、三道人工闸 | @references/method.md |
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.