Skill: cellinlab/podcast-radar-cn (version 0.1.0)

--- name: podcast-radar-cn description: Discover, compare, and curate trending Chinese podcasts or episodes from 中文播客榜. Use for hot or recent show discovery, creator benchmarking, curation lists, competitor research, or podcast distribution leads. Prefer ranking fields and title signals first, and only do small-scale Xiaoyuzhou enrichment when needed. homepage: https://github.com/XiaohuoluFM/xhlfm-skills/tree/main/skills/podcast-radar-cn metadata: {"openclaw":{"emoji":"🎙️","homepage":"https://github.com/XiaohuoluFM/xhlfm-skills/tree/main/skills/podcast-radar-cn","requires":{"anyBins":["python3","python"]},"install":[{"id":"brew-python","kind":"brew","formula":"python","bins":["python3"],"label":"Install Python 3 (brew)","os":["darwin"]}]}} --- # Podcast Radar CN Use this skill when the user wants to discover, compare, or curate Chinese podcasts or podcast episodes based on current ranking data. This skill is strongest at: - listener discovery - creator benchmarking - curation and distribution research It is not a general web scraping skill. Its default posture is ranking-first and title-first. ## Skill Root Rule Treat the directory containing this `SKILL.md` as the skill root. - In OpenClaw, prefer `{baseDir}` when calling files inside this skill. - In other hosts or manual installs, use the same paths relative to the skill root, for example `scripts/fetch_xyz_rank.py`. - Examples below use `python3`; if the host only exposes `python`, swap the binary name. ## Quick Start Choose one of the four ranking lists: - `hot-episodes` - `hot-podcasts` - `new-podcasts` - `new-episodes` Fetch a candidate set first: ```bash python3 {baseDir}/scripts/fetch_xyz_rank.py --list hot-episodes --limit 20 ``` Filter by genre, freshness, or query when the user already has a direction: ```bash python3 {baseDir}/scripts/fetch_xyz_rank.py \ --list new-episodes \ --limit 12 \ --genre 社会与文化 \ --freshness-days-max 30 ``` If and only if you truly need extra context for a small set of candidates, enrich a few Xiaoyuzhou URLs: ```bash python3 {baseDir}/scripts/enrich_xiaoyuzhou.py \ --episode-url https://www.xiaoyuzhoufm.com/episode/69bf524c2d318777c9169361 ``` ## Workflow 1. Classify the request as one of: - listener discovery - creator benchmarking - curation/distribution research 2. Interpret the user's wording before choosing the list: - if the user says `热门播客`, `最近播客`, `值得听的播客`, or similar casual Chinese phrasing, default to episode-level results - if the user explicitly asks for `播客频道`, `播客栏目`, `节目主页`, `show-level`, `频道级`, or wants a creator benchmark list, use podcast-level results 3. Pick the most relevant ranking list. - default listener-discovery wording usually maps to `hot-episodes` or `new-episodes` - explicit channel / show / benchmark wording usually maps to `hot-podcasts` or `new-podcasts` 4. Fetch enough candidates to support filtering; do not stop at the first 5 unless the user asked for 5. 5. Use ranking fields and title signals first. 6. Only if the answer would otherwise be weak, enrich a small set of Xiaoyuzhou pages. 7. Return a task-shaped result, not raw JSON. ## Query Interpretation Rule In Chinese product usage, users often say `播客` when they really mean `最近值得点开的内容`. Default behavior: - `热门播客` - `最近热门播客` - `最近值得听的播客` - `推荐几个热门播客` Treat these as requests for episode-level recommendations unless the wording clearly asks for show/channel-level objects. Switch to podcast-level results only when the user explicitly asks for things like: - `播客频道` - `播客栏目` - `播客节目主页` - `频道级榜单` - `栏目级榜单` - show-level benchmark or channel list When in doubt: - listener-oriented wording -> episode-first - benchmark / channel / host / show-portfolio wording -> podcast-first ## Title-First Rule Before reaching for Xiaoyuzhou pages, inspect: - `title` - `podcastName` / `name` - `primaryGenreName` - `rank` - `playCount` / `avgPlayCount` - `commentCount` / `avgCommentCount` - freshness fields The fetch script also extracts title signals such as: - episode markers like `S8E9`, `EP03`, `Vol132` - guest hints like `A×B`, `对话某某` - format hints like `对谈`, `访谈`, `复盘`, `盘点` - topic keywords inferred from the title If those signals are enough, do not enrich. Read [references/title-signals.md](references/title-signals.md) when you need examples or interpretation guidance. ## Xiaoyuzhou Enrichment Rule Xiaoyuzhou enrichment is allowed only for narrow, necessary follow-up work. Hard rules: - never bulk-enrich by default - never enrich just to complete every field - never enrich more than 20 URLs in one run - if a request implies short-window bulk access, refuse enrichment and stay at ranking level Use enrichment when: - a short list needs better recommendation reasons - you need the real Xiaoyuzhou `pid` from an episode page - you need a podcast brief or episode description for a handful of finalists The enrichment script enforces the cap for you. ## Output Modes Use one of these result shapes: - Listener Discovery: what episode is worth hearing now, and why - Creator Benchmarking: which shows are worth studying or comparing against - Curation and Distribution: which shows or episodes are worth packaging, recommending, or developing into downstream ideas Read [references/output-modes.md](references/output-modes.md) when you need concrete formatting guidance. ## Data Caveats - 中文播客榜 is weekly, not minute-by-minute real time - the data is not full-platform coverage - `primaryGenreName` is often missing on new lists - `openRate` / `avgOpenRate` can exceed `1`; treat these as internal ranking signals, not literal rates Read [references/api.md](references/api.md) when you need field notes or endpoint details. ## Scripts - `scripts/fetch_xyz_rank.py` Purpose: fetch and normalize ranking data, with basic filters and title-signal extraction - `scripts/enrich_xiaoyuzhou.py` Purpose: enrich a small set of Xiaoyuzhou pages with brief/description/shownotes context, under a strict cap ## What To Avoid - dumping raw fields without synthesis - pretending the ranking is a real-time universal truth - using Xiaoyuzhou enrichment for large-page traversal - over-explaining `openRate` as if it were a clean probability

path: agents/openai.yaml
content: interface:
  display_name: "中文播客雷达"
  short_description: "发现热门与新锐中文播客，适合听众、创作者和分发策展"
  default_prompt: "Use $podcast-radar-cn to discover, compare, and curate trending or rising Chinese podcasts and episodes from 中文播客榜. In casual Chinese requests, interpret '热门播客' as episode-level recommendations by default, and only switch to podcast/show-level results when the user explicitly asks for a channel, column, show page, or benchmark list."


path: references/api.md
content: # API Notes

## Ranking Endpoints

The skill relies on four ranking endpoints from 中文播客榜:

- `https://xyzrank.com/api/episodes`
- `https://xyzrank.com/api/podcasts`
- `https://xyzrank.com/api/new-episodes`
- `https://xyzrank.com/api/new-podcasts`

They all support:

- `offset`
- `limit`

Typical shape:

```json
{
  "items": [],
  "total": 1000,
  "limit": 50,
  "offset": 0
}
```

## Hot vs New

- `hot-*`: better when the user wants current winners, stable attention leaders, or reference shows
- `new-*`: better when the user wants rising programs, fresh discoveries, or new entrants

## Podcasts vs Episodes

- `*-podcasts`: better for show-level discovery, creator benchmarking, and curation lists
- `*-episodes`: better for single-item recommendations, topic scouting, and downstream distribution ideas

## User Language Mapping

In Chinese, users often say `播客` loosely when they actually mean `最近值得点开的内容`.

Default interpretation:

- `热门播客`
- `最近热门播客`
- `推荐几个最近很火的播客`

Map these to `*-episodes` first unless the user clearly asks for show/channel-level entities.

Use `*-podcasts` when the wording explicitly asks for:

- `播客频道`
- `播客栏目`
- `节目主页`
- `频道级榜单`
- `栏目级榜单`
- benchmark lists of shows

Rule of thumb:

- listener discovery wording -> episodes
- channel / show / benchmark wording -> podcasts

## Useful Episode Fields

- `rank`
- `title`
- `podcastID`
- `podcastName`
- `primaryGenreName`
- `playCount`
- `commentCount`
- `subscription`
- `duration`
- `postTime`
- `link`
- `lastReleaseDateDayCount`

## Useful Podcast Fields

- `rank`
- `id`
- `name`
- `primaryGenreName`
- `authorsText`
- `trackCount`
- `avgDuration`
- `avgPlayCount`
- `avgCommentCount`
- `avgOpenRate`
- `lastReleaseDateDayCount`
- `links`

## Field Caveats

### Genre

`primaryGenreName` is useful but incomplete, especially on `new-*` lists. When it is blank:

- treat it as `未分类`
- do not invent a hard genre unless the title makes it obvious and the claim is clearly framed as an inference

### Open Rate

`openRate` and `avgOpenRate` can be greater than `1` in live data.

Do not present them as literal open rates or probabilities. If you mention them at all:

- call them ranking signals
- keep the interpretation light

### podcastID vs Xiaoyuzhou pid

For episodes:

- ranking `podcastID` aligns with the ranking dataset
- it is not the same as Xiaoyuzhou `pid`

If you need the Xiaoyuzhou podcast URL and only have an episode URL:

1. enrich the episode page
2. read `episode.pid`
3. derive `https://www.xiaoyuzhoufm.com/podcast/<pid>`

## Xiaoyuzhou Enrichment

When enriching a small set of pages, you can pull from `#__NEXT_DATA__`.

Useful episode fields:

- `episode.description`
- `episode.shownotes`
- `episode.duration`
- `episode.playCount`
- `episode.commentCount`
- `episode.favoriteCount`
- `episode.clapCount`
- `episode.pubDate`
- `episode.pid`
- nested `episode.podcast`

Useful podcast fields:

- `podcast.title`
- `podcast.author`
- `podcast.brief`
- `podcast.description`
- `podcast.subscriptionCount`
- `podcast.episodeCount`
- `podcast.latestEpisodePubDate`
- `podcast.podcasters`

Remember: enrichment is optional and capped. Ranking-first remains the default.


path: references/output-modes.md
content: # Output Modes

Use these patterns to turn ranking data into user-facing results.

## Listener Discovery

Use when the user wants to know what is worth hearing now.

Unless the user explicitly asks for a channel, column, or show page, default this mode to episode-level results even if the user says `热门播客`.

Recommended shape:

1. One short takeaway
2. A ranked or lightly curated list
3. Each item should include:
   - episode name first, or show name only when channel-level wording is explicit
   - genre
   - one or two concrete reasons
   - link

Good reasons:

- strong current ranking
- fresh recent release
- clear topic fit for the user's interest
- recognizable guest or hook from the title

## Creator Benchmarking

Use when the user wants shows to study, compare against, or learn from.

This mode is usually show-level and therefore a good fit for `*-podcasts`.

Recommended shape:

1. A short benchmark summary
2. A list of shows
3. Each item should include:
   - show name
   - genre / host
   - freshness
   - attention signal
   - why it is relevant as a benchmark

Benchmark angles:

- title style
- guest strategy
- publishing cadence
- audience size proxy
- comment intensity proxy

## Curation and Distribution

Use when the user wants a list to recommend, package, or adapt downstream.

If the user is curating “what to hear now”, prefer episodes first.
If the user is curating “which channels to follow”, prefer podcasts.

Recommended shape:

1. A one-paragraph curation angle
2. A list of shows or episodes
3. Each item should include:
   - title
   - podcast name
   - why it fits the brief
   - what makes it easy to recommend or expand on
   - original link

Helpful framing:

- who it is for
- when to listen
- what makes it travel well as a recommendation
- what downstream use it could support, such as a roundup, reading list, or topic packet

## What Not To Do

- do not dump API fields with no editorial help
- do not overclaim that a single ranking equals quality
- do not pretend missing genre data is precise
- do not fetch extra details unless the shortlist is already small


path: references/title-signals.md
content: # Title Signals

The first pass should learn as much as possible from titles before enriching any Xiaoyuzhou page.

## Episode Markers

Look for:

- `S8E9`
- `EP03`
- `Vol132`
- `第 14 期`

These help estimate:

- maturity of the show
- editorial consistency
- whether the user is looking at a flagship or an early-stage program

## Guest Hints

Strong guest clues often appear as:

- `A×B`
- `对话某某`
- `对谈某某`
- `with 某某`
- a trailing guest name after punctuation

Treat them as hints, not guarantees.

## Format Hints

Common format cues:

- `对话`
- `对谈`
- `访谈`
- `复盘`
- `盘点`
- `问答`
- `故事`
- `观察`
- `独白`

These help decide whether a result suits listening, benchmarking, or distribution work.

## Topic Keywords

The script extracts simple topic cues from the title. Common useful buckets include:

- AI / 科技
- 商业 / 创业 / 投资
- 职场 / 管理
- 社会 / 文化 / 历史
- 喜剧 / 幽默
- 教育 / 健康 / 情感
- 女性 / 国际 / 媒体 / 电影 / 音乐

These are lightweight hints, not a replacement for full semantic classification.

## Practical Rule

If the title, genre, rank, and a few metrics already answer the user's question, stop there.

Use enrichment only when the answer would otherwise feel thin or ambiguous.


path: scripts/enrich_xiaoyuzhou.py
content: #!/usr/bin/env python3
"""Enrich a small set of Xiaoyuzhou URLs under a strict cap."""

from __future__ import annotations

import argparse
import json
import random
import re
import sys
import time
import zlib
from html import unescape
from html.parser import HTMLParser
from typing import Any, Dict, Iterable, List
from urllib.request import Request, urlopen

USER_AGENT = "podcast-radar-cn/0.1 (+https://github.com/XiaohuoluFM/xhlfm-skills)"
NEXT_DATA_PATTERN = re.compile(
    rb'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
    re.DOTALL,
)
PODCAST_URL_TEMPLATE = "https://www.xiaoyuzhoufm.com/podcast/{pid}"
READ_CHUNK_SIZE = 16384
MAX_READ_BYTES = 512000


class TextExtractor(HTMLParser):
    BLOCK_TAGS = {"p", "div", "section", "figure", "figcaption", "li", "ul", "ol", "h1", "h2", "h3", "h4"}

    def __init__(self) -> None:
        super().__init__()
        self.parts: List[str] = []

    def handle_starttag(self, tag: str, attrs: List[tuple[str, str | None]]) -> None:
        if tag == "br":
            self.parts.append("\n")

    def handle_endtag(self, tag: str) -> None:
        if tag in self.BLOCK_TAGS:
            self.parts.append("\n")

    def handle_data(self, data: str) -> None:
        self.parts.append(data)

    def text(self) -> str:
        text = unescape("".join(self.parts))
        text = re.sub(r"[ \t]+\n", "\n", text)
        text = re.sub(r"\n{3,}", "\n\n", text)
        return text.strip()


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Enrich a small number of Xiaoyuzhou episode or podcast pages. Hard-capped to 20 URLs."
    )
    parser.add_argument("--episode-url", action="append", default=[], help="Episode URL to enrich. May be repeated.")
    parser.add_argument("--podcast-url", action="append", default=[], help="Podcast URL to enrich. May be repeated.")
    parser.add_argument(
        "--from-json",
        help="Path to JSON from fetch_xyz_rank.py. Use '-' to read from stdin.",
    )
    parser.add_argument(
        "--max-items",
        type=int,
        default=20,
        help="Hard cap on URLs to enrich in one run. Default: 20.",
    )
    parser.add_argument(
        "--sleep-seconds",
        type=float,
        default=1.2,
        help="Base delay between sequential requests.",
    )
    parser.add_argument(
        "--jitter-seconds",
        type=float,
        default=0.4,
        help="Random extra delay added per request.",
    )
    parser.add_argument(
        "--pretty",
        action="store_true",
        help="Pretty-print the JSON output.",
    )
    return parser.parse_args()


def fetch_next_data(url: str) -> Dict[str, Any]:
    request = Request(
        url,
        headers={
            "User-Agent": USER_AGENT,
            "Accept": "text/html,application/xhtml+xml",
            "Accept-Encoding": "gzip",
            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
        },
    )
    with urlopen(request, timeout=30) as response:
        content_encoding = (response.getheader("Content-Encoding") or "").lower()
        decompressor = zlib.decompressobj(16 + zlib.MAX_WBITS) if "gzip" in content_encoding else None
        chunks: List[bytes] = []
        total_read = 0
        while total_read < MAX_READ_BYTES:
            raw_chunk = response.read(READ_CHUNK_SIZE)
            if not raw_chunk:
                break
            total_read += len(raw_chunk)
            chunk = decompressor.decompress(raw_chunk) if decompressor else raw_chunk
            if chunk:
                chunks.append(chunk)
            payload = extract_next_data_from_html_bytes(b"".join(chunks))
            if payload is not None:
                return payload

    raise ValueError("Could not find __NEXT_DATA__ within the read limit.")


def extract_next_data_from_html_bytes(html: bytes) -> Dict[str, Any] | None:
    match = NEXT_DATA_PATTERN.search(html)
    if not match:
        return None
    return json.loads(match.group(1).decode("utf-8"))


def load_json_payload(path: str) -> Any:
    if path == "-":
        return json.load(sys.stdin)
    with open(path, "r", encoding="utf-8") as handle:
        return json.load(handle)


def dedupe_preserve(items: Iterable[str]) -> List[str]:
    seen = set()
    result: List[str] = []
    for item in items:
        value = item.strip()
        if not value or value in seen:
            continue
        seen.add(value)
        result.append(value)
    return result


def extract_urls_from_payload(payload: Any) -> List[str]:
    if isinstance(payload, list):
        items = payload
    elif isinstance(payload, dict):
        items = payload.get("items", [])
    else:
        return []

    urls: List[str] = []
    for item in items:
        if isinstance(item, str):
            urls.append(item)
            continue
        if not isinstance(item, dict):
            continue

        if item.get("kind") == "episode" and item.get("episodeUrl"):
            urls.append(item["episodeUrl"])
            continue
        if item.get("kind") == "podcast" and item.get("xyzUrl"):
            urls.append(item["xyzUrl"])
            continue
        if item.get("episodeUrl"):
            urls.append(item["episodeUrl"])
            continue
        if item.get("xyzUrl"):
            urls.append(item["xyzUrl"])
            continue
        links = item.get("links")
        if isinstance(links, dict) and links.get("xyz"):
            urls.append(links["xyz"])
    return urls


def html_to_text(value: Any) -> str | None:
    if not value:
        return None
    if not isinstance(value, str):
        return str(value)
    extractor = TextExtractor()
    extractor.feed(value)
    return extractor.text() or None


def enrich_episode(url: str, episode: Dict[str, Any]) -> Dict[str, Any]:
    podcast = episode.get("podcast") or {}
    pid = episode.get("pid")
    return {
        "kind": "episode",
        "url": url,
        "eid": episode.get("eid"),
        "pid": pid,
        "title": episode.get("title"),
        "description": episode.get("description"),
        "shownotesText": html_to_text(episode.get("shownotes")),
        "durationSeconds": episode.get("duration"),
        "playCount": episode.get("playCount"),
        "commentCount": episode.get("commentCount"),
        "favoriteCount": episode.get("favoriteCount"),
        "clapCount": episode.get("clapCount"),
        "pubDate": episode.get("pubDate"),
        "podcastUrl": PODCAST_URL_TEMPLATE.format(pid=pid) if pid else None,
        "podcast": {
            "title": podcast.get("title"),
            "author": podcast.get("author"),
            "brief": podcast.get("brief"),
            "description": podcast.get("description"),
            "subscriptionCount": podcast.get("subscriptionCount"),
            "episodeCount": podcast.get("episodeCount"),
        },
    }


def enrich_podcast(url: str, podcast: Dict[str, Any]) -> Dict[str, Any]:
    podcasters = podcast.get("podcasters") or []
    recent_episodes = podcast.get("episodes") or []
    return {
        "kind": "podcast",
        "url": url,
        "pid": podcast.get("pid"),
        "title": podcast.get("title"),
        "author": podcast.get("author"),
        "brief": podcast.get("brief"),
        "description": podcast.get("description"),
        "subscriptionCount": podcast.get("subscriptionCount"),
        "episodeCount": podcast.get("episodeCount"),
        "latestEpisodePubDate": podcast.get("latestEpisodePubDate"),
        "podcasters": [
            {"uid": podcaster.get("uid"), "nickname": podcaster.get("nickname")}
            for podcaster in podcasters[:5]
        ],
        "recentEpisodes": [
            {
                "eid": episode.get("eid"),
                "title": episode.get("title"),
                "pubDate": episode.get("pubDate"),
                "playCount": episode.get("playCount"),
            }
            for episode in recent_episodes[:5]
        ],
    }


def enrich_url(url: str) -> Dict[str, Any]:
    next_data = fetch_next_data(url)
    page_props = next_data.get("props", {}).get("pageProps", {})

    if "episode" in page_props:
        return enrich_episode(url, page_props["episode"])
    if "podcast" in page_props:
        return enrich_podcast(url, page_props["podcast"])
    raise ValueError(f"Unsupported Xiaoyuzhou page shape for URL: {url}")


def main() -> int:
    args = parse_args()

    urls = list(args.episode_url) + list(args.podcast_url)
    if args.from_json:
        urls.extend(extract_urls_from_payload(load_json_payload(args.from_json)))

    urls = dedupe_preserve(urls)
    if not urls:
        raise SystemExit("No URLs supplied. Use --episode-url, --podcast-url, or --from-json.")

    if len(urls) > args.max_items:
        raise SystemExit(
            f"Refusing to enrich {len(urls)} URLs. Narrow the shortlist to {args.max_items} or fewer first."
        )

    items: List[Dict[str, Any]] = []
    for index, url in enumerate(urls):
        if index:
            delay = max(0.0, args.sleep_seconds + random.uniform(0.0, max(0.0, args.jitter_seconds)))
            time.sleep(delay)
        items.append(enrich_url(url))

    response = {
        "requestedUrls": urls,
        "returnedCount": len(items),
        "items": items,
    }

    if args.pretty:
        json.dump(response, sys.stdout, ensure_ascii=False, indent=2)
        sys.stdout.write("\n")
    else:
        json.dump(response, sys.stdout, ensure_ascii=False)
        sys.stdout.write("\n")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/fetch_xyz_rank.py
