Commit 71c850ae authored by 谢宇轩's avatar 谢宇轩

feat: edit bloomberg bundles

parent 7dfdb0e2
...@@ -17,6 +17,10 @@ node_modules/ ...@@ -17,6 +17,10 @@ node_modules/
# authored locally — the bundle is the committed unit, not the helper. # authored locally — the bundle is the committed unit, not the helper.
helpers/ helpers/
# User preferences and research reports (news-hub-feed)
skills/news-hub-feed/profile.json
skills/news-hub-feed/reports/
# IDE / agent workspace files # IDE / agent workspace files
.zcode/ .zcode/
AGENTS.md AGENTS.md
--- ---
name: news-hub-feed name: news-hub-feed
description: Search, browse, and annotate News Hub articles via the News Hub API. Use this skill whenever the user wants to discover trending news, search articles by keyword/source/category/tags/marks, read an article's full detail (summary, original, translation), or add/remove key-value marks for later retrieval and analysis. A lightweight read + annotate client — no harvesting, no pushing, no browser. Triggers on phrases like "检索新闻", "搜索文章", "查看文章", "文章详情", "添加标记", "标注", "热点", "热门", "hot news", "trending", "search articles", "article detail", "marks", "discover trending", "标签列表", "按标记检索". description: Search, browse, and annotate News Hub articles via the News Hub API. Use this skill whenever the user wants to discover trending news, get a daily briefing, search articles by keyword/source/category/tags/marks, read an article's full detail (summary, original, translation), add/remove key-value marks for later retrieval, generate research reports on hot topics, or manage user interest preferences. A lightweight read + annotate + briefing client — no harvesting, no pushing, no browser. Triggers on phrases like "检索新闻", "搜索文章", "查看文章", "文章详情", "添加标记", "标注", "热点", "热门", "每日简报", "今天有什么新闻", "daily briefing", "热点推荐", "研究报告", "research report", "选题", "追踪事件", "我的偏好", "兴趣领域", "hot news", "trending", "search articles", "article detail", "marks", "discover trending", "标签列表", "按标记检索".
--- ---
# News Hub Feed Skill # News Hub Feed Skill
Search and browse articles stored in News Hub, read full article details (summary / original / translation), and manage key-value **marks** for annotation. This skill is a **pure API consumer** — it never harvests, pushes, or launches a browser. Its sole purpose is rapid hot-topic discovery and post-hoc annotation for later retrieval and analysis. Search and browse articles stored in News Hub, read full article details (summary / original / translation), manage key-value **marks** for annotation, get **daily briefings** with hot-topic discovery, and generate **research reports**. This skill is a **pure API consumer** — it never harvests, pushes, or launches a browser. Its purpose is rapid hot-topic discovery, personalized recommendations, and post-hoc annotation for later retrieval and analysis.
> **Skill root** = the directory containing this `SKILL.md`. Prefix every command with `cd "<skill-root>" &&`, or scripts fail with `MODULE_NOT_FOUND`. > **Skill root** = the directory containing this `SKILL.md`. Prefix every command with `cd "<skill-root>" &&`, or scripts fail with `MODULE_NOT_FOUND`.
...@@ -13,20 +13,31 @@ Search and browse articles stored in News Hub, read full article details (summar ...@@ -13,20 +13,31 @@ Search and browse articles stored in News Hub, read full article details (summar
| Task | Command | | Task | Command |
|------|---------| |------|---------|
| **Daily briefing (7-day hot topics)** | `node scripts/digest.js --json` |
| **Briefing (human-readable)** | `node scripts/digest.js` |
| Check connectivity + scopes | `node scripts/check.js` | | Check connectivity + scopes | `node scripts/check.js` |
| Search articles (hot topics) | `node scripts/search.js --q <keyword> [--days N] [--source csv] [--category csv] [--tags csv] [--marks csv]` | | Search articles (hot topics) | `node scripts/search.js --q <keyword> [--days N] [--source csv] [--category csv] [--tags csv] [--marks csv]` |
| Search with facets | `node scripts/search.js --q 利率 --days 7 --sort relevance` | | Search with facets | `node scripts/search.js --q 利率 --days 7 --sort relevance` |
| Article detail | `node scripts/article.js <id> [--fields summary,original,translation]` | | Article detail | `node scripts/article.js <id> [--fields summary,original,translation]` |
| Add a mark | `node scripts/marks.js add <id> <key> [value]` | | Add a mark | `node scripts/marks.js add <id> <key> [value]` |
| Add marks (batch) | `node scripts/marks.js add <id> --json '[{"key":"已读","value":"是"},{"key":"重要","value":"高"}]'` | | Add marks (batch) | `node scripts/marks.js add <id> --json '[{"key":"已读","value":"是"}]'` |
| Remove a mark | `node scripts/marks.js remove <id> <key>` | | Remove a mark | `node scripts/marks.js remove <id> <key>` |
| List article marks | `node scripts/marks.js list <id>` | | List article marks | `node scripts/marks.js list <id>` |
| List mark keys | `node scripts/marks.js keys` | | List mark keys | `node scripts/marks.js keys` |
| List mark values | `node scripts/marks.js values <key>` | | List mark values | `node scripts/marks.js values <key>` |
| Search by marks | `node scripts/marks.js marked --marks 已读:是,重要:高` | | Search by marks | `node scripts/marks.js marked --marks 已读:是,重要:高` |
| List all tags (read-only) | `node scripts/tags.js` | | List all tags (read-only) | `node scripts/tags.js` |
| Show user preferences | `node scripts/profile.js show` |
> **All commands must be prefixed with `cd "<skill-root>" &&`**. Scripts live in `scripts/` — always reference them as `node scripts/search.js`, never `node search.js`. | Add interest category | `node scripts/profile.js add-category <csv>` |
| Add interest tags | `node scripts/profile.js add-tags <csv>` |
| Add interest keyword | `node scripts/profile.js add-keyword <keyword>` |
| Track an event | `node scripts/profile.js track "事件名" --keywords csv --category <cat>` |
| Stop tracking | `node scripts/profile.js untrack <id>` |
| View briefing history | `node scripts/profile.js history` |
| Batch fetch articles (report) | `node scripts/report.js fetch <id1,id2,...> --json` |
| Save research report | `node scripts/report.js save --title <title> --articles <csv> --stdin` |
> **All commands must be prefixed with `cd "<skill-root>" &&`**. Scripts live in `scripts/` — always reference them as `node scripts/digest.js`, never `node digest.js`.
--- ---
...@@ -85,7 +96,7 @@ This skill only needs the `api` block in `config.json` (no chromium, no vault, n ...@@ -85,7 +96,7 @@ This skill only needs the `api` block in `config.json` (no chromium, no vault, n
## Workflow ## Workflow
The core loop: **discover read annotate**. The core loops: **discover read annotate** and **briefing research refine**.
### Flow A Discover hot topics (search.js) ### Flow A Discover hot topics (search.js)
...@@ -140,12 +151,6 @@ node scripts/marks.js remove 101 重要 ...@@ -140,12 +151,6 @@ node scripts/marks.js remove 101 重要
# 查看某篇文章的所有标记 # 查看某篇文章的所有标记
node scripts/marks.js list 101 node scripts/marks.js list 101
# 查看当前 App 用过哪些标记键
node scripts/marks.js keys
# 查看某个键下用过哪些值
node scripts/marks.js values 重要
# 按标记检索(找出所有标记为"重要:高"的文章) # 按标记检索(找出所有标记为"重要:高"的文章)
node scripts/marks.js marked --marks 重要:高 --page-size 50 node scripts/marks.js marked --marks 重要:高 --page-size 50
``` ```
...@@ -154,6 +159,173 @@ Marks are **App-isolated** key-value pairs — only the current App's marks are ...@@ -154,6 +159,173 @@ Marks are **App-isolated** key-value pairs — only the current App's marks are
--- ---
## Daily Briefing(每日简报)
**Trigger**: when the user asks "今天有什么新闻", "每日简报", "热点推荐", "daily briefing", or starts a session wanting to catch up on news.
The daily briefing is an **interactive agent-led workflow**. Scripts handle data collection; the agent handles presentation, conversation, and follow-up.
### Step 1. Collect briefing data
```bash
node scripts/digest.js --json
```
This fetches articles from the last 7 days (up to 500 for analysis), computes:
- **Top categories** (Top 5, with 3 latest articles each)
- **Top tags** (Top 10)
- **Top keywords** (extracted from titles, Top 10)
- **Hot articles** (latest 10)
- **Interest matches** — articles matching the user's saved interests (categories/tags/keywords)
- **Tracked event matches** — articles matching keywords of events the user is tracking
The script also records this briefing in `profile.json` → `briefingHistory`.
> Use `--days N` to change the lookback window. Use `--json` for agent consumption (recommended); omit for human-readable output.
### Step 2. Present the briefing
The agent reads the JSON output and presents a **personalized summary** to the user:
- Highlight the hottest categories and trending keywords
- If the user has saved interests, prioritize those matches
- If tracked events have new articles, call them out explicitly
- Keep it concise — this is a briefing, not a deep dive
### Step 3. Ask the user what to do next
Present clear options:
1. **继续浏览** — search more articles or adjust filters
2. **查看详情** — read full article details (give the agent article IDs)
3. **选题做研究报告** — pick a topic and generate a research report (see below)
4. **更新偏好** — add/remove interest categories, tags, keywords, or tracked events
5. **标记文章** — mark articles as 已读/重要/跟进 etc.
### Step 4. Update preferences (optional)
After the briefing, proactively ask if the user wants to:
- Save a trending topic as a tracked event
- Add new categories/tags/keywords to their interests
```bash
# Track a new event
node scripts/profile.js track "美联储 9 月议息会议" --keywords 美联储,加息,降息,FOMC --category 财经
# Add interest category
node scripts/profile.js add-category 科技/AI,中美关系
```
> **Personalization**: next time the briefing runs, `digest.js` automatically matches the user's saved interests and tracked events, making recommendations more relevant over time.
---
## Research Report(研究报告)
**Trigger**: when the user picks a topic from the briefing (or any search) and wants a deep-dive report.
### Step 1. Identify relevant article IDs
From the briefing or search results, the agent identifies articles related to the chosen topic. The user may also specify additional keywords or articles.
### Step 2. Batch-fetch article details
```bash
node scripts/report.js fetch 101,98,95,89 --json
```
Fetches full article details (summary + translation by default) for all specified IDs. Use `--fields summary,original,translation` to also get the original text. The `--json` output is an array of article objects ready for the agent to synthesize.
### Step 3. Agent synthesizes the report
The agent reads the fetched articles and writes a structured Markdown report:
```markdown
# <主题标题>
## 事件概述
<1-2 段概括事件核心>
## 背景分析
<事件背景、来龙去脉>
## 关键信息
- 来自 #101: <关键点>
- 来自 #98: <关键点>
- ...
## 影响分析
<对相关领域/市场/地缘格局的潜在影响>
## 涉及文章
- #101 <标题><来源><日期>
- #98 <标题><来源><日期>
```
### Step 4. Save the report
```bash
echo '<markdown content>' | node scripts/report.js save --title "美联储 9 月议息会议前瞻" --articles 101,98,95,89 --stdin
```
The script adds frontmatter (date, title, article IDs, timestamp) and saves to `reports/YYYY-MM-DD_<title>.md`. The briefing history entry for today is updated with the report path.
> The agent can also write the Markdown to a temp file and use `--content <path>` instead of `--stdin`.
---
## User Preferences(用户偏好)
User preferences are stored in `profile.json` (gitignored, auto-generated on first use). They power the personalized recommendations in daily briefings.
### Profile structure
```json
{
"interests": {
"categories": ["财经", "中美关系"],
"tags": ["利率", "AI", "央行"],
"keywords": ["美联储", "半导体"]
},
"trackedEvents": [
{
"id": "evt-001",
"label": "美联储 9 月议息会议",
"keywords": ["美联储", "加息", "降息", "FOMC"],
"category": "财经",
"addedAt": "2026-08-13T10:00:00Z",
"lastCheckedAt": "2026-08-13T10:00:00Z"
}
],
"briefingHistory": [
{ "date": "2026-08-13", "totalArticles": 42, "topics": ["..."] }
]
}
```
### Managing preferences
```bash
# View current preferences
node scripts/profile.js show
# Add interests
node scripts/profile.js add-category 财经,中美关系
node scripts/profile.js add-tags 利率,央行,AI
node scripts/profile.js add-keyword 美联储
# Track an event (agent should proactively suggest this after briefings)
node scripts/profile.js track "美联储 9 月议息会议" --keywords 美联储,加息,降息,FOMC --category 财经
# Stop tracking
node scripts/profile.js untrack evt-001
# View briefing history
node scripts/profile.js history
```
> **Agent responsibility**: proactively suggest tracking events when the user shows interest in a topic during briefings or report generation. The more preferences the user accumulates, the better the personalized recommendations become.
---
## Marks vs Tags ## Marks vs Tags
| | Marks | Tags | | | Marks | Tags |
......
...@@ -20,6 +20,8 @@ const path = require('path'); ...@@ -20,6 +20,8 @@ const path = require('path');
const SKILL_DIR = path.dirname(__dirname); // .../skills/news-hub-feed const SKILL_DIR = path.dirname(__dirname); // .../skills/news-hub-feed
const CONFIG_PATH = path.join(SKILL_DIR, 'config.json'); const CONFIG_PATH = path.join(SKILL_DIR, 'config.json');
const TEMPLATE_PATH = path.join(SKILL_DIR, 'references', 'config-template.json'); const TEMPLATE_PATH = path.join(SKILL_DIR, 'references', 'config-template.json');
const PROFILE_PATH = path.join(SKILL_DIR, 'profile.json');
const REPORTS_DIR = path.join(SKILL_DIR, 'reports');
// Scopes this skill needs for full functionality. // Scopes this skill needs for full functionality.
const REQUIRED_SCOPES = ['articles:read', 'marks:read', 'marks:write']; const REQUIRED_SCOPES = ['articles:read', 'marks:read', 'marks:write'];
...@@ -307,10 +309,50 @@ function truncate(s, maxLen) { ...@@ -307,10 +309,50 @@ function truncate(s, maxLen) {
return s.slice(0, maxLen - 1) + '…'; return s.slice(0, maxLen - 1) + '…';
} }
/**
* Ensure a directory exists (recursive mkdir).
* @param {string} dir
*/
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Return today's date as YYYY-MM-DD (local timezone).
* @returns {string}
*/
function todayStr() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/**
* Convert a string into a safe filename component.
* Replaces spaces and special chars with underscores, keeps CJK chars.
* @param {string} s
* @returns {string}
*/
function sanitizeFilename(s) {
if (!s) return 'untitled';
return s
.replace(/[\/\\:*?"<>|]/g, '_')
.replace(/\s+/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.slice(0, 60) || 'untitled';
}
module.exports = { module.exports = {
SKILL_DIR, SKILL_DIR,
CONFIG_PATH, CONFIG_PATH,
TEMPLATE_PATH, TEMPLATE_PATH,
PROFILE_PATH,
REPORTS_DIR,
REQUIRED_SCOPES, REQUIRED_SCOPES,
loadConfig, loadConfig,
requireApi, requireApi,
...@@ -324,4 +366,7 @@ module.exports = { ...@@ -324,4 +366,7 @@ module.exports = {
parseMarksCsv, parseMarksCsv,
fmtTime, fmtTime,
truncate, truncate,
ensureDir,
todayStr,
sanitizeFilename,
}; };
'use strict';
/**
* digest.js — Daily briefing data collection for news-hub-feed.
*
* Fetches articles from the last N days (default 7), computes hot-topic
* rankings from facets + title keyword frequency, matches user interests
* and tracked events from profile.json, and outputs a structured briefing.
*
* Usage:
* node scripts/digest.js [--days N] [--page-size N] [--json]
*
* --json outputs structured JSON for agent consumption (recommended)
* text mode prints a human-readable briefing summary.
*
* Exported (for potential reuse):
* collectDigest(cfg, opts)
*/
const {
loadConfig,
requireApi,
apiRequest,
parseArgs,
fmtTime,
truncate,
todayStr,
} = require('./api-client');
const { loadProfile, saveProfile } = require('./profile');
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const MAX_PAGES = 5; // max pages to fetch for hot-topic analysis (5 * pageSize)
// Chinese stop-words for keyword extraction (high-frequency but meaningless)
const STOP_WORDS = new Set([
'的', '了', '在', '是', '和', '与', '或', '也', '都', '已', '将', '为',
'被', '对', '向', '从', '到', '于', '及', '等', '但', '而', '或', '若',
'这', '那', '其', '该', '此', '一个', '一些', '可以', '可能', '将',
'中', '后', '前', '上', '下', '不', '无', '有', '称', '说', '指',
'美国', '中国', '日本', '今日', '昨日', '今年', '去年', '报道', '消息',
'表示', '认为', '预计', '可能', '应该', '需要', '继续', '首次', '最新',
'新闻', '报道', '宣布', '发布', '显示', '此前', '随后', '目前', '正在',
'world', 'news', 'today', 'report',
]);
// ---------------------------------------------------------------------------
// Keyword extraction from titles
// ---------------------------------------------------------------------------
/**
* Extract candidate keywords from a batch of titles.
* Uses a simple n-gram approach: 2-4 char CJK substrings + latin words.
* @param {string[]} titles
* @param {number} topN
* @returns {Array<{keyword: string, count: number}>}
*/
function extractKeywords(titles, topN = 10) {
const freq = new Map();
for (const title of titles) {
if (!title) continue;
// Latin words (2+ chars)
const latinMatches = title.match(/[a-zA-Z]{2,}/g) || [];
for (const w of latinMatches) {
const lw = w.toLowerCase();
if (!STOP_WORDS.has(lw) && lw.length >= 2) {
freq.set(lw, (freq.get(lw) || 0) + 1);
}
}
// CJK 2-grams and 3-grams
const cjkChars = title.match(/[\u4e00-\u9fff]/g) || [];
const cjkText = cjkChars.join('');
// 2-grams
for (let i = 0; i < cjkText.length - 1; i++) {
const gram = cjkText.slice(i, i + 2);
if (!STOP_WORDS.has(gram) && !gram.match(/^[的是了在和]/)) {
freq.set(gram, (freq.get(gram) || 0) + 1);
}
}
// 3-grams (more specific, higher weight)
for (let i = 0; i < cjkText.length - 2; i++) {
const gram = cjkText.slice(i, i + 3);
if (!STOP_WORDS.has(gram)) {
freq.set(gram, (freq.get(gram) || 0) + 1);
}
}
}
// Filter: only keep keywords appearing 2+ times, sort by count desc
const entries = [...freq.entries()]
.filter(([, count]) => count >= 2)
.sort((a, b) => b[1] - a[1]);
// Deduplicate: prefer longer n-grams over shorter substrings when counts are equal.
// Sort by count desc, then by length desc (longer = more specific = preferred).
entries.sort((a, b) => b[1] - a[1] || b[0].length - a[0].length);
const result = [];
for (const [keyword, count] of entries) {
// Skip if this keyword is a substring of an already-selected one
// (e.g. skip "美联" if "美联储" already selected)
let isSubstring = false;
for (const { keyword: sel } of result) {
if (sel.includes(keyword) && sel !== keyword) {
isSubstring = true;
break;
}
}
if (isSubstring) continue;
result.push({ keyword, count });
if (result.length >= topN) break;
}
return result;
}
// ---------------------------------------------------------------------------
// Interest matching
// ---------------------------------------------------------------------------
/**
* Match articles against user interests (categories, tags, keywords).
* @param {Array} items — search result items
* @param {object} interests — { categories, tags, keywords }
* @returns {object} — { categories: [...], tags: [...], keywords: [...] }
*/
function matchInterests(items, interests) {
const result = { categories: [], tags: [], keywords: [] };
if (!interests) return result;
// Category matches
if (interests.categories && interests.categories.length > 0) {
for (const cat of interests.categories) {
const matched = items.filter(
(item) => item.category === cat
);
if (matched.length > 0) {
result.categories.push({
category: cat,
count: matched.length,
articles: matched.slice(0, 5).map(articleSummary),
});
}
}
}
// Tag matches
if (interests.tags && interests.tags.length > 0) {
for (const tag of interests.tags) {
const matched = items.filter(
(item) => item.tags && item.tags.includes(tag)
);
if (matched.length > 0) {
result.tags.push({
tag,
count: matched.length,
articles: matched.slice(0, 5).map(articleSummary),
});
}
}
}
// Keyword matches (in titleZh)
if (interests.keywords && interests.keywords.length > 0) {
for (const kw of interests.keywords) {
const matched = items.filter(
(item) => item.titleZh && item.titleZh.includes(kw)
);
if (matched.length > 0) {
result.keywords.push({
keyword: kw,
count: matched.length,
articles: matched.slice(0, 5).map(articleSummary),
});
}
}
}
return result;
}
/**
* Match articles against tracked events.
* @param {Array} items
* @param {Array} trackedEvents
* @returns {Array}
*/
function matchTrackedEvents(items, trackedEvents) {
const results = [];
if (!trackedEvents || trackedEvents.length === 0) return results;
for (const evt of trackedEvents) {
const keywords = evt.keywords || [];
if (keywords.length === 0) continue;
const matched = items.filter(
(item) =>
item.titleZh &&
keywords.some((kw) => item.titleZh.includes(kw))
);
if (matched.length > 0) {
results.push({
eventId: evt.id,
label: evt.label,
category: evt.category || '',
matchedCount: matched.length,
articles: matched.slice(0, 10).map(articleSummary),
});
}
}
return results;
}
function articleSummary(item) {
return {
articleId: item.articleId,
titleZh: item.titleZh || item.titleEn || '',
source: item.source || '',
category: item.category || '',
publishedAt: item.publishedAt || '',
summaryOverview: truncate(item.summaryOverview || '', 120),
};
}
// ---------------------------------------------------------------------------
// Core: collect digest data
// ---------------------------------------------------------------------------
/**
* Fetch articles from the last N days and compute briefing data.
* @param {object} cfg — config object
* @param {object} opts — { days, pageSize }
* @returns {Promise<object>} structured digest
*/
async function collectDigest(cfg, opts = {}) {
const days = opts.days || 7;
const pageSize = opts.pageSize || 100;
const from = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const publishedFrom = from.toISOString().replace(/\.\d{3}Z$/, 'Z');
// Fetch pages
const allItems = [];
let facets = null;
let total = 0;
let page = 1;
let maxPage = Math.min(MAX_PAGES, Math.ceil(500 / pageSize));
for (page = 1; page <= maxPage; page++) {
const body = {
publishedFrom,
sort: 'published_desc',
page,
pageSize,
};
const resp = await apiRequest(cfg, 'POST', '/articles/search', { body });
total = resp.total || 0;
if (!facets && resp.facets) facets = resp.facets;
allItems.push(...(resp.items || []));
// Stop if we've fetched everything or enough for analysis
if (allItems.length >= total || allItems.length >= 500) break;
if (!resp.items || resp.items.length < pageSize) break;
}
// Compute top categories with latest articles
const categoryMap = new Map(); // category → { count, articles: [] }
for (const item of allItems) {
const cat = item.category || '(未分类)';
if (!categoryMap.has(cat)) {
categoryMap.set(cat, { category: cat, count: 0, articles: [] });
}
const entry = categoryMap.get(cat);
entry.count++;
if (entry.articles.length < 3) {
entry.articles.push(articleSummary(item));
}
}
const topCategories = [...categoryMap.values()]
.sort((a, b) => b.count - a.count)
.slice(0, 5);
// Top tags from facets
const topTags = facets && facets.tags
? Object.entries(facets.tags)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([tag, count]) => ({ tag, count }))
: [];
// Top sources from facets
const topSources = facets && facets.source
? Object.entries(facets.source)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([source, count]) => ({ source, count }))
: [];
// Top keywords from titles
const titles = allItems.map((item) => item.titleZh || '');
const topKeywords = extractKeywords(titles, 10);
// Hot articles: latest 10 by publish time (already sorted desc)
const hotArticles = allItems.slice(0, 10).map(articleSummary);
// Load profile and match interests + tracked events
const profile = loadProfile();
const interestMatches = matchInterests(allItems, profile.interests);
const trackedEventMatches = matchTrackedEvents(allItems, profile.trackedEvents);
// Update lastCheckedAt for tracked events
if (profile.trackedEvents && profile.trackedEvents.length > 0) {
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
for (const evt of profile.trackedEvents) {
evt.lastCheckedAt = now;
}
saveProfile(profile);
}
const tagCount = facets && facets.tags ? Object.keys(facets.tags).length : 0;
return {
date: todayStr(),
days,
totalArticles: total,
fetchedArticles: allItems.length,
tagCount,
topCategories,
topTags,
topSources,
topKeywords,
hotArticles,
interestMatches,
trackedEventMatches,
profile: {
hasInterests:
(profile.interests.categories.length > 0) ||
(profile.interests.tags.length > 0) ||
(profile.interests.keywords.length > 0),
hasTrackedEvents: profile.trackedEvents.length > 0,
},
};
}
// ---------------------------------------------------------------------------
// Text output
// ---------------------------------------------------------------------------
function printDigest(digest) {
const { date, days, totalArticles, fetchedArticles, tagCount } = digest;
console.log();
console.log('═══════════════════════════════════════════════');
console.log(` 📰 每日简报 — ${date}(最近 ${days} 天)`);
console.log(` 共 ${totalArticles} 篇文章 | 涵盖 ${digest.topCategories.length} 个分类、${tagCount} 个标签`);
if (fetchedArticles < totalArticles) {
console.log(` (基于 ${fetchedArticles} 篇采样分析)`);
}
console.log('═══════════════════════════════════════════════');
// Top categories
console.log('\n── 🔥 热门分类 ──');
digest.topCategories.forEach((cat, i) => {
const latest = cat.articles
.map((a) => `#${a.articleId} ${truncate(a.titleZh, 30)}`)
.join(' / ');
console.log(` ${i + 1}. ${cat.category} (${cat.count})${latest ? ' ── ' + latest : ''}`);
});
// Top tags
if (digest.topTags.length > 0) {
console.log('\n── 🏷️ 热门标签 ──');
const tagStr = digest.topTags.map((t) => `${t.tag}(${t.count})`).join(' ');
console.log(` ${tagStr}`);
}
// Top keywords
if (digest.topKeywords.length > 0) {
console.log('\n── 🔑 热门关键词 ──');
const kwStr = digest.topKeywords.map((k) => `${k.keyword}(${k.count})`).join(' ');
console.log(` ${kwStr}`);
}
// Hot articles
if (digest.hotArticles.length > 0) {
console.log('\n── 📰 热点文章 ──');
for (const a of digest.hotArticles.slice(0, 10)) {
console.log(` [${a.category}] #${a.articleId} ${truncate(a.titleZh, 40)} (${fmtTime(a.publishedAt)})`);
}
}
// Interest matches
if (digest.profile.hasInterests) {
console.log('\n── ⭐ 你的兴趣领域 ──');
printInterestMatches(digest.interestMatches);
}
// Tracked events
if (digest.trackedEventMatches.length > 0) {
console.log('\n── 📌 追踪事件 ──');
for (const evt of digest.trackedEventMatches) {
console.log(` [${evt.eventId}] ${evt.label} (匹配 ${evt.matchedCount} 篇)`);
for (const a of evt.articles.slice(0, 3)) {
console.log(` → #${a.articleId} ${truncate(a.titleZh, 40)}`);
}
if (evt.articles.length > 3) {
console.log(` ... 还有 ${evt.articles.length - 3} 篇`);
}
}
} else if (digest.profile.hasTrackedEvents) {
console.log('\n── 📌 追踪事件 ──');
console.log(' (追踪事件在最近期间无匹配文章)');
}
// Suggestions
console.log('\n── 💡 建议操作 ──');
console.log(' · 查看文章详情: 告诉 agent 文章 ID');
console.log(' · 选题做研究报告: 告诉 agent 你想深入了解哪个话题');
console.log(' · 标记已读: 告诉 agent 对哪些文章标记已读');
console.log(' · 更新偏好: 告诉 agent 添加兴趣分类/标签/关键词或追踪事件');
console.log();
}
function printInterestMatches(matches) {
let hasAny = false;
for (const cat of matches.categories) {
hasAny = true;
console.log(` 分类「${cat.category}」: ${cat.count} 篇`);
for (const a of cat.articles.slice(0, 3)) {
console.log(` → #${a.articleId} ${truncate(a.titleZh, 40)}`);
}
}
for (const tag of matches.tags) {
hasAny = true;
console.log(` 标签「${tag.tag}」: ${tag.count} 篇`);
for (const a of tag.articles.slice(0, 3)) {
console.log(` → #${a.articleId} ${truncate(a.titleZh, 40)}`);
}
}
for (const kw of matches.keywords) {
hasAny = true;
console.log(` 关键词「${kw.keyword}」: ${kw.count} 篇`);
for (const a of kw.articles.slice(0, 3)) {
console.log(` → #${a.articleId} ${truncate(a.titleZh, 40)}`);
}
}
if (!hasAny) {
console.log(' (兴趣领域在最近期间无匹配文章)');
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const { flags } = parseArgs(process.argv.slice(2));
const cfg = loadConfig();
requireApi(cfg);
const days = flags.days ? parseInt(flags.days, 10) : 7;
const pageSize = flags['page-size'] ? parseInt(flags['page-size'], 10) : 100;
console.error(`正在采集最近 ${days} 天的新闻数据…`);
const digest = await collectDigest(cfg, { days, pageSize });
if (flags.json) {
process.stdout.write(JSON.stringify(digest, null, 2) + '\n');
} else {
printDigest(digest);
}
// Record briefing in profile history
const { loadProfile: lp, saveProfile: sp } = require('./profile');
const profile = lp();
const topics = digest.topCategories.slice(0, 3).map((c) => c.category);
const topCats = {};
for (const c of digest.topCategories.slice(0, 5)) {
topCats[c.category] = c.count;
}
profile.briefingHistory.unshift({
date: digest.date,
totalArticles: digest.totalArticles,
topCategories: topCats,
topics,
reportGenerated: '',
});
if (profile.briefingHistory.length > 30) {
profile.briefingHistory = profile.briefingHistory.slice(0, 30);
}
sp(profile);
}
module.exports = { collectDigest, extractKeywords, matchInterests, matchTrackedEvents };
if (require.main === module) {
main().catch((err) => {
console.error(`\n✗ ${err.message}`);
process.exit(1);
});
}
'use strict';
/**
* profile.js — User preference management for news-hub-feed.
*
* Stores interests (categories/tags/keywords), tracked events, and briefing
* history in profile.json (gitignored, auto-bootstrap).
*
* Usage:
* node scripts/profile.js show # 打印当前偏好
* node scripts/profile.js add-category <csv> # 添加兴趣分类
* node scripts/profile.js add-tags <csv> # 添加兴趣标签
* node scripts/profile.js add-keyword <keyword> # 添加兴趣关键词
* node scripts/profile.js track <label> [--keywords csv] [--category <cat>] # 追踪事件
* node scripts/profile.js untrack <id> # 停止追踪事件
* node scripts/profile.js history [--limit N] # 查看简报历史
* node scripts/profile.js --json # 输出原始 JSON
*
* Exported for use by digest.js / report.js:
* loadProfile(), saveProfile(), addBriefingHistory()
*/
const fs = require('fs');
const {
PROFILE_PATH,
parseArgs,
csvSplit,
fmtTime,
todayStr,
} = require('./api-client');
// ---------------------------------------------------------------------------
// Profile load / save
// ---------------------------------------------------------------------------
const EMPTY_PROFILE = {
interests: {
categories: [],
tags: [],
keywords: [],
},
trackedEvents: [],
briefingHistory: [],
updatedAt: '',
};
/**
* Load profile.json, auto-creating an empty one on first call.
* @returns {object}
*/
function loadProfile() {
if (!fs.existsSync(PROFILE_PATH)) {
saveProfile(EMPTY_PROFILE);
}
const profile = JSON.parse(fs.readFileSync(PROFILE_PATH, 'utf8'));
// Merge to ensure all fields exist (forward-compatible)
return {
interests: {
categories: profile.interests?.categories || [],
tags: profile.interests?.tags || [],
keywords: profile.interests?.keywords || [],
},
trackedEvents: profile.trackedEvents || [],
briefingHistory: profile.briefingHistory || [],
updatedAt: profile.updatedAt || '',
};
}
/**
* Save profile.json with updated timestamp.
* @param {object} profile
*/
function saveProfile(profile) {
profile.updatedAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
fs.writeFileSync(PROFILE_PATH, JSON.stringify(profile, null, 2) + '\n');
}
/**
* Add a briefing record to history (called by digest.js or agent).
* @param {object} entry — { date, totalArticles, topCategories, topics, reportGenerated }
*/
function addBriefingHistory(entry) {
const profile = loadProfile();
profile.briefingHistory.unshift(entry);
// Keep last 30 entries
if (profile.briefingHistory.length > 30) {
profile.briefingHistory = profile.briefingHistory.slice(0, 30);
}
saveProfile(profile);
}
// ---------------------------------------------------------------------------
// Display helpers
// ---------------------------------------------------------------------------
function cmdShow(profile) {
console.log('\n═══════════════════════════════════════════════');
console.log(' 用户偏好');
console.log('═══════════════════════════════════════════════\n');
const { interests, trackedEvents, briefingHistory } = profile;
console.log('── 兴趣分类 ──');
if (interests.categories.length === 0) {
console.log(' (无)');
} else {
console.log(` ${interests.categories.join(', ')}`);
}
console.log('\n── 兴趣标签 ──');
if (interests.tags.length === 0) {
console.log(' (无)');
} else {
console.log(` ${interests.tags.join(', ')}`);
}
console.log('\n── 兴趣关键词 ──');
if (interests.keywords.length === 0) {
console.log(' (无)');
} else {
console.log(` ${interests.keywords.join(', ')}`);
}
console.log('\n── 追踪事件 ──');
if (trackedEvents.length === 0) {
console.log(' (无)');
} else {
for (const evt of trackedEvents) {
console.log(` [${evt.id}] ${evt.label}`);
console.log(` 关键词: ${evt.keywords.join(', ')}`);
console.log(` 分类: ${evt.category || '(未指定)'}`);
console.log(` 追踪自: ${fmtTime(evt.addedAt)}`);
if (evt.lastCheckedAt) {
console.log(` 最近检查: ${fmtTime(evt.lastCheckedAt)}`);
}
}
}
console.log(`\n── 简报历史 (${briefingHistory.length}) ──`);
if (briefingHistory.length === 0) {
console.log(' (无)');
} else {
for (const h of briefingHistory.slice(0, 5)) {
console.log(` ${h.date} | ${h.totalArticles} 篇 | ${h.topics.join(' / ')}`);
}
if (briefingHistory.length > 5) {
console.log(` ... 共 ${briefingHistory.length} 条记录`);
}
}
console.log(`\n 更新时间: ${fmtTime(profile.updatedAt) || '(从未)'}`);
console.log();
}
// ---------------------------------------------------------------------------
// Sub-commands
// ---------------------------------------------------------------------------
function cmdAddCategory(profile, positional) {
if (positional.length < 1) {
console.error('用法: node scripts/profile.js add-category <csv>');
process.exit(1);
}
const items = csvSplit(positional[0]);
const existing = new Set(profile.interests.categories);
for (const item of items) {
if (!existing.has(item)) {
profile.interests.categories.push(item);
existing.add(item);
}
}
saveProfile(profile);
console.log(`✓ 已添加兴趣分类: ${items.join(', ')}`);
console.log(` 当前分类: ${profile.interests.categories.join(', ')}`);
}
function cmdAddTags(profile, positional) {
if (positional.length < 1) {
console.error('用法: node scripts/profile.js add-tags <csv>');
process.exit(1);
}
const items = csvSplit(positional[0]);
const existing = new Set(profile.interests.tags);
for (const item of items) {
if (!existing.has(item)) {
profile.interests.tags.push(item);
existing.add(item);
}
}
saveProfile(profile);
console.log(`✓ 已添加兴趣标签: ${items.join(', ')}`);
console.log(` 当前标签: ${profile.interests.tags.join(', ')}`);
}
function cmdAddKeyword(profile, positional) {
if (positional.length < 1) {
console.error('用法: node scripts/profile.js add-keyword <keyword>');
process.exit(1);
}
const kw = positional[0];
if (!profile.interests.keywords.includes(kw)) {
profile.interests.keywords.push(kw);
}
saveProfile(profile);
console.log(`✓ 已添加兴趣关键词: ${kw}`);
console.log(` 当前关键词: ${profile.interests.keywords.join(', ')}`);
}
function cmdTrack(profile, positional, flags) {
if (positional.length < 1) {
console.error('用法: node scripts/profile.js track <label> [--keywords csv] [--category <cat>]');
process.exit(1);
}
const label = positional.join(' ');
const keywords = flags.keywords ? csvSplit(flags.keywords) : [];
const category = flags.category || '';
// Generate event ID
const id = `evt-${String(Date.now()).slice(-6)}`;
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const evt = {
id,
label,
keywords,
category,
addedAt: now,
lastCheckedAt: now,
};
profile.trackedEvents.push(evt);
saveProfile(profile);
console.log(`✓ 已添加追踪事件: [${id}] ${label}`);
console.log(` 关键词: ${keywords.join(', ') || '(无)'}`);
console.log(` 分类: ${category || '(未指定)'}`);
}
function cmdUntrack(profile, positional) {
if (positional.length < 1) {
console.error('用法: node scripts/profile.js untrack <id>');
process.exit(1);
}
const id = positional[0];
const before = profile.trackedEvents.length;
profile.trackedEvents = profile.trackedEvents.filter((e) => e.id !== id);
if (profile.trackedEvents.length === before) {
console.error(`✗ 未找到追踪事件: ${id}`);
process.exit(1);
}
saveProfile(profile);
console.log(`✓ 已移除追踪事件: ${id}`);
}
function cmdHistory(profile, flags) {
const limit = flags.limit ? parseInt(flags.limit, 10) : 10;
const history = profile.briefingHistory.slice(0, limit);
if (history.length === 0) {
console.log('暂无简报历史');
return;
}
console.log(`\n简报历史 (最近 ${history.length} 条):\n`);
for (const h of history) {
console.log(` ${h.date} | ${h.totalArticles} 篇 | 主题: ${h.topics.join(' / ')}`);
if (h.reportGenerated) {
console.log(` 报告: ${h.reportGenerated}`);
}
}
console.log();
}
// ---------------------------------------------------------------------------
// Main dispatch
// ---------------------------------------------------------------------------
const HELP = `\
用法:
node scripts/profile.js show 打印当前偏好
node scripts/profile.js add-category <csv> 添加兴趣分类
node scripts/profile.js add-tags <csv> 添加兴趣标签
node scripts/profile.js add-keyword <keyword> 添加兴趣关键词
node scripts/profile.js track <label> [--keywords csv] [--category <cat>] 追踪事件
node scripts/profile.js untrack <id> 停止追踪事件
node scripts/profile.js history [--limit N] 查看简报历史
node scripts/profile.js --json 输出原始 JSON
`;
async function main() {
const { flags, positional } = parseArgs(process.argv.slice(2));
const subcommand = positional[0];
const rest = positional.slice(1);
if (flags.json && !subcommand) {
const profile = loadProfile();
process.stdout.write(JSON.stringify(profile, null, 2) + '\n');
return;
}
if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
console.log(HELP);
return;
}
const profile = loadProfile();
switch (subcommand) {
case 'show':
cmdShow(profile);
break;
case 'add-category':
cmdAddCategory(profile, rest);
break;
case 'add-tags':
cmdAddTags(profile, rest);
break;
case 'add-keyword':
cmdAddKeyword(profile, rest);
break;
case 'track':
cmdTrack(profile, rest, flags);
break;
case 'untrack':
cmdUntrack(profile, rest);
break;
case 'history':
cmdHistory(profile, flags);
break;
default:
console.error(`✗ 未知子命令: ${subcommand}\n`);
console.log(HELP);
process.exit(1);
}
}
module.exports = { loadProfile, saveProfile, addBriefingHistory, EMPTY_PROFILE };
// Only run CLI when invoked directly, not when require()'d by other scripts
if (require.main === module) {
main().catch((err) => {
console.error(`\n✗ ${err.message}`);
process.exit(1);
});
}
'use strict';
/**
* report.js — Research report helper for news-hub-feed.
*
* Two sub-commands:
*
* fetch — batch-fetch article details for the agent to synthesize into a report.
* node scripts/report.js fetch <csv-of-ids> [--fields summary,original,translation] [--json]
*
* save — save a finished Markdown report to reports/ with frontmatter.
* node scripts/report.js save --title <title> [--articles <csv-ids>] --stdin
* node scripts/report.js save --title <title> [--articles <csv-ids>] --content <path>
*
* The agent writes the report body; this script handles the API fetching and
* the file I/O (ensuring frontmatter, directory, filename).
*/
const fs = require('fs');
const path = require('path');
const {
loadConfig,
requireApi,
apiRequest,
parseArgs,
csvSplit,
fmtTime,
ensureDir,
todayStr,
sanitizeFilename,
REPORTS_DIR,
} = require('./api-client');
const HELP = `\
用法:
node scripts/report.js fetch <id1,id2,...> [--fields summary,original,translation] [--json]
node scripts/report.js save --title <title> [--articles <csv-ids>] --stdin
node scripts/report.js save --title <title> [--articles <csv-ids>] --content <file-path>
`;
// ---------------------------------------------------------------------------
// fetch sub-command
// ---------------------------------------------------------------------------
/**
* Batch-fetch article details.
* @param {object} cfg
* @param {string[]} ids — array of numeric string IDs
* @param {string} fields — comma-separated fields param
* @returns {Promise<Array>}
*/
async function fetchArticles(cfg, ids, fields) {
const results = [];
for (const id of ids) {
const query = {};
if (fields) query.fields = fields;
try {
const article = await apiRequest(cfg, 'GET', `/articles/${id}`, { query });
results.push(article);
} catch (err) {
results.push({
articleId: id,
error: err.message,
code: err.code || 'ERROR',
});
}
}
return results;
}
async function cmdFetch(cfg, positional, flags) {
if (positional.length < 1) {
console.error('用法: node scripts/report.js fetch <id1,id2,...> [--fields ...] [--json]');
process.exit(1);
}
// Accept both CSV and space-separated IDs
let ids;
if (positional[0].includes(',')) {
ids = csvSplit(positional[0]);
} else {
ids = positional.slice();
}
// Validate all IDs are numeric
for (const id of ids) {
if (!/^\d+$/.test(id)) {
console.error(`✗ 文章 ID 必须是正整数,得到: ${id}`);
process.exit(1);
}
}
const fields = flags.fields || 'summary,translation';
console.error(`正在拉取 ${ids.length} 篇文章 (fields=${fields})…`);
const articles = await fetchArticles(cfg, ids, fields);
// Check for errors
const errors = articles.filter((a) => a.error);
if (errors.length > 0) {
console.error(`⚠️ ${errors.length} 篇拉取失败:`);
for (const e of errors) {
console.error(` #${e.articleId}: ${e.error}`);
}
}
if (flags.json) {
process.stdout.write(JSON.stringify(articles, null, 2) + '\n');
} else {
// Print a compact summary for agent reference
console.log(`\n已拉取 ${articles.length - errors.length}/${ids.length} 篇文章:\n`);
for (const a of articles) {
if (a.error) {
console.log(` #${a.articleId}${a.error}`);
continue;
}
console.log(` #${a.articleId} ${a.titleZh || a.titleEn || '(无标题)'}`);
console.log(` ${a.source || ''} | ${a.category || ''} | ${fmtTime(a.publishedAt)}`);
if (a.tags && a.tags.length > 0) {
console.log(` tags: ${a.tags.join(', ')}`);
}
if (a.summaryOverview) {
console.log(` 概述: ${a.summaryOverview}`);
}
console.log();
}
}
}
// ---------------------------------------------------------------------------
// save sub-command
// ---------------------------------------------------------------------------
async function cmdSave(positional, flags) {
if (!flags.title) {
console.error('✗ --title 是必需的');
console.error(HELP);
process.exit(1);
}
let content;
if (flags.stdin) {
// Read from stdin
content = fs.readFileSync(0, 'utf8');
} else if (flags.content) {
if (!fs.existsSync(flags.content)) {
console.error(`✗ 文件不存在: ${flags.content}`);
process.exit(1);
}
content = fs.readFileSync(flags.content, 'utf8');
} else {
console.error('✗ 需要 --stdin 或 --content <path> 之一');
console.error(HELP);
process.exit(1);
}
// Parse article IDs
const articleIds = flags.articles ? csvSplit(flags.articles) : [];
// Extract categories from content? No — let the agent provide them.
// Build frontmatter
const date = todayStr();
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const frontmatter = {
date,
title: flags.title,
articles: articleIds,
generatedAt: now,
};
const frontmatterYaml = formatFrontmatter(frontmatter);
// Combine: frontmatter + content (strip existing frontmatter from content if present)
const bodyContent = stripFrontmatter(content);
const fullReport = `---\n${frontmatterYaml}---\n\n${bodyContent.trim()}\n`;
// Ensure reports directory exists
ensureDir(REPORTS_DIR);
// Generate filename
const filename = `${date}_${sanitizeFilename(flags.title)}.md`;
const filepath = path.join(REPORTS_DIR, filename);
fs.writeFileSync(filepath, fullReport);
console.log(`✓ 报告已保存: ${filepath}`);
// Update briefing history if there's a matching entry for today
try {
const { loadProfile, saveProfile } = require('./profile');
const profile = loadProfile();
const todayEntry = profile.briefingHistory.find((h) => h.date === date);
if (todayEntry) {
todayEntry.reportGenerated = `reports/${filename}`;
saveProfile(profile);
}
} catch {
// Profile may not exist yet — non-fatal
}
}
function formatFrontmatter(fm) {
const lines = [];
lines.push(`date: ${fm.date}`);
lines.push(`title: "${fm.title.replace(/"/g, '\\"')}"`);
if (fm.articles.length > 0) {
lines.push(`articles: [${fm.articles.join(', ')}]`);
} else {
lines.push(`articles: []`);
}
lines.push(`generatedAt: ${fm.generatedAt}`);
return lines.join('\n') + '\n';
}
function stripFrontmatter(text) {
if (text.startsWith('---\n')) {
const end = text.indexOf('\n---\n', 4);
if (end !== -1) {
return text.slice(end + 5);
}
}
return text;
}
// ---------------------------------------------------------------------------
// Main dispatch
// ---------------------------------------------------------------------------
async function main() {
const { flags, positional } = parseArgs(process.argv.slice(2));
const subcommand = positional[0];
const rest = positional.slice(1);
if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
console.log(HELP);
return;
}
switch (subcommand) {
case 'fetch': {
const cfg = loadConfig();
requireApi(cfg);
await cmdFetch(cfg, rest, flags);
break;
}
case 'save':
await cmdSave(rest, flags);
break;
default:
console.error(`✗ 未知子命令: ${subcommand}\n`);
console.log(HELP);
process.exit(1);
}
}
module.exports = { fetchArticles, formatFrontmatter, stripFrontmatter };
main().catch((err) => {
console.error(`\n✗ ${err.message}`);
process.exit(1);
});
{
"format": "news-harvester-source",
"version": 1,
"exportedAt": "2026-08-14T02:47:27.378Z",
"source": {
"id": "bloomberg-technology",
"name": "彭博社科技",
"homepageUrl": "https://www.bloomberg.com/technology",
"method": "browser",
"vaultFolder": "Bloomberg/Technology"
},
"files": {
"helpers/bloomberg-technology.md": "---\nsource: bloomberg-technology\n\n# ── 发现阶段 ──\n# Bloomberg Technology 首页文章分散在多个 LineupContent* 卡片容器 +\n# styles_itemTextContainer 列表中,用复合 listSelector 一次性圈中。\nlistSelector: '[class*=\"LineupContent\"], [class*=\"itemTextContainer\"]'\nlinkSelector: 'a[href]'\n\n# 文章 URL 形如 /news/articles/YYYY-MM-DD/<slug> 或 /news/features/YYYY-MM-DD/<slug>\n# 视频 /news/videos/ 用 excludeUrlPattern 排除。\nurlPattern: 'bloomberg\\.com/news/(articles|features)/.+'\nexcludeUrlPattern: 'bloomberg\\.com/news/videos/'\n\nscrollSteps: 3\nscrollWaitMs: 2000\nwaitMs: 20000\nmaxLinks: 30\n\n# ── 提取阶段 ──\n# Bloomberg 正文段落 class 为 ArticleBodyText_articleBodyContent__xxxxx,\n# 直接命中正文 <p>,避免误抓 newsletter / MostRead 等侧边栏内容。\n# body-content p 作为 fallback(整个正文容器内的所有 <p>)。\nbodySelectors:\n - '[class*=\"ArticleBodyText\"]'\n - '[data-testid=\"paragraph\"]'\n - '[class*=\"body-content\"] p'\n - '[class*=\"articleBodyContent\"] p'\n - '[class*=\"ArticleBody\"] p'\n - '[class*=\"article-body\"] p'\n - 'article p'\nbodyMinParagraphs: 3\n\ndateSelector: 'time[datetime]'\n# Bloomberg 作者署名在 byline 容器中,JSON-LD 作为最终 fallback。\nauthorSelector: '[rel=\"author\"], [class*=\"author\"] a, [class*=\"byline\"] a, [class*=\"Byline\"] a'\n\nimageSelector: 'img'\nimageMinWidth: 200\nexcludeImage:\n - logo\n - icon\n - avatar\n - profile\n\n# Bloomberg 文末常见署名/订阅推广噪声 + 推荐文章标题噪声。\nnoisePrefixes:\n - 'Sign up for'\n - 'Subscribe to'\n - 'Read more:'\n - 'Read More:'\n - 'Most Read from Bloomberg'\n - 'This article was produced'\n - '©2026 Bloomberg'\n - 'Bloomberg may send me'\n - 'By submitting my information'\n - 'By continuing, I agree'\n\ntitleStripSuffix: ' - Bloomberg$'\nmaxBodyChars: 15000\n---\n# bloomberg-technology 抓取备忘\n\n- 首页 `https://www.bloomberg.com/technology` 文章卡片分布在多个 `LineupContent*`\n 容器(4Up/3Up/2Up/Basic)以及 `styles_itemTextContainer` 列表中,inspect 实测\n 首屏约 22 篇文章链接 + 4 个视频。用复合 `listSelector` 圈中所有卡片。\n- `urlPattern` 限定 `/news/(articles|features)/.+`,天然排除 `/technology/ai` 等\n section 页与 `/markets` 等导航;`excludeUrlPattern` 排除 `/news/videos/`。\n- 反爬/付费墙:Bloomberg 对 headless 有反爬,正文可能因未登录被截断。\n 如 preview 出现 charCount<500 或 CAPTCHA(exit 70),需\n `node scripts/ensure-chromium.js --login --url https://www.bloomberg.com/technology`\n 在窗口浏览器登录后退出,持久化 cookie 到 userDataDir。\n- 正文段落 class 为 `ArticleBodyText_articleBodyContent__xxxxx`(哈希后缀会变),\n 用 `[class*=\"ArticleBodyText\"]` 精确命中,避免误抓 newsletter / MostRead 推荐区块。\n waitMs=20000 给足页面渲染时间,防止正文容器加载不全时 fallback 兜底抓到推荐标题。\n- 作者署名在 byline 容器中,authorSelector 已覆盖 `[class*=\"byline\"] a`;\n 若 CSS 选择器全部未命中,fetcher-helper 会自动 fallback 到 JSON-LD `author` 字段。\n- 验证: node scripts/preview.js --url https://www.bloomberg.com/technology \\\n --recipe helpers/bloomberg-technology.md --count 3\n",
"helpers/bloomberg-technology.fixtures.json": "{\n \"articles\": [\n \"https://www.bloomberg.com/news/articles/2026-08-13/trump-enlists-private-sector-to-boost-cyber-offensive-arsenal\",\n \"https://www.bloomberg.com/news/articles/2026-08-13/anthropic-said-in-talks-to-buy-ai-startup-decart-for-6-billion\",\n \"https://www.bloomberg.com/news/features/2026-08-12/thousands-of-india-workers-are-helping-ai-firms-train-robots-to-replace-them\"\n ],\n \"sections\": [\n \"https://www.bloomberg.com/technology/ai\",\n \"https://www.bloomberg.com/technology/big-tech\"\n ],\n \"minLinks\": 10\n}\n"
}
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment