diff --git a/skills/ak-rss-digest/CONTRIBUTORS b/skills/ak-rss-digest/CONTRIBUTORS new file mode 100644 index 0000000..d8649da --- /dev/null +++ b/skills/ak-rss-digest/CONTRIBUTORS @@ -0,0 +1 @@ +root diff --git a/skills/ak-rss-digest/SKILL.md b/skills/ak-rss-digest/SKILL.md new file mode 100644 index 0000000..2264745 --- /dev/null +++ b/skills/ak-rss-digest/SKILL.md @@ -0,0 +1,121 @@ +--- +name: ak-rss-digest +description: Curate a Chinese reading digest from a fixed bundle of RSS and Atom feeds, with a strong preference for AI agent thinking, frontier AI commentary, deep interviews, and non-boring high-signal essays. Use when Codex needs to pull the latest week's posts by default, or a specific day's posts when explicitly requested, summarize them, score each article on a 10-point scale, and output only the posts scoring above 7 in a concise Chinese daily-brief style. +--- + +# AK RSS Digest + +## Overview + +Use this skill to build a current reading list from the feed bundle in `references/feeds.opml`. +Default to the most recent 7 days ending on the current date in `Asia/Shanghai`, and narrow to a single day only when the user explicitly asks for it. + +## Workflow + +1. Run `python3 scripts/fetch_today_feed_items.py --format json` to collect entries from the configured feeds. This defaults to the most recent 7 days. +2. Treat feed-level network failures as non-fatal. Continue with the feeds that succeeded and mention major failures only when they materially reduce coverage. +3. Read the structured output and discard obvious mismatches before opening article pages. Reject items that are clearly raw research papers, release notes, changelogs, benchmark dumps, or narrowly technical implementation logs without broader implications. +4. Open the remaining candidate links when the feed summary is too thin to judge the article well. Skim for thesis, novelty, readability, and whether the piece offers strong perspective rather than just information. +5. Score every serious candidate on the rubric below. Output only items with a score strictly greater than `7.0`. +6. If nothing clears the threshold, say so directly instead of padding the output with mediocre picks. + +## Selection Heuristics + +Prefer articles with at least one of these traits: + +- Fresh thinking about AI agents, agent tooling, agent UX, multi-agent workflows, evaluation, deployment, or failure modes. +- Strong interviews or conversations with operators, founders, researchers, or engineers who reveal how frontier work is actually being done. +- Essays that synthesize a new direction, new constraint, or strategic implication in AI, software, or adjacent technology. +- Pieces that are readable and idea-dense for a general technical audience, not just specialists in one subfield. + +Penalize heavily or reject: + +- Pure technical papers and paper summaries with little interpretive value. +- Vendor marketing, launch fluff, SEO writing, or obvious news rewrites. +- Narrow implementation diaries that do not connect to broader product, research, or ecosystem questions. +- Dry reference material that is correct but not worth a strong recommendation. + +## Scoring Rubric + +- `9-10`: Exceptional fit. Strong signal, strong writing, original insight, and clearly valuable for someone tracking AI agents or adjacent frontier shifts. +- `8-8.9`: Good recommendation. Worth reading, clear point of view, and relevant enough to the target taste profile. +- `7-7.9`: Borderline. Useful but not compelling enough for the final digest. Do not output it. +- `5-6.9`: Competent but dry, derivative, too narrow, or not aligned with the target taste profile. +- `<5`: Irrelevant, low-signal, or actively unsuitable. + +When scoring, weigh these dimensions: + +- Relevance to AI agents, frontier AI, deep operator insight, or adjacent strategic technology discussion. +- Originality of the article's argument or reporting. +- Readability and ability to hold attention. +- Practical usefulness for someone trying to keep up with meaningful new directions. + +## Output Format + +Write the final answer in Simplified Chinese. +For each article that scores above `7`, include exactly these elements with Chinese labels: + +- `标题`: original article title. +- `评分`: `x/10`, use one decimal place when helpful. +- `推荐语`: one or two sentences explaining why this is worth reading. +- `摘要`: exactly two sentences summarizing the article. +- `链接`: canonical article URL. + +Use a concise tone that reads like a curated daily brief, not a formal report: + +- Prefer short, direct sentences over explanatory padding. +- Lead with why the article is worth the user's time. +- Keep each item compact and scannable. +- Avoid English field names such as `Title`, `Score`, or `Recommendation`. + +Use this structure for the final answer: + +```markdown +本期从最近一周的 RSS 里筛出几篇值得看的文章,重点偏 AI agent、前沿判断和不太枯燥的深度内容。 + +- 标题:文章标题 + 评分:8.7/10 + 推荐语:1-2 句话,先说为什么值得看。 + 摘要:严格两句话,讲清核心观点和价值。 + 链接:文章链接 +``` + +If nothing qualifies, say so directly in Chinese, for example: + +```markdown +这周没有筛到真正值得推荐的文章。现有更新要么偏技术细节,要么信息密度不够,没有过 7 分线。 +``` + +## Resources + +- `scripts/fetch_today_feed_items.py` + Use this script to fetch the configured feeds and return recent entries as structured JSON or Markdown. + +- `references/feeds.opml` + Use this as the source of truth for the feed bundle. Keep the workflow anchored to this file unless the user explicitly asks to change the feed list. + +## Command Examples + +Fetch the latest week of entries in Shanghai time: + +```bash +python3 scripts/fetch_today_feed_items.py --format json +``` + +Fetch a single day explicitly: + +```bash +python3 scripts/fetch_today_feed_items.py --date 2026-03-17 --days 1 --timezone Asia/Shanghai --format json +``` + +Fetch the latest posts from the past week: + +```bash +python3 scripts/fetch_today_feed_items.py --days 7 --limit 30 --format json +``` + +Inspect a quick Markdown view instead of JSON: + +```bash +python3 scripts/fetch_today_feed_items.py --format markdown +``` diff --git a/skills/ak-rss-digest/agents/openai.yaml b/skills/ak-rss-digest/agents/openai.yaml new file mode 100644 index 0000000..cfa841b --- /dev/null +++ b/skills/ak-rss-digest/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "RSS Agent Digest" + short_description: "Daily RSS picks for AI agent readers" + default_prompt: "Use $ak-rss-digest to pull the latest week's configured RSS posts, score them, and return only the strongest recommendations in Chinese." + +policy: + allow_implicit_invocation: true diff --git a/skills/ak-rss-digest/references/feeds.opml b/skills/ak-rss-digest/references/feeds.opml new file mode 100644 index 0000000..a078086 --- /dev/null +++ b/skills/ak-rss-digest/references/feeds.opml @@ -0,0 +1,12 @@ + + + RSS Feeds + + + + + + + + + diff --git a/skills/ak-rss-digest/references/feeds.opml.bak b/skills/ak-rss-digest/references/feeds.opml.bak new file mode 100644 index 0000000..ca0baa2 --- /dev/null +++ b/skills/ak-rss-digest/references/feeds.opml.bak @@ -0,0 +1,99 @@ + + + + Blog Feeds + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/ak-rss-digest/references/feeds.opml.full b/skills/ak-rss-digest/references/feeds.opml.full new file mode 100644 index 0000000..bc54d12 --- /dev/null +++ b/skills/ak-rss-digest/references/feeds.opml.full @@ -0,0 +1,100 @@ + + + + Blog Feeds + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/ak-rss-digest/references/last_seen.json b/skills/ak-rss-digest/references/last_seen.json new file mode 100644 index 0000000..1f421b2 --- /dev/null +++ b/skills/ak-rss-digest/references/last_seen.json @@ -0,0 +1,229 @@ +{ + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/22/openai-cyberattack/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/22/are-ai-labs-pelicanmaxxing/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/21/nativ/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/21/cat-and-thariq/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/20/cheap-reverse-engineering/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/20/afraid-of-chinese-models/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/20/sam-altman/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/19/ai-mania/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/19/claude-code-in-bun-in-rust/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/18/sqlite-query-explainer/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/18/claude-make-fable-5-permanent/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/18/quixote/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/17/kimi-k3/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/17/llm-cliche-highlighter/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/17/spot-birds-not-golf/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/16/firefox-in-webassembly/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/16/kimi-k3/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/16/bad-codex-bug/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/16/inkling/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/16/mermaid-ascii/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/16/linus-torvalds/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/16/grok-mermaid/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/15/grok-build/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/15/claude-web-fetch-exfiltration/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/14/github-changeling/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/14/pedalican/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/14/lobsters-sqlite/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/14/armin-ronacher/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/14/datasette/#atom-everything": true, + "Simon Willison's Weblog|https://simonwillison.net/2026/Jul/14/uvx-github-actions-cache/#atom-everything": true, + "Daring Fireball|https://daringfireball.net/2026/07/ec_google_guidance_android_ai_and_search_sharing": true, + "Daring Fireball|https://workos.com/blog/management-mcp-server?utm_source=daringfireball&utm_medium=newsletter&utm_campaign=q32026": true, + "Daring Fireball|https://stratechery.com/2026/whos-afraid-of-chinese-models/": true, + "Daring Fireball|https://paper.design/?utm_source=df": true, + "Daring Fireball|https://9to5mac.com/2026/07/17/investigation-reveals-dozens-of-disguised-gambling-apps-on-the-app-store-in-brazil/": true, + "Daring Fireball|https://daringfireball.net/2026/07/mornings_in_cupertino_have_the_aroma_of_napalm_once_again": true, + "Daring Fireball|https://www.ft.com/content/1b8c9d52-88a9-426b-ba47-f1811f859166?syn-25a6b1a6=1": true, + "Daring Fireball|https://thenewthings.com/p/apple-big-ai-book-slop-problem": true, + "Daring Fireball|https://www.cnbc.com/2026/07/02/alphabet-google-android-eu-antitrust-fine-4-1-billion-euro-appeal.html": true, + "Daring Fireball|https://about.roblox.com/newsroom/2026/07/build-without-limits-on-roblox": true, + "Daring Fireball|https://9to5mac.com/2026/07/17/apple-raises-prices-for-apple-music-and-apple-one-subscriptions/": true, + "Daring Fireball|https://www.wired.com/story/openai-reorg-greg-brockman-product/": true, + "Daring Fireball|https://spyglass.org/chatgpt-brings-back-chatgpt/": true, + "Daring Fireball|https://x.com/thsottiaux/status/2077928427936710901": true, + "Daring Fireball|https://lore.kernel.org/linux-media/CAHk-=wi4zC+Ze8e+p3tMv8TtG_80KzsZ1syL9anBtmEh5Z40vg@mail.gmail.com/": true, + "Daring Fireball|https://www.barebones.com/products/bbedit/bbedit16.html": true, + "Daring Fireball|https://github.com/ActionRetro/ArtfulType": true, + "Daring Fireball|https://www.threads.com/@joannastern/post/Da5rgIvjrLM": true, + "Daring Fireball|https://www.theverge.com/policy/965792/google-epic-withdraw-injunction-third-party-app-stores-coming-google-play?view_token=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IkZpdmhlVXFoV0giLCJwIjoiL3BvbGljeS85NjU3OTIvZ29vZ2xlLWVwaWMtd2l0aGRyYXctaW5qdW5jdGlvbi10aGlyZC1wYXJ0eS1hcHAtc3RvcmVzLWNvbWluZy1nb29nbGUtcGxheSIsImV4cCI6MTc4NDczNTA1NSwiaWF0IjoxNzg0MzAzMDU1fQ.zPHCDeRVkCOK73sdt6bKC2evAofTI582EsJ0N-rk79g": true, + "Daring Fireball|https://environment.ec.europa.eu/news/commission-adds-exemptions-portable-battery-removal-rules-2026-07-14_en": true, + "Daring Fireball|https://mastodon.social/@quicheindustries/116918456229212087": true, + "Daring Fireball|https://dithering.passport.online/member/episode/apple-sues-open-ai": true, + "Daring Fireball|https://www.bloomberg.com/news/articles/2026-07-14/openai-says-it-s-not-aware-of-any-evidence-that-apple-lawsuit-has-merit": true, + "Daring Fireball|https://www.nbcnews.com/tech/apple/apple-openai-lawsuit-suit-trade-product-hardware-email-sam-altman-rcna587376": true, + "Daring Fireball|https://parakeet.co/blog/the-shape-of-apps/": true, + "Daring Fireball|https://openai.com/supply/co-lab/work-louder/": true, + "Daring Fireball|https://www.bloomberg.com/news/articles/2026-07-14/openai-s-first-device-will-be-moveable-screenless-speaker-built-as-ai-companion?accessToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzb3VyY2UiOiJTdWJzY3JpYmVyR2lmdGVkQXJ0aWNsZSIsImlhdCI6MTc4NDA2MjAxMywiZXhwIjoxNzg0NjY2ODEzLCJhcnRpY2xlSWQiOiJUSTYwSllUOU5KTFMwMCIsImJjb25uZWN0SWQiOiJDNEVEQ0FFMUZBMDU0MEJFQTI0QTlGMjExQzFFOTA4MCJ9.DfRN0afk0TFIaHFw9zEKYjehnfMsZfKC7gPoVos8WPI&leadSource=article-gifting": true, + "Daring Fireball|https://mobiledevmemo.com/did-apple-just-signal-a-third-party-expansion-of-apple-ads/": true, + "Daring Fireball|https://techcrunch.com/2026/07/15/apple-quietly-reveals-how-its-maps-ads-will-differ-from-googles/": true, + "Daring Fireball|https://www.scmp.com/tech/policy/article/3360685/china-approves-apple-intelligence-phones-alibaba-baidu-emerging-partners": true, + "Daring Fireball|https://arstechnica.com/tech-policy/2025/08/elon-musk-sues-apple-openai-to-block-exclusive-iphone-chatgpt-integration/": true, + "Daring Fireball|https://workos.com/pipes?utm_source=daringfireball&utm_medium=newsletter&utm_campaign=q32026": true, + "Daring Fireball|https://pfandrade.me/blog/swiftui-mac-assed-wwdc27-update/": true, + "Daring Fireball|https://grumpy.website/1723": true, + "Daring Fireball|https://tonsky.me/blog/every-frame-perfect/": true, + "Daring Fireball|https://github.com/insidegui/TwoMillionKit": true, + "Daring Fireball|https://x.com/sama/status/2075982617976230043": true, + "Daring Fireball|https://morphing.cloud/lunacy/": true, + "Daring Fireball|https://morphing.cloud/hypercard/": true, + "Daring Fireball|https://help.openai.com/en/articles/20001276-moving-to-the-new-chatgpt-desktop-app": true, + "Daring Fireball|https://help.openai.com/en/articles/20001275-chatgpt-work-and-codex": true, + "Daring Fireball|https://www.threads.com/@benedictevans/post/Dano_uvDr8F": true, + "Daring Fireball|https://daringfireball.net/2026/07/exactly_like_om_malik": true, + "Daring Fireball|https://www.bloomberg.com/news/articles/2026-07-11/openai-engineer-s-lol-moment-set-stage-for-legal-fight-with-apple?accessToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzb3VyY2UiOiJTdWJzY3JpYmVyR2lmdGVkQXJ0aWNsZSIsImlhdCI6MTc4Mzc3OTcwMCwiZXhwIjoxNzg0Mzg0NTAwLCJhcnRpY2xlSWQiOiJUSFpEVUhLR0lGUEMwMCIsImJjb25uZWN0SWQiOiJDNEVEQ0FFMUZBMDU0MEJFQTI0QTlGMjExQzFFOTA4MCJ9.lF9LVTMyaJOToYhpwph5JjsSJEjdBGXLbenBKQdpHhc&leadSource=uverify%20wall": true, + "Daring Fireball|https://daringfireball.net/2026/07/ternus_apple_slippery_slope": true, + "Daring Fireball|https://daringfireball.net/2026/07/whats_good_for_the_ios_goose_is_often_not_good_for_the_macos_gander": true, + "Daring Fireball|https://daringfireball.net/2026/07/eliminate_app_icon_squircle_jail": true, + "Krebs on Security|https://krebsonsecurity.com/2026/07/lg-to-ban-residential-proxies-from-smart-tv-apps/": true, + "Krebs on Security|https://krebsonsecurity.com/2026/07/microsoft-patches-a-record-570-security-flaws/": true, + "Krebs on Security|https://krebsonsecurity.com/2026/07/lessons-learned-from-cisas-recent-github-leak/": true, + "Krebs on Security|https://krebsonsecurity.com/2026/07/felons-fraudsters-flog-offensive-cybersecurity-startup/": true, + "Krebs on Security|https://krebsonsecurity.com/2026/07/fbi-seizes-netnut-proxy-platform-popa-botnet/": true, + "Krebs on Security|https://krebsonsecurity.com/2026/06/scattered-spider-hackers-plead-guilty-on-day-1-of-trial/": true, + "Krebs on Security|https://krebsonsecurity.com/2026/06/popa-botnet-linked-to-publicly-traded-israeli-firm/": true, + "Krebs on Security|https://krebsonsecurity.com/2026/06/who-runs-the-ransomware-group-the-gentlemen/": true, + "Krebs on Security|https://krebsonsecurity.com/2026/06/a-record-breaking-patch-tuesday-for-june-2026/": true, + "Krebs on Security|https://krebsonsecurity.com/2026/06/hackers-used-metas-ai-support-bot-to-seize-instagram-accounts/": true, + "|http://antirez.com/news/170": true, + "|http://antirez.com/news/169": true, + "|http://antirez.com/news/168": true, + "|http://antirez.com/news/167": true, + "|http://antirez.com/news/166": true, + "|http://antirez.com/news/165": true, + "|http://antirez.com/news/164": true, + "|http://antirez.com/news/163": true, + "|http://antirez.com/news/162": true, + "|http://antirez.com/news/161": true, + "|http://antirez.com/news/160": true, + "|http://antirez.com/news/159": true, + "|http://antirez.com/news/158": true, + "|http://antirez.com/news/157": true, + "|http://antirez.com/news/156": true, + "|http://antirez.com/news/155": true, + "|http://antirez.com/news/154": true, + "|http://antirez.com/news/153": true, + "|http://antirez.com/news/152": true, + "|http://antirez.com/news/151": true, + "|http://antirez.com/news/150": true, + "|http://antirez.com/news/149": true, + "|http://antirez.com/news/148": true, + "|http://antirez.com/news/147": true, + "|http://antirez.com/news/146": true, + "|http://antirez.com/news/145": true, + "|http://antirez.com/news/144": true, + "|http://antirez.com/news/143": true, + "|http://antirez.com/news/142": true, + "|http://antirez.com/news/141": true, + "|http://antirez.com/news/140": true, + "|http://antirez.com/news/139": true, + "|http://antirez.com/news/138": true, + "|http://antirez.com/news/137": true, + "|http://antirez.com/news/136": true, + "|http://antirez.com/news/135": true, + "|http://antirez.com/news/134": true, + "|http://antirez.com/news/133": true, + "|http://antirez.com/news/132": true, + "|http://antirez.com/news/131": true, + "|http://antirez.com/news/130": true, + "|http://antirez.com/news/129": true, + "|http://antirez.com/news/128": true, + "|http://antirez.com/news/127": true, + "|http://antirez.com/news/126": true, + "|http://antirez.com/news/125": true, + "|http://antirez.com/news/124": true, + "|http://antirez.com/news/123": true, + "|http://antirez.com/news/122": true, + "|http://antirez.com/news/121": true, + "|http://antirez.com/news/120": true, + "|http://antirez.com/news/119": true, + "|http://antirez.com/news/118": true, + "|http://antirez.com/news/117": true, + "|http://antirez.com/news/116": true, + "|http://antirez.com/news/115": true, + "|http://antirez.com/news/114": true, + "|http://antirez.com/news/113": true, + "|http://antirez.com/news/112": true, + "|http://antirez.com/news/111": true, + "|http://antirez.com/news/110": true, + "|http://antirez.com/news/109": true, + "|http://antirez.com/news/108": true, + "|http://antirez.com/news/107": true, + "|http://antirez.com/news/106": true, + "|http://antirez.com/news/105": true, + "|http://antirez.com/news/104": true, + "|http://antirez.com/news/103": true, + "|http://antirez.com/news/102": true, + "|http://antirez.com/news/101": true, + "|http://antirez.com/news/100": true, + "|http://antirez.com/news/99": true, + "|http://antirez.com/news/98": true, + "|http://antirez.com/news/97": true, + "|http://antirez.com/news/96": true, + "|http://antirez.com/news/95": true, + "|http://antirez.com/news/94": true, + "|http://antirez.com/news/93": true, + "|http://antirez.com/news/92": true, + "|http://antirez.com/news/91": true, + "|http://antirez.com/news/90": true, + "|http://antirez.com/news/89": true, + "|http://antirez.com/news/88": true, + "|http://antirez.com/news/87": true, + "|http://antirez.com/news/86": true, + "|http://antirez.com/news/85": true, + "|http://antirez.com/news/84": true, + "|http://antirez.com/news/83": true, + "|http://antirez.com/news/82": true, + "|http://antirez.com/news/81": true, + "|http://antirez.com/news/80": true, + "|http://antirez.com/news/79": true, + "|http://antirez.com/news/78": true, + "|http://antirez.com/news/77": true, + "|http://antirez.com/news/76": true, + "|http://antirez.com/news/75": true, + "|http://antirez.com/news/74": true, + "|http://antirez.com/news/73": true, + "|http://antirez.com/news/72": true, + "|http://antirez.com/news/71": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/open-sauce-gps-time-badge/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/quadrf-can-spot-drones-and-see-wifi-through-my-wall/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/special-value-pi-4-extremely-short-lived/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/apply-lut-color-grade-with-ffmpeg/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/framework-10g-ethernet-module-usb-c-complexity/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/power-on-your-mac-remotely/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/i-tested-every-ip-kvm/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/its-hard-to-justify-framework-12/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/tuning-in-fm-radio-on-a-3d-printer-heatbed/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/i-patched-iozone-for-better-disk-benchmarks-on-modern-macos/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/news-about-raspberry-pi-6-and-microcontroller-development/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/wi-wi-is-wireless-time-sync-less-than-5ns/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/bambu-lab-abusing-open-source-social-contract/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/homepod-mini-feels-like-magic--but-it-s-just-good-timing/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/deskpi-super4c-sbc-cluster/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/raspberry-pi-connect-may-control-windows-soon/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/new-10-gbe-usb-adapters-cooler-smaller-cheaper/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/arm-mainboard-for-framework-laptop/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/build-your-own-dial-up-isp-with-a-raspberry-pi/": true, + "Jeff Geerling|https://www.jeffgeerling.com/blog/2026/dram-pricing-is-killing-the-hobbyist-sbc-market/": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/122.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/121.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/120.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/119.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/118.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/117.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/116.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/115.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/114.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/113.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/112.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/111.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/110.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/109.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/108.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/107.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/106.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/105.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/104.html": true, + "Github Weekly|https://itcoffee66.github.io/githubweekly/103.html": true +} \ No newline at end of file diff --git a/skills/ak-rss-digest/scripts/fetch_rss.py b/skills/ak-rss-digest/scripts/fetch_rss.py new file mode 100755 index 0000000..82e57bb --- /dev/null +++ b/skills/ak-rss-digest/scripts/fetch_rss.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""RSS fetcher with dedup. Subprocess isolation per feed for true timeout.""" + +import argparse +import json +import os +import re +import subprocess +import sys +import time +import xml.etree.ElementTree as ET +from datetime import datetime, timedelta, timezone +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +SKILL_DIR = SCRIPT_DIR.parent +DEFAULT_OPML = SKILL_DIR / "references" / "feeds.opml" +DEFAULT_STATE = SKILL_DIR / "references" / "last_seen.json" + +FETCHER_CODE = r""" +import sys, json, time +import requests, feedparser +from datetime import datetime, timezone +try: + resp = requests.get(sys.argv[1], timeout=(5, 15), + headers={'User-Agent': 'ak-rss-digest/1.0', + 'Accept': 'application/rss+xml, application/atom+xml, text/xml'}) + resp.raise_for_status() + parsed = feedparser.parse(resp.content) + entries = [] + for e in parsed.entries: + link = e.get('link', '') + if not link: continue + pub = None + for df in ['published_parsed', 'updated_parsed']: + dt = e.get(df) + if dt: pub = datetime(*dt[:6], tzinfo=timezone.utc); break + import re + summary = re.sub(r'<[^>]+>', '', e.get('summary','') or e.get('description','') or '').strip()[:300] + entries.append({ + 'title': e.get('title','(untitled)'), 'url': link, + 'published': pub.isoformat() if pub else None, 'summary': summary, + }) + print(json.dumps({'status': 'ok', 'feed_title': parsed.feed.get('title',''), 'entries': entries})) +except Exception as ex: + print(json.dumps({'status': 'error', 'error': str(ex)})) +""" + + +def load_opml(path): + tree = ET.parse(path) + feeds = [] + for o in tree.iter("outline"): + if "xmlUrl" in o.attrib: + feeds.append({ + "name": o.attrib.get("text") or o.attrib.get("title") or "", + "url": o.attrib["xmlUrl"], + "site": o.attrib.get("htmlUrl", ""), + }) + return feeds + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--days", type=int, default=7) + parser.add_argument("--timeout", type=int, default=20) + parser.add_argument("--feeds", default=str(DEFAULT_OPML)) + parser.add_argument("--state", default=str(DEFAULT_STATE)) + parser.add_argument("--no-dedup", action="store_true") + args = parser.parse_args() + + if not os.path.exists(args.feeds): + print(json.dumps({"error": "OPML not found"})) + sys.exit(1) + + feeds = load_opml(args.feeds) + cutoff = datetime.now(timezone.utc) - timedelta(days=args.days) + + # Load last_seen + last_seen = {} + if not args.no_dedup and os.path.exists(args.state): + with open(args.state) as f: + last_seen = json.load(f) + + all_entries = [] + errors = [] + new_seen = {} + ok = err = 0 + + for feed in feeds: + try: + result = subprocess.run( + [sys.executable, "-c", FETCHER_CODE, feed["url"]], + capture_output=True, text=True, timeout=args.timeout + ) + data = json.loads(result.stdout) + if data["status"] != "ok": + err += 1 + errors.append({"feed": feed["name"], "error": data.get("error", "unknown")}) + continue + + ok += 1 + feed_title = data.get("feed_title", feed["name"]) + for e in data["entries"]: + key = f'{feed_title}|{e["url"]}' + new_seen[key] = True + if not args.no_dedup and key in last_seen: + continue + pub = e.get("published") + if pub: + pub_dt = datetime.fromisoformat(pub) + if pub_dt < cutoff: + continue + all_entries.append({ + "source": feed_title, + "title": e["title"], + "url": e["url"], + "published": pub, + "summary": e["summary"], + }) + except subprocess.TimeoutExpired: + err += 1 + errors.append({"feed": feed["name"], "error": "timeout"}) + except Exception as ex: + err += 1 + errors.append({"feed": feed["name"], "error": str(ex)}) + + # Save state + if not args.no_dedup: + os.makedirs(os.path.dirname(args.state) or ".", exist_ok=True) + with open(args.state, "w") as f: + json.dump(new_seen, f, ensure_ascii=False, indent=2) + + all_entries.sort(key=lambda e: e.get("published") or "", reverse=True) + new_by_source = {} + for e in all_entries: + s = e["source"] + new_by_source[s] = new_by_source.get(s, 0) + 1 + + output = { + "total_feeds": len(feeds), + "ok": ok, "errors": err, + "total_entries": len(all_entries), + "has_new": len(all_entries) > 0, + "new_by_source": new_by_source, + "error_details": errors[:5] if errors else None, + "entries": all_entries[:500], + } + print(json.dumps(output, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/skills/ak-rss-digest/scripts/fetch_today_feed_items.py b/skills/ak-rss-digest/scripts/fetch_today_feed_items.py new file mode 100644 index 0000000..da8e269 --- /dev/null +++ b/skills/ak-rss-digest/scripts/fetch_today_feed_items.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 + +import argparse +import concurrent.futures +import datetime as dt +import html +import json +import re +import signal +import sys +import xml.etree.ElementTree as ET +from email.utils import parsedate_to_datetime +from pathlib import Path + +import feedparser + + +USER_AGENT = "Mozilla/5.0 (compatible; ak-rss-digest/1.0; +https://openai.com)" +ACCEPT = "application/rss+xml, application/atom+xml, application/xml, text/xml, */*;q=0.8" +DEFAULT_TIMEOUT = 15 +DEFAULT_WORKERS = 5 +DEFAULT_TZ = "Asia/Shanghai" +DEFAULT_DAYS = 7 + + +def local_name(tag): + return tag.rsplit("}", 1)[-1] + + +def strip_html(raw): + if not raw: + return "" + text = re.sub(r"<[^>]+>", " ", raw) + text = html.unescape(text) + return re.sub(r"\s+", " ", text).strip() + + +def first_text(parent, names): + for child in parent: + if local_name(child.tag) in names: + text = "".join(child.itertext()).strip() + if text: + return text + return "" + + +def first_link(entry): + for child in entry: + if local_name(child.tag) != "link": + continue + href = child.attrib.get("href") + rel = child.attrib.get("rel", "alternate") + if href and rel == "alternate": + return href + text = "".join(child.itertext()).strip() + if text: + return text + if href: + return href + return "" + + +def parse_datetime(raw): + if not raw: + return None + value = raw.strip() + try: + parsed = parsedate_to_datetime(value) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=dt.timezone.utc) + return parsed + except (TypeError, ValueError, IndexError): + pass + + normalized = value.replace("Z", "+00:00") + try: + parsed = dt.datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt.timezone.utc) + return parsed + + +def load_feeds(opml_path): + root = ET.parse(opml_path).getroot() + feeds = [] + for outline in root.findall(".//outline[@type='rss']"): + feeds.append( + { + "name": outline.attrib.get("text") or outline.attrib.get("title") or "", + "xml_url": outline.attrib["xmlUrl"], + "html_url": outline.attrib.get("htmlUrl", ""), + } + ) + return feeds + + +def fetch_url(url, timeout): + """Fetch feed content via feedparser, which handles DNS/TLS timeouts properly.""" + parsed = feedparser.parse( + url, + agent=USER_AGENT, + request_headers={"Accept": ACCEPT}, + ) + # Reconstruct raw feed content for XML parsing + # feedparser stores full content in 'headers' and parsed entries + raw = b"" + if hasattr(parsed, "headers") and parsed.headers: + content_type = parsed.headers.get("content-type", "") + else: + content_type = "" + final_url = parsed.get("href", url) + return raw, final_url, content_type + + +def parse_atom(root, feed_meta): + feed_title = first_text(root, {"title"}) or feed_meta["name"] + entries = [] + for entry in root: + if local_name(entry.tag) != "entry": + continue + published_raw = first_text(entry, {"published", "updated", "issued", "created"}) + entries.append( + { + "feed_name": feed_title, + "feed_url": feed_meta["xml_url"], + "site_url": feed_meta["html_url"], + "title": first_text(entry, {"title"}) or "(untitled)", + "link": first_link(entry), + "published_raw": published_raw, + "published_at": parse_datetime(published_raw), + "summary": strip_html(first_text(entry, {"summary", "content"})), + } + ) + return entries + + +def parse_rss(root, feed_meta): + channel = None + if local_name(root.tag) == "rss": + for child in root: + if local_name(child.tag) == "channel": + channel = child + break + elif local_name(root.tag) in {"RDF", "rdf"}: + channel = root + else: + channel = root + + feed_title = first_text(channel, {"title"}) or feed_meta["name"] + entries = [] + for item in channel.iter(): + if local_name(item.tag) != "item": + continue + published_raw = first_text(item, {"pubDate", "published", "date", "updated"}) + entries.append( + { + "feed_name": feed_title, + "feed_url": feed_meta["xml_url"], + "site_url": feed_meta["html_url"], + "title": first_text(item, {"title"}) or "(untitled)", + "link": first_link(item) or first_text(item, {"guid"}), + "published_raw": published_raw, + "published_at": parse_datetime(published_raw), + "summary": strip_html(first_text(item, {"description", "encoded", "content", "summary"})), + } + ) + return entries + + +def parse_feed(content, feed_meta): + root = ET.fromstring(content) + tag = local_name(root.tag) + if tag == "feed": + return parse_atom(root, feed_meta) + if tag in {"rss", "RDF", "rdf"}: + return parse_rss(root, feed_meta) + raise ValueError(f"Unsupported feed root tag: {root.tag}") + + +def fetch_feed(feed_meta, timeout): + """Fetch and parse a feed using feedparser (handles timeouts reliably).""" + try: + parsed = feedparser.parse( + feed_meta["xml_url"], + agent=USER_AGENT, + request_headers={"Accept": ACCEPT}, + ) + if parsed.bozo and not parsed.entries: + raise Exception(f"Feed parse error: {parsed.bozo_exception}") + + entries = [] + for entry in parsed.entries: + entries.append({ + "feed_name": parsed.feed.get("title", feed_meta["name"]), + "feed_url": feed_meta["xml_url"], + "site_url": feed_meta["html_url"], + "title": entry.get("title", "(untitled)"), + "link": entry.get("link", ""), + "published_raw": entry.get("published", ""), + "published_at": parse_datetime(entry.get("published", "")), + "summary": strip_html(entry.get("summary", "") or entry.get("description", "")), + }) + return { + "feed": feed_meta, + "status": "ok", + "final_url": parsed.get("href", feed_meta["xml_url"]), + "content_type": parsed.headers.get("content-type", "") if hasattr(parsed, "headers") and parsed.headers else "", + "entries": entries, + } + except Exception as exc: + return { + "feed": feed_meta, + "status": "error", + "error": f"{type(exc).__name__}: {exc}", + "entries": [], + } + + +def serialize_item(item, target_tz): + published_at = item["published_at"] + published_local = published_at.astimezone(target_tz) if published_at else None + return { + "feed_name": item["feed_name"], + "feed_url": item["feed_url"], + "site_url": item["site_url"], + "title": item["title"], + "link": item["link"], + "published_raw": item["published_raw"], + "published_at": published_at.isoformat() if published_at else None, + "published_local": published_local.isoformat() if published_local else None, + "summary": item["summary"], + } + + +def format_markdown(payload): + target_label = payload.get("target_date") + if payload.get("days", 1) > 1: + target_label = f"{payload['target_date']} minus {payload['days'] - 1} day(s)" + lines = [ + f"# RSS items for {target_label} ({payload['timezone']})", + "", + f"- Feeds checked: {payload['feed_count']}", + f"- Feeds failed: {len(payload['errors'])}", + f"- Matching items: {len(payload['items'])}", + "", + ] + if payload["errors"]: + lines.extend(["## Feed errors", ""]) + for error in payload["errors"]: + lines.append(f"- {error['feed_name']}: {error['error']}") + lines.append("") + if payload["items"]: + lines.extend(["## Items", ""]) + for item in payload["items"]: + lines.append(f"### {item['title']}") + lines.append(f"- Feed: {item['feed_name']}") + lines.append(f"- Published: {item['published_local'] or item['published_raw'] or 'unknown'}") + lines.append(f"- Link: {item['link']}") + if item["summary"]: + lines.append(f"- Summary: {item['summary']}") + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def main(): + skill_dir = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description="Fetch recent items from the configured RSS bundle.") + parser.add_argument("--feeds-file", default=str(skill_dir / "references" / "feeds.opml")) + parser.add_argument("--date", help="Target end date in YYYY-MM-DD. Default: current date in target timezone.") + parser.add_argument( + "--days", + type=int, + default=DEFAULT_DAYS, + help=f"Number of days to include ending on --date. Default: {DEFAULT_DAYS}.", + ) + parser.add_argument("--limit", type=int, help="Optional maximum number of items to return after sorting.") + parser.add_argument("--timezone", default=DEFAULT_TZ) + parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT) + parser.add_argument("--workers", type=int, default=10) + parser.add_argument("--format", choices=("json", "markdown"), default="json") + args = parser.parse_args() + + if args.days < 1: + raise SystemExit("--days must be at least 1") + if args.limit is not None and args.limit < 1: + raise SystemExit("--limit must be at least 1") + + try: + target_tz = dt.ZoneInfo(args.timezone) + except Exception as exc: + raise SystemExit(f"Invalid timezone '{args.timezone}': {exc}") + + if args.date: + target_date = dt.date.fromisoformat(args.date) + else: + target_date = dt.datetime.now(target_tz).date() + start_date = target_date - dt.timedelta(days=args.days - 1) + + feeds = load_feeds(args.feeds_file) + + results = [] + overall_timeout = max(args.timeout * 2, 60) + with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor: + futures = [executor.submit(fetch_feed, feed, args.timeout) for feed in feeds] + try: + for future in concurrent.futures.as_completed(futures, timeout=overall_timeout): + try: + results.append(future.result(timeout=args.timeout)) + except Exception as exc: + # Shouldn't happen with our fetch_feed, but safety net + pass + except concurrent.futures.TimeoutError: + # Collect whatever completed, mark rest as errors + for future in futures: + if future.done(): + try: + results.append(future.result(timeout=0)) + except Exception: + pass + else: + future.cancel() + + items = [] + errors = [] + for result in results: + if result["status"] != "ok": + errors.append( + { + "feed_name": result["feed"]["name"], + "feed_url": result["feed"]["xml_url"], + "error": result["error"], + } + ) + continue + for entry in result["entries"]: + published_at = entry["published_at"] + if not published_at: + continue + published_local = published_at.astimezone(target_tz) + published_date = published_local.date() + if published_date < start_date or published_date > target_date: + continue + if not entry["link"]: + continue + items.append(serialize_item(entry, target_tz)) + + items.sort( + key=lambda item: ( + item["published_local"] or "", + item["feed_name"].lower(), + item["title"].lower(), + ), + reverse=True, + ) + errors.sort(key=lambda err: err["feed_name"].lower()) + if args.limit is not None: + items = items[: args.limit] + + payload = { + "start_date": start_date.isoformat(), + "target_date": target_date.isoformat(), + "days": args.days, + "timezone": args.timezone, + "feed_count": len(feeds), + "items": items, + "errors": errors, + } + + if args.format == "markdown": + sys.stdout.write(format_markdown(payload)) + else: + json.dump(payload, sys.stdout, ensure_ascii=True, indent=2) + sys.stdout.write("\n") + + +if __name__ == "__main__": + if not hasattr(dt, "ZoneInfo"): + from zoneinfo import ZoneInfo # type: ignore + + dt.ZoneInfo = ZoneInfo # type: ignore[attr-defined] + main() diff --git a/skills/ak-rss-digest/scripts/fetch_with_dedup.py b/skills/ak-rss-digest/scripts/fetch_with_dedup.py new file mode 100755 index 0000000..3ad867d --- /dev/null +++ b/skills/ak-rss-digest/scripts/fetch_with_dedup.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Wrapper for ak-rss-digest: add state-based deduplication on top of the real fetch script.""" + +import json +import subprocess +import sys +import os + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +REAL_SCRIPT = os.path.join(SCRIPT_DIR, "fetch_today_feed_items.py") +STATE_FILE = os.path.join(os.path.dirname(SCRIPT_DIR), "references", "last_seen.json") + +# Run the real script +result = subprocess.run( + [sys.executable, REAL_SCRIPT] + sys.argv[1:], + capture_output=True, text=True +) +if result.returncode != 0: + print(result.stderr, file=sys.stderr) + sys.exit(result.returncode) + +data = json.loads(result.stdout) +entries = data.get("entries", []) + +# Load last seen +last_seen = {} +if os.path.exists(STATE_FILE): + with open(STATE_FILE) as f: + last_seen = json.load(f) + +# Filter: keep only entries not seen before (by URL) +new_entries = [] +new_seen = {} +for e in entries: + url = e.get("url", "") + source = e.get("source", "") + if url: + key = f"{source}|{url}" + new_seen[key] = True + if key not in last_seen: + new_entries.append(e) + +# Group new entries by source +new_by_source = {} +for e in new_entries: + src = e.get("source", "unknown") + new_by_source[src] = new_by_source.get(src, 0) + 1 + +# Output filtered results +output = { + **data, + "entries": new_entries, + "has_new": len(new_entries) > 0, + "new_by_source": new_by_source, + "total_fetched": len(entries), +} + +# Save state +os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) +with open(STATE_FILE, "w") as f: + json.dump(new_seen, f, ensure_ascii=False, indent=2) + +print(json.dumps(output, ensure_ascii=False, indent=2))