content: #!/usr/bin/env python3
"""Fetch and normalize ranking data from 中文播客榜."""

from __future__ import annotations

import argparse
import json
import re
import sys
from typing import Any, Dict, Iterable, List, Optional
from urllib.parse import urlencode
from urllib.request import Request, urlopen

USER_AGENT = "podcast-radar-cn/0.1 (+https://github.com/XiaohuoluFM/xhlfm-skills)"

ENDPOINTS = {
    "hot-episodes": "https://xyzrank.com/api/episodes",
    "hot-podcasts": "https://xyzrank.com/api/podcasts",
    "new-episodes": "https://xyzrank.com/api/new-episodes",
    "new-podcasts": "https://xyzrank.com/api/new-podcasts",
}

TOPIC_KEYWORDS = [
    "AI",
    "科技",
    "商业",
    "创业",
    "投资",
    "职场",
    "管理",
    "社会",
    "文化",
    "历史",
    "女性",
    "教育",
    "健康",
    "情感",
    "喜剧",
    "幽默",
    "电影",
    "音乐",
    "媒体",
    "国际",
    "旅行",
    "体育",
    "艺术",
]

FORMAT_HINTS = {
    "对话": "对话",
    "对谈": "对谈",
    "访谈": "访谈",
    "串台": "串台",
    "问答": "问答",
    "复盘": "复盘",
    "盘点": "盘点",
    "故事": "故事",
    "观察": "观察",
    "独白": "独白",
    "聊天": "聊天",
    "圆桌": "圆桌",
}

EPISODE_MARKER_PATTERNS = [
    re.compile(r"\bS(?P<season>\d+)\s*E(?P<episode>\d+)\b", re.IGNORECASE),
    re.compile(r"\bEP(?:ISODE)?\s*(?P<number>\d+)\b", re.IGNORECASE),
    re.compile(r"\bVol\.?\s*(?P<number>\d+)\b", re.IGNORECASE),
    re.compile(r"第\s*(?P<number>\d+)\s*[期集]"),
]

GUEST_CAPTURE_PATTERNS = [
    re.compile(r"(?:对话|对谈|访谈|with)\s*([^｜|:：!！?？]+)", re.IGNORECASE),
    re.compile(r"嘉宾[:：]\s*([^｜|!！?？]+)"),
    re.compile(r"[—-]{2,}\s*([^｜|:：!！?？]+)$"),
]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Fetch ranking data from 中文播客榜 and normalize it for podcast-radar-cn."
    )
    parser.add_argument(
        "--list",
        choices=sorted(ENDPOINTS),
        default="hot-podcasts",
        help="Ranking list to fetch.",
    )
    parser.add_argument(
        "--limit",
        type=int,
        default=20,
        help="Number of filtered items to return.",
    )
    parser.add_argument(
        "--page-size",
        type=int,
        default=50,
        help="Page size used against the ranking API.",
    )
    parser.add_argument(
        "--offset",
        type=int,
        default=0,
        help="Initial API offset.",
    )
    parser.add_argument(
        "--max-fetch",
        type=int,
        default=150,
        help="Maximum number of raw items to scan before stopping.",
    )
    parser.add_argument(
        "--genre",
        action="append",
        default=[],
        help="Filter by genre. May be repeated.",
    )
    parser.add_argument(
        "--query",
        help="Case-insensitive substring filter across title, show name, authors, and title signals.",
    )
    parser.add_argument(
        "--freshness-days-max",
        type=float,
        help="Only keep items whose freshness-days field is <= this value.",
    )
    parser.add_argument(
        "--min-play-count",
        type=int,
        help="Minimum play-count signal. Uses playCount for episodes and avgPlayCount for podcasts.",
    )
    parser.add_argument(
        "--min-comment-count",
        type=int,
        help="Minimum comment-count signal. Uses commentCount for episodes and avgCommentCount for podcasts.",
    )
    parser.add_argument(
        "--min-subscription",
        type=int,
        help="Minimum subscription signal. Uses subscription for episodes; ignored for podcasts without data.",
    )
    parser.add_argument(
        "--pretty",
        action="store_true",
        help="Pretty-print the JSON output.",
    )
    return parser.parse_args()


