newsnow 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025-present ChenCheng (sorrycc@gmail.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # newsnow
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/newsnow.svg)](https://www.npmjs.com/package/newsnow)
4
+ [![npm downloads](https://img.shields.io/npm/dm/newsnow.svg)](https://www.npmjs.com/package/newsnow)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![clawhub](https://img.shields.io/badge/clawhub-view-blue)](https://clawhub.ai/sorrycc/newsnow)
7
+
3
8
  A command-line tool to fetch trending news and hot topics from 66 sources across 44 platforms. Built with TypeScript, runs on Bun.
4
9
 
5
10
  Ported from [ourongxing/newsnow](https://github.com/ourongxing/newsnow) server sources.
@@ -7,26 +12,63 @@ Ported from [ourongxing/newsnow](https://github.com/ourongxing/newsnow) server s
7
12
  ## Install
8
13
 
9
14
  ```bash
10
- bun install
15
+ npm install -g newsnow
16
+ ```
17
+
18
+ Or use directly with npx:
19
+
20
+ ```bash
21
+ npx newsnow
11
22
  ```
12
23
 
13
24
  ## Usage
14
25
 
15
26
  ```bash
16
27
  # Show help
17
- bun src/cli.ts --help
28
+ newsnow --help
18
29
 
19
30
  # List all available sources
20
- bun src/cli.ts list
31
+ newsnow list
21
32
 
22
33
  # Fetch news from a source
23
- bun src/cli.ts hackernews
34
+ newsnow hackernews
24
35
 
25
36
  # Output as JSON (pipeable to jq, etc.)
26
- bun src/cli.ts hackernews --json
37
+ newsnow hackernews --json
38
+
39
+ # Pretty-print JSON
40
+ newsnow hackernews --json --pretty
41
+
42
+ # Limit number of items
43
+ newsnow hackernews --limit 5
44
+
45
+ # Select specific fields (JSON mode)
46
+ newsnow hackernews --json --fields title,url
27
47
 
28
48
  # List sources as JSON
29
- bun src/cli.ts list --json
49
+ newsnow list --json
50
+
51
+ # Print machine-readable JSON Schema
52
+ newsnow schema
53
+ ```
54
+
55
+ ### JSON Envelopes
56
+
57
+ All `--json` output uses structured envelopes:
58
+
59
+ **Fetch** (`newsnow <source> --json`):
60
+ ```json
61
+ { "source": "hackernews", "count": 30, "items": [...] }
62
+ ```
63
+
64
+ **List** (`newsnow list --json`):
65
+ ```json
66
+ { "count": 66, "sources": [{ "name": "hackernews", "category": "hackernews", "envVars": [] }, ...] }
67
+ ```
68
+
69
+ **Errors** (written to stderr):
70
+ ```json
71
+ { "error": "Unknown source \"foo\"", "code": "UNKNOWN_SOURCE", "suggestions": ["foobar"] }
30
72
  ```
31
73
 
32
74
  ## Sources
@@ -94,30 +136,6 @@ Some sources may be blocked by Cloudflare or require authentication:
94
136
  bun test
95
137
  ```
96
138
 
97
- ## Project Structure
98
-
99
- ```
100
- src/
101
- cli.ts # CLI entry point (raw process.argv)
102
- types.ts # NewsItem type definitions
103
- fetch.ts # HTTP client (ofetch wrapper)
104
- crypto.ts # md5, SHA-1, base64 helpers
105
- utils.ts # Date parsing utilities
106
- rss.ts # RSS feed parser
107
- sources/
108
- index.ts # Source registry (all sources merged)
109
- baidu.ts # One file per platform
110
- bilibili.ts
111
- cls/ # Multi-file sources
112
- index.ts
113
- utils.ts
114
- coolapk/
115
- index.ts
116
- ...
117
- test/
118
- cli.test.ts
119
- ```
120
-
121
139
  ## Dependencies
122
140
 
123
141
  - [cheerio](https://github.com/cheeriojs/cheerio) - HTML parsing
@@ -127,4 +145,4 @@ test/
127
145
 
128
146
  ## License
129
147
 
130
- ISC
148
+ MIT
package/dist/src/cli.js CHANGED
@@ -1,35 +1,98 @@
1
1
  #!/usr/bin/env bun
2
2
  import { sources } from "./sources/index.js";
3
- const args = process.argv.slice(2);
4
- const jsonFlag = args.includes("--json");
5
- const filteredArgs = args.filter(a => a !== "--json");
6
- const command = filteredArgs[0];
3
+ import { NEWS_ITEM_FIELDS } from "./types.js";
4
+ function parseArgs(argv) {
5
+ const raw = argv.slice(2);
6
+ let json = false;
7
+ let pretty = false;
8
+ const fields = [];
9
+ let limit;
10
+ let command;
11
+ for (let i = 0; i < raw.length; i++) {
12
+ const arg = raw[i];
13
+ if (arg === "--json") {
14
+ json = true;
15
+ }
16
+ else if (arg === "--output" && raw[i + 1] === "json") {
17
+ json = true;
18
+ i++;
19
+ }
20
+ else if (arg === "--pretty") {
21
+ pretty = true;
22
+ }
23
+ else if (arg === "--fields" && raw[i + 1]) {
24
+ fields.push(...raw[++i].split(",").map(f => f.trim()).filter(Boolean));
25
+ }
26
+ else if (arg === "--limit" && raw[i + 1]) {
27
+ limit = parseInt(raw[++i], 10);
28
+ }
29
+ else if (!arg.startsWith("-") && command === undefined) {
30
+ command = arg;
31
+ }
32
+ }
33
+ return { command, json, pretty, fields, limit };
34
+ }
35
+ const opts = parseArgs(process.argv);
36
+ function jsonOut(data) {
37
+ console.log(opts.pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data));
38
+ }
39
+ function emitError(msg, code, exitCode, suggestions) {
40
+ if (opts.json) {
41
+ const envelope = { error: msg, code };
42
+ if (suggestions?.length)
43
+ envelope.suggestions = suggestions;
44
+ process.stderr.write(JSON.stringify(envelope) + "\n");
45
+ }
46
+ else {
47
+ console.error(`Error: ${msg}`);
48
+ if (suggestions?.length) {
49
+ console.error(`Did you mean: ${suggestions.join(", ")}?`);
50
+ }
51
+ }
52
+ process.exit(exitCode);
53
+ }
7
54
  function printHelp() {
8
- console.log(`Usage: newsnow <source> [--json]
9
- newsnow list [--json]
55
+ console.log(`Usage: newsnow <source> [options]
56
+ newsnow list [--json | --output json] [--pretty]
57
+ newsnow schema [--pretty]
10
58
 
11
59
  Commands:
12
60
  list List all available sources
61
+ schema Print machine-readable JSON Schema for this CLI
13
62
  <source> Fetch news from the given source
14
63
 
15
64
  Options:
16
- --json Output as JSON
65
+ --json Output as JSON
66
+ --output json Alias for --json
67
+ --pretty Pretty-print JSON output
68
+ --fields f1,f2 Filter output fields (JSON mode only)
69
+ --limit N Limit number of items returned
17
70
 
18
71
  Sources: ${Object.keys(sources).length} available. Run "newsnow list" to see all.`);
19
72
  }
73
+ function buildSourceMeta() {
74
+ const envVarMap = {
75
+ producthunt: ["PRODUCTHUNT_API_TOKEN"],
76
+ };
77
+ return Object.keys(sources).sort().map(name => ({
78
+ name,
79
+ category: name.split("-")[0],
80
+ envVars: envVarMap[name] || [],
81
+ }));
82
+ }
20
83
  function printList() {
21
- const names = Object.keys(sources).sort();
22
- if (jsonFlag) {
23
- console.log(JSON.stringify(names, null, 2));
84
+ const metas = buildSourceMeta();
85
+ if (opts.json) {
86
+ const envelope = { count: metas.length, sources: metas };
87
+ jsonOut(envelope);
24
88
  }
25
89
  else {
26
- console.log(`Available sources (${names.length}):\n`);
90
+ console.log(`Available sources (${metas.length}):\n`);
27
91
  const groups = {};
28
- for (const name of names) {
29
- const base = name.split("-")[0];
30
- if (!groups[base])
31
- groups[base] = [];
32
- groups[base].push(name);
92
+ for (const m of metas) {
93
+ if (!groups[m.category])
94
+ groups[m.category] = [];
95
+ groups[m.category].push(m.name);
33
96
  }
34
97
  for (const [base, items] of Object.entries(groups).sort(([a], [b]) => a.localeCompare(b))) {
35
98
  if (items.length === 1) {
@@ -41,6 +104,74 @@ function printList() {
41
104
  }
42
105
  }
43
106
  }
107
+ function printSchema() {
108
+ const metas = buildSourceMeta();
109
+ const categories = {};
110
+ for (const m of metas) {
111
+ if (!categories[m.category])
112
+ categories[m.category] = [];
113
+ categories[m.category].push(m.name);
114
+ }
115
+ const schema = {
116
+ $schema: "https://json-schema.org/draft/2020-12/schema",
117
+ name: "newsnow",
118
+ commands: {
119
+ list: { description: "List all available news sources" },
120
+ schema: { description: "Print this JSON Schema" },
121
+ "<source>": { description: "Fetch news items from the named source" },
122
+ },
123
+ flags: {
124
+ "--json": { description: "Output as JSON" },
125
+ "--output": { description: "Output format", values: ["json"] },
126
+ "--pretty": { description: "Pretty-print JSON output" },
127
+ "--fields": { description: "Comma-separated list of fields to include", values: [...NEWS_ITEM_FIELDS] },
128
+ "--limit": { description: "Maximum number of items to return", type: "integer" },
129
+ },
130
+ categories,
131
+ $defs: {
132
+ NewsItem: {
133
+ type: "object",
134
+ properties: {
135
+ id: { type: ["string", "number"] },
136
+ title: { type: "string" },
137
+ url: { type: "string" },
138
+ mobileUrl: { type: "string" },
139
+ pubDate: { type: ["string", "number"] },
140
+ extra: { type: "object" },
141
+ },
142
+ required: ["id", "title"],
143
+ },
144
+ FetchEnvelope: {
145
+ type: "object",
146
+ properties: {
147
+ source: { type: "string" },
148
+ count: { type: "integer" },
149
+ items: { type: "array", items: { $ref: "#/$defs/NewsItem" } },
150
+ },
151
+ required: ["source", "count", "items"],
152
+ },
153
+ ListEnvelope: {
154
+ type: "object",
155
+ properties: {
156
+ count: { type: "integer" },
157
+ sources: {
158
+ type: "array",
159
+ items: {
160
+ type: "object",
161
+ properties: {
162
+ name: { type: "string" },
163
+ category: { type: "string" },
164
+ envVars: { type: "array", items: { type: "string" } },
165
+ },
166
+ },
167
+ },
168
+ },
169
+ required: ["count", "sources"],
170
+ },
171
+ },
172
+ };
173
+ console.log(opts.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema));
174
+ }
44
175
  function suggestSimilar(input) {
45
176
  const names = Object.keys(sources);
46
177
  return names.filter(n => n.includes(input) || input.includes(n) || levenshtein(n, input) <= 3).slice(0, 5);
@@ -61,16 +192,35 @@ async function fetchSource(name) {
61
192
  const handler = sources[name];
62
193
  if (!handler) {
63
194
  const similar = suggestSimilar(name);
64
- console.error(`Error: Unknown source "${name}"`);
65
- if (similar.length) {
66
- console.error(`Did you mean: ${similar.join(", ")}?`);
195
+ emitError(`Unknown source "${name}"`, "UNKNOWN_SOURCE", 1, similar.length ? similar : undefined);
196
+ }
197
+ // Validate --fields before fetching
198
+ if (opts.fields.length) {
199
+ const validFields = new Set(NEWS_ITEM_FIELDS);
200
+ const invalid = opts.fields.filter(f => !validFields.has(f));
201
+ if (invalid.length) {
202
+ emitError(`Invalid field(s): ${invalid.join(", ")}. Valid fields: ${NEWS_ITEM_FIELDS.join(", ")}`, "INVALID_FIELD", 1);
67
203
  }
68
- process.exit(1);
69
204
  }
70
205
  try {
71
- const items = await handler();
72
- if (jsonFlag) {
73
- console.log(JSON.stringify(items, null, 2));
206
+ let items = await handler();
207
+ if (opts.limit !== undefined && opts.limit > 0) {
208
+ items = items.slice(0, opts.limit);
209
+ }
210
+ if (opts.json) {
211
+ let projected = items;
212
+ if (opts.fields.length) {
213
+ projected = items.map(item => {
214
+ const picked = {};
215
+ for (const f of opts.fields) {
216
+ if (f in item)
217
+ picked[f] = item[f];
218
+ }
219
+ return picked;
220
+ });
221
+ }
222
+ const envelope = { source: name, count: projected.length, items: projected };
223
+ jsonOut(envelope);
74
224
  }
75
225
  else {
76
226
  if (!items.length) {
@@ -89,16 +239,18 @@ async function fetchSource(name) {
89
239
  }
90
240
  }
91
241
  catch (err) {
92
- console.error(`Error fetching "${name}": ${err.message}`);
93
- process.exit(1);
242
+ emitError(`Fetch failed for "${name}": ${err.message}`, "FETCH_ERROR", 2);
94
243
  }
95
244
  }
96
- if (!command || command === "help" || command === "--help" || command === "-h") {
245
+ if (!opts.command || opts.command === "help" || opts.command === "--help" || opts.command === "-h") {
97
246
  printHelp();
98
247
  }
99
- else if (command === "list") {
248
+ else if (opts.command === "list") {
100
249
  printList();
101
250
  }
251
+ else if (opts.command === "schema") {
252
+ printSchema();
253
+ }
102
254
  else {
103
- fetchSource(command);
255
+ fetchSource(opts.command);
104
256
  }
@@ -0,0 +1,57 @@
1
+ import { load } from "cheerio";
2
+ import { myFetch } from "../fetch.js";
3
+ import { parseRelativeDate } from "../utils.js";
4
+ const quick = async () => {
5
+ const baseURL = "https://www.36kr.com";
6
+ const url = `${baseURL}/newsflashes`;
7
+ const response = await myFetch(url);
8
+ const $ = load(response);
9
+ const news = [];
10
+ const $items = $(".newsflash-item");
11
+ $items.each((_, el) => {
12
+ const $el = $(el);
13
+ const $a = $el.find("a.item-title");
14
+ const url = $a.attr("href");
15
+ const title = $a.text();
16
+ const relativeDate = $el.find(".time").text();
17
+ if (url && title && relativeDate) {
18
+ news.push({
19
+ url: `${baseURL}${url}`,
20
+ title,
21
+ id: url,
22
+ extra: { date: parseRelativeDate(relativeDate, "Asia/Shanghai").valueOf() },
23
+ });
24
+ }
25
+ });
26
+ return news;
27
+ };
28
+ const renqi = async () => {
29
+ const url = "https://gateway.36kr.com/api/mis/nav/home/nav/rank/hot";
30
+ const response = await myFetch(url, {
31
+ method: "POST",
32
+ headers: {
33
+ "Content-Type": "application/json",
34
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
35
+ },
36
+ body: {
37
+ partner_id: "wap",
38
+ param: { siteId: 1, platformId: 2 },
39
+ timestamp: Date.now(),
40
+ },
41
+ });
42
+ const items = response?.data?.hotRankList ?? [];
43
+ return items.map((item) => {
44
+ const m = item.templateMaterial ?? {};
45
+ return {
46
+ url: `https://36kr.com/p/${item.itemId}`,
47
+ title: m.widgetTitle ?? "",
48
+ id: String(item.itemId),
49
+ extra: { info: m.authorName },
50
+ };
51
+ }).filter((item) => item.title);
52
+ };
53
+ export default {
54
+ "36kr": quick,
55
+ "36kr-quick": quick,
56
+ "36kr-renqi": renqi,
57
+ };
@@ -16,7 +16,14 @@ const hotSearch = async () => {
16
16
  };
17
17
  const hotVideo = async () => {
18
18
  const url = "https://api.bilibili.com/x/web-interface/popular";
19
- const res = await myFetch(url);
19
+ const res = await myFetch(url, {
20
+ headers: {
21
+ "Referer": "https://www.bilibili.com",
22
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
23
+ },
24
+ });
25
+ if (!res?.data?.list)
26
+ throw new Error(`Bilibili popular API returned unexpected response: ${JSON.stringify(res).slice(0, 200)}`);
20
27
  return res.data.list.map(video => ({
21
28
  id: video.bvid,
22
29
  title: video.title,
@@ -31,7 +38,14 @@ const hotVideo = async () => {
31
38
  };
32
39
  const ranking = async () => {
33
40
  const url = "https://api.bilibili.com/x/web-interface/ranking/v2";
34
- const res = await myFetch(url);
41
+ const res = await myFetch(url, {
42
+ headers: {
43
+ "Referer": "https://www.bilibili.com/ranking/all",
44
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
45
+ },
46
+ });
47
+ if (!res?.data?.list)
48
+ throw new Error(`Bilibili ranking API returned unexpected response: ${JSON.stringify(res).slice(0, 200)}`);
35
49
  return res.data.list.map(video => ({
36
50
  id: video.bvid,
37
51
  title: video.title,
@@ -7,11 +7,11 @@ const express = async () => {
7
7
  const $main = $(".news-list");
8
8
  const news = [];
9
9
  $main.each((_, el) => {
10
- const a = $(el).find(".title_name");
11
- const url = a.attr("href");
12
- const titleText = a.text();
10
+ const $el = $(el);
11
+ const titleText = $el.find(".title_name").text();
13
12
  const title = titleText.match(/【(.+)】/)?.[1] ?? titleText;
14
- const date = $(el).attr("data-date");
13
+ const url = $el.find(".shear_box").attr("data-href");
14
+ const date = $el.attr("data-date");
15
15
  if (url && title && date) {
16
16
  news.push({
17
17
  url: baseURL + url,
@@ -1,4 +1,4 @@
1
- import _36kr from "./_36kr.js";
1
+ import _36kr from "./36kr.js";
2
2
  import baidu from "./baidu.js";
3
3
  import bilibili from "./bilibili.js";
4
4
  import cankaoxiaoxi from "./cankaoxiaoxi.js";
@@ -1,28 +1,34 @@
1
1
  import { myFetch } from "../fetch.js";
2
- const hot = async () => {
3
- const res = await myFetch("https://linux.do/top/daily.json", {
4
- headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36" },
2
+ import * as cheerio from "cheerio";
3
+ function parseDiscourseRSS(xml) {
4
+ const $ = cheerio.load(xml, { xmlMode: true });
5
+ const items = [];
6
+ $("item").each((_, el) => {
7
+ const pinned = $(el).find("discourse\\:topicPinned").text();
8
+ const archived = $(el).find("discourse\\:topicArchived").text();
9
+ if (pinned === "Yes" || archived === "Yes")
10
+ return;
11
+ const title = $(el).find("title").text();
12
+ const link = $(el).find("link").text();
13
+ const pubDate = $(el).find("pubDate").text();
14
+ if (title && link) {
15
+ items.push({
16
+ id: link,
17
+ title,
18
+ url: link,
19
+ pubDate: pubDate ? new Date(pubDate).getTime() : undefined,
20
+ });
21
+ }
5
22
  });
6
- return res.topic_list.topics
7
- .filter(k => k.visible && !k.archived && !k.pinned)
8
- .map(k => ({
9
- id: k.id,
10
- title: k.title,
11
- url: `https://linux.do/t/topic/${k.id}`,
12
- }));
23
+ return items;
24
+ }
25
+ const hot = async () => {
26
+ const xml = await myFetch("https://linux.do/top/daily.rss");
27
+ return parseDiscourseRSS(xml);
13
28
  };
14
29
  const latest = async () => {
15
- const res = await myFetch("https://linux.do/latest.json?order=created", {
16
- headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36" },
17
- });
18
- return res.topic_list.topics
19
- .filter(k => k.visible && !k.archived && !k.pinned)
20
- .map(k => ({
21
- id: k.id,
22
- title: k.title,
23
- pubDate: new Date(k.created_at).valueOf(),
24
- url: `https://linux.do/t/topic/${k.id}`,
25
- }));
30
+ const xml = await myFetch("https://linux.do/latest.rss");
31
+ return parseDiscourseRSS(xml);
26
32
  };
27
33
  export default {
28
34
  "linuxdo": latest,
@@ -1,5 +1,5 @@
1
1
  import { fetchRSS } from "../rss.js";
2
2
  export default {
3
3
  "pcbeta-windows11": () => fetchRSS("https://bbs.pcbeta.com/forum.php?mod=rss&fid=563&auth=0"),
4
- "pcbeta-windows": () => fetchRSS("https://bbs.pcbeta.com/forum.php?mod=rss&fid=521&auth=0"),
4
+ "pcbeta-windows": () => fetchRSS("https://bbs.pcbeta.com/forum.php?mod=rss&fid=548&auth=0"),
5
5
  };
@@ -31,10 +31,30 @@ const hotSearch = async () => {
31
31
  module_strategy_id: {},
32
32
  sub_module_id: "20251106065177",
33
33
  flip_params: {
34
+ folding_screen_show_num: "",
35
+ is_mvl: "1",
36
+ mvl_strategy_info: JSON.stringify({
37
+ default_strategy_id: "06755800b45b49238582a6fa1ad0f5c5",
38
+ default_version: "3836",
39
+ hit_page_uuid: "b5080d97dc694a5fb50eb9e7c99326ac",
40
+ hit_tab_info: null,
41
+ gray_status_info: null,
42
+ bypass_to_un_exp_id: "",
43
+ }),
44
+ mvl_sub_mod_id: "20251106065177",
45
+ pad_post_show_num: "",
46
+ pad_pro_post_show_num: "",
47
+ pad_pro_small_hor_pic_display_num: "",
48
+ pad_small_hor_pic_display_num: "",
34
49
  page_id: "scms_shake",
35
50
  page_num: "0",
36
51
  page_type: "scms_shake",
52
+ post_show_num: "",
53
+ shake_size: "",
54
+ small_hor_pic_display_num: "",
37
55
  source_key: "100113",
56
+ un_policy_id: "06755800b45b49238582a6fa1ad0f5c5",
57
+ un_strategy_id: "06755800b45b49238582a6fa1ad0f5c5",
38
58
  },
39
59
  relace_children_key: [],
40
60
  },
@@ -1,17 +1,4 @@
1
- import * as cheerio from "cheerio";
2
- import { myFetch } from "../fetch.js";
3
- async function handler() {
4
- const baseURL = "https://post.smzdm.com/hot_1/";
5
- const html = await myFetch(baseURL);
6
- const $ = cheerio.load(html);
7
- const $main = $("#feed-main-list .z-feed-title");
8
- const news = [];
9
- $main.each((_, el) => {
10
- const a = $(el).find("a");
11
- const url = a.attr("href");
12
- const title = a.text();
13
- news.push({ url, title, id: url });
14
- });
15
- return news;
16
- }
17
- export default { smzdm: handler };
1
+ import { fetchRSS } from "../rss.js";
2
+ export default {
3
+ smzdm: () => fetchRSS("https://post.smzdm.com/feed/"),
4
+ };
package/dist/src/types.js CHANGED
@@ -1 +1 @@
1
- export {};
1
+ export const NEWS_ITEM_FIELDS = ["id", "title", "url", "mobileUrl", "pubDate", "extra"];
@@ -1,5 +1,13 @@
1
1
  import { describe, test, expect } from "bun:test";
2
2
  import { sources } from "../src/sources/index.js";
3
+ const cwd = import.meta.dir + "/..";
4
+ function run(args) {
5
+ return Bun.spawn(["bun", "src/cli.ts", ...args], {
6
+ cwd,
7
+ stdout: "pipe",
8
+ stderr: "pipe",
9
+ });
10
+ }
3
11
  describe("registry", () => {
4
12
  test("has sources registered", () => {
5
13
  const names = Object.keys(sources);
@@ -21,32 +29,85 @@ describe("registry", () => {
21
29
  });
22
30
  });
23
31
  describe("cli", () => {
24
- test("list command outputs sources", async () => {
25
- const proc = Bun.spawn(["bun", "src/cli.ts", "list", "--json"], {
26
- cwd: import.meta.dir + "/..",
27
- stdout: "pipe",
28
- });
29
- const output = await new Response(proc.stdout).text();
30
- const names = JSON.parse(output);
31
- expect(Array.isArray(names)).toBe(true);
32
- expect(names.length).toBeGreaterThan(40);
33
- expect(names).toContain("hackernews");
32
+ test("list --json returns ListEnvelope", async () => {
33
+ const proc = run(["list", "--json"]);
34
+ const output = await new Response(proc.stdout).text();
35
+ const envelope = JSON.parse(output);
36
+ expect(envelope).toHaveProperty("count");
37
+ expect(envelope).toHaveProperty("sources");
38
+ expect(Array.isArray(envelope.sources)).toBe(true);
39
+ expect(envelope.count).toBeGreaterThan(40);
40
+ expect(envelope.sources[0]).toHaveProperty("name");
41
+ expect(envelope.sources[0]).toHaveProperty("category");
42
+ expect(envelope.sources[0]).toHaveProperty("envVars");
43
+ });
44
+ test("--output json alias works", async () => {
45
+ const proc = run(["list", "--output", "json"]);
46
+ const output = await new Response(proc.stdout).text();
47
+ const envelope = JSON.parse(output);
48
+ expect(envelope).toHaveProperty("count");
49
+ expect(envelope).toHaveProperty("sources");
34
50
  });
35
51
  test("help command works", async () => {
36
- const proc = Bun.spawn(["bun", "src/cli.ts", "--help"], {
37
- cwd: import.meta.dir + "/..",
38
- stdout: "pipe",
39
- });
52
+ const proc = run(["--help"]);
40
53
  const output = await new Response(proc.stdout).text();
41
54
  expect(output).toContain("Usage:");
55
+ expect(output).toContain("--fields");
56
+ expect(output).toContain("--limit");
57
+ expect(output).toContain("schema");
42
58
  });
43
- test("unknown source shows error", async () => {
44
- const proc = Bun.spawn(["bun", "src/cli.ts", "nonexistent_xyz"], {
45
- cwd: import.meta.dir + "/..",
46
- stderr: "pipe",
47
- });
59
+ test("schema command returns valid JSON Schema", async () => {
60
+ const proc = run(["schema"]);
61
+ const output = await new Response(proc.stdout).text();
62
+ const schema = JSON.parse(output);
63
+ expect(schema).toHaveProperty("$schema");
64
+ expect(schema).toHaveProperty("commands");
65
+ expect(schema).toHaveProperty("flags");
66
+ expect(schema).toHaveProperty("categories");
67
+ expect(schema).toHaveProperty("$defs");
68
+ expect(schema.$defs).toHaveProperty("NewsItem");
69
+ expect(schema.$defs).toHaveProperty("FetchEnvelope");
70
+ expect(schema.$defs).toHaveProperty("ListEnvelope");
71
+ });
72
+ test("unknown source shows error with exitCode", async () => {
73
+ const proc = run(["nonexistent_xyz"]);
48
74
  const err = await new Response(proc.stderr).text();
49
75
  expect(err).toContain("Unknown source");
76
+ const code = await proc.exited;
77
+ expect(code).toBe(1);
78
+ });
79
+ test("unknown source --json emits ErrorEnvelope to stderr", async () => {
80
+ const proc = run(["nonexistent_xyz", "--json"]);
81
+ const err = await new Response(proc.stderr).text();
82
+ const envelope = JSON.parse(err);
83
+ expect(envelope).toHaveProperty("error");
84
+ expect(envelope.code).toBe("UNKNOWN_SOURCE");
85
+ const code = await proc.exited;
86
+ expect(code).toBe(1);
87
+ });
88
+ test("--fields with invalid field returns INVALID_FIELD error", async () => {
89
+ const proc = run(["hackernews", "--json", "--fields", "bogus"]);
90
+ const err = await new Response(proc.stderr).text();
91
+ const envelope = JSON.parse(err);
92
+ expect(envelope.code).toBe("INVALID_FIELD");
93
+ expect(envelope.error).toContain("bogus");
94
+ const code = await proc.exited;
95
+ expect(code).toBe(1);
96
+ });
97
+ test("--pretty produces indented output", async () => {
98
+ const proc = run(["list", "--json", "--pretty"]);
99
+ const output = await new Response(proc.stdout).text();
100
+ expect(output).toContain("\n ");
101
+ // Should still parse as valid JSON
102
+ const parsed = JSON.parse(output);
103
+ expect(parsed).toHaveProperty("count");
104
+ });
105
+ test("compact JSON is default (no indentation)", async () => {
106
+ const proc = run(["list", "--json"]);
107
+ const output = await new Response(proc.stdout).text();
108
+ // Compact JSON is a single line
109
+ const lines = output.trim().split("\n");
110
+ expect(lines.length).toBe(1);
50
111
  });
51
112
  });
52
113
  describe("fetch source", () => {
@@ -58,4 +119,23 @@ describe("fetch source", () => {
58
119
  expect(items[0]).toHaveProperty("id");
59
120
  expect(items[0]).toHaveProperty("url");
60
121
  }, 15000);
122
+ test("--fields filters output fields", async () => {
123
+ const proc = run(["hackernews", "--json", "--fields", "id,title", "--limit", "3"]);
124
+ const output = await new Response(proc.stdout).text();
125
+ const envelope = JSON.parse(output);
126
+ expect(envelope.source).toBe("hackernews");
127
+ expect(envelope.count).toBeLessThanOrEqual(3);
128
+ for (const item of envelope.items) {
129
+ expect(item).toHaveProperty("id");
130
+ expect(item).toHaveProperty("title");
131
+ expect(item).not.toHaveProperty("url");
132
+ }
133
+ }, 15000);
134
+ test("--limit caps item count", async () => {
135
+ const proc = run(["hackernews", "--json", "--limit", "2"]);
136
+ const output = await new Response(proc.stdout).text();
137
+ const envelope = JSON.parse(output);
138
+ expect(envelope.count).toBeLessThanOrEqual(2);
139
+ expect(envelope.items.length).toBeLessThanOrEqual(2);
140
+ }, 15000);
61
141
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newsnow",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "newsnow": "./dist/src/cli.js"
@@ -12,7 +12,7 @@
12
12
  "start": "bun src/cli.ts",
13
13
  "test": "bun test",
14
14
  "build": "tsc",
15
- "release": "bun run build && npm publish"
15
+ "release": "bun scripts/release.ts"
16
16
  },
17
17
  "dependencies": {
18
18
  "cheerio": "^1.0.0",
@@ -24,5 +24,6 @@
24
24
  "@types/bun": "latest",
25
25
  "@types/node": "^22.0.0",
26
26
  "typescript": "^5.7.0"
27
- }
27
+ },
28
+ "license": "MIT"
28
29
  }