def fetch_json(url: str) -> Dict[str, Any]:
    request = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
    with urlopen(request, timeout=30) as response:
        return json.load(response)


def dedupe_preserve(items: Iterable[str]) -> List[str]:
    seen = set()
    result: List[str] = []
    for item in items:
        value = item.strip()
        if not value or value in seen:
            continue
        seen.add(value)
        result.append(value)
    return result


def split_title_head(title: str) -> str:
    return re.split(r"[｜|:：!！?？]", title, maxsplit=1)[0].strip()


def extract_episode_markers(title: str) -> List[Dict[str, Any]]:
    markers: List[Dict[str, Any]] = []
    for pattern in EPISODE_MARKER_PATTERNS:
        for match in pattern.finditer(title):
            groups = {key: value for key, value in match.groupdict().items() if value is not None}
            marker: Dict[str, Any] = {"raw": match.group(0)}
            for key, value in groups.items():
                marker[key] = int(value)
            markers.append(marker)
    return markers


def extract_guest_hints(title: str) -> List[str]:
    hints: List[str] = []
    head = split_title_head(title)

    if "×" in head:
        hints.extend(part.strip(" ，,、") for part in head.split("×"))
    if "x" in head.lower():
        parts = re.split(r"\s+[xX]\s+", head)
        if len(parts) > 1:
            hints.extend(part.strip(" ，,、") for part in parts)

    for pattern in GUEST_CAPTURE_PATTERNS:
        for match in pattern.finditer(title):
            captured = match.group(1)
            hints.extend(re.split(r"[、,/，,]", captured))

    cleaned = [hint.strip() for hint in hints if hint and len(hint.strip()) <= 24]
    return dedupe_preserve(cleaned)


def extract_format_hints(title: str) -> List[str]:
    return [label for keyword, label in FORMAT_HINTS.items() if keyword in title]


def extract_topic_keywords(title: str) -> List[str]:
    lower_title = title.lower()
    matched: List[str] = []
    for keyword in TOPIC_KEYWORDS:
        if keyword.lower() in lower_title:
            matched.append(keyword)
    return matched


def make_title_signals(title: str) -> Dict[str, Any]:
    episode_markers = extract_episode_markers(title)
    guest_hints = extract_guest_hints(title)
    format_hints = extract_format_hints(title)
    topic_keywords = extract_topic_keywords(title)

    summary: List[str] = []
    if episode_markers:
        summary.append("标题含期号线索")
    if guest_hints:
        summary.append("标题含嘉宾或对谈线索")
    if format_hints:
        summary.append("标题含节目形式线索")
    if topic_keywords:
        summary.append("标题含主题关键词")

    return {
        "episodeMarkers": episode_markers,
        "guestHints": guest_hints,
        "formatHints": format_hints,
        "topicKeywords": topic_keywords,
        "summary": summary,
    }


def normalize_links(links: List[Dict[str, Any]]) -> Dict[str, str]:
    normalized: Dict[str, str] = {}
    for entry in links or []:
        name = entry.get("name")
        url = entry.get("url")
        if name and url:
            normalized[name] = url
    return normalized


def normalize_episode(item: Dict[str, Any], source_list: str) -> Dict[str, Any]:
    return {
        "kind": "episode",
        "sourceList": source_list,
        "rank": item.get("rank"),
        "title": item.get("title"),
        "podcastExternalId": item.get("podcastID"),
        "podcastName": item.get("podcastName"),
        "genre": item.get("primaryGenreName") or "未分类",
        "playCount": item.get("playCount"),
        "commentCount": item.get("commentCount"),
        "subscriptionCount": item.get("subscription"),
        "durationMinutes": item.get("duration"),
        "postTime": item.get("postTime"),
        "freshnessDays": item.get("lastReleaseDateDayCount"),
        "episodeUrl": item.get("link"),
        "logoUrl": item.get("logoURL"),
        "openSignal": item.get("openRate"),
        "totalEpisodesCount": item.get("totalEpisodesCount"),
        "titleSignals": make_title_signals(item.get("title", "")),
    }


def normalize_podcast(item: Dict[str, Any], source_list: str) -> Dict[str, Any]:
    links = normalize_links(item.get("links") or [])
    return {
        "kind": "podcast",
        "sourceList": source_list,
        "rank": item.get("rank"),
        "externalId": item.get("id"),
        "title": item.get("name"),
        "podcastName": item.get("name"),
        "genre": item.get("primaryGenreName") or "未分类",
        "authors": item.get("authorsText"),
        "trackCount": item.get("trackCount"),
        "avgDurationMinutes": item.get("avgDuration"),
        "avgPlayCount": item.get("avgPlayCount"),
        "avgCommentCount": item.get("avgCommentCount"),
        "avgOpenSignal": item.get("avgOpenRate"),
        "freshnessDays": item.get("lastReleaseDateDayCount"),
        "lastReleaseDate": item.get("lastReleaseDate"),
        "firstEpisodePostTime": item.get("firstEpisodePostTime"),
        "links": links,
        "xyzUrl": links.get("xyz"),
        "appleUrl": links.get("apple"),
        "websiteUrl": links.get("website"),
        "rssUrl": links.get("rss"),
    }


def normalize_item(item: Dict[str, Any], source_list: str) -> Dict[str, Any]:
    if source_list.endswith("episodes"):
        return normalize_episode(item, source_list)
    return normalize_podcast(item, source_list)


def matches_filters(item: Dict[str, Any], args: argparse.Namespace) -> bool:
    if args.genre:
        requested_genres = {genre.strip().lower() for genre in args.genre if genre.strip()}
        if (item.get("genre") or "").lower() not in requested_genres:
            return False

    if args.freshness_days_max is not None:
        freshness = item.get("freshnessDays")
        if freshness is None or freshness > args.freshness_days_max:
            return False

    if args.min_play_count is not None:
        play_count = item.get("playCount", item.get("avgPlayCount"))
        if play_count is None or play_count < args.min_play_count:
            return False

    if args.min_comment_count is not None:
        comment_count = item.get("commentCount", item.get("avgCommentCount"))
        if comment_count is None or comment_count < args.min_comment_count:
            return False

    if args.min_subscription is not None:
        subscription = item.get("subscriptionCount")
        if subscription is None and item.get("kind") == "podcast":
            pass
        elif subscription is None or subscription < args.min_subscription:
            return False

    if args.query:
        haystacks = [
            item.get("title") or "",
            item.get("podcastName") or "",
            item.get("authors") or "",
            " ".join(item.get("titleSignals", {}).get("guestHints", [])),
            " ".join(item.get("titleSignals", {}).get("topicKeywords", [])),
            " ".join(item.get("titleSignals", {}).get("formatHints", [])),
        ]
        query = args.query.lower()
        if not any(query in haystack.lower() for haystack in haystacks):
            return False

    return True


def build_url(source_list: str, offset: int, limit: int) -> str:
    query = urlencode({"offset": offset, "limit": limit})
    return f"{ENDPOINTS[source_list]}?{query}"


def main() -> int:
    args = parse_args()

    if args.limit <= 0:
        raise SystemExit("--limit must be > 0")
    if args.page_size <= 0:
        raise SystemExit("--page-size must be > 0")
    if args.max_fetch <= 0:
        raise SystemExit("--max-fetch must be > 0")

    items: List[Dict[str, Any]] = []
    total: Optional[int] = None
    raw_scanned = 0
    offset = args.offset

    while raw_scanned < args.max_fetch and len(items) < args.limit:
        page_limit = min(args.page_size, args.max_fetch - raw_scanned)
        payload = fetch_json(build_url(args.list, offset, page_limit))
        if total is None:
            total = payload.get("total")

        raw_items = payload.get("items") or []
        if not raw_items:
            break

        for raw_item in raw_items:
            normalized = normalize_item(raw_item, args.list)
            if matches_filters(normalized, args):
                items.append(normalized)
                if len(items) >= args.limit:
                    break

        raw_scanned += len(raw_items)
        offset += len(raw_items)

        if len(raw_items) < page_limit:
            break

    response = {
        "sourceList": args.list,
        "requestedLimit": args.limit,
        "returnedCount": len(items),
        "rawScanned": raw_scanned,
        "sourceTotal": total,
        "appliedFilters": {
            "genres": args.genre,
            "query": args.query,
            "freshnessDaysMax": args.freshness_days_max,
            "minPlayCount": args.min_play_count,
            "minCommentCount": args.min_comment_count,
            "minSubscription": args.min_subscription,
        },
        "items": items,
    }

    if args.pretty:
        json.dump(response, sys.stdout, ensure_ascii=False, indent=2)
        sys.stdout.write("\n")
    else:
        json.dump(response, sys.stdout, ensure_ascii=False)
        sys.stdout.write("\n")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)