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

feat: edit bloomberg bundles

parent 7dfdb0e2
......@@ -17,6 +17,10 @@ node_modules/
# authored locally — the bundle is the committed unit, not the helper.
helpers/
# User preferences and research reports (news-hub-feed)
skills/news-hub-feed/profile.json
skills/news-hub-feed/reports/
# IDE / agent workspace files
.zcode/
AGENTS.md
This diff is collapsed.
......@@ -20,6 +20,8 @@ const path = require('path');
const SKILL_DIR = path.dirname(__dirname); // .../skills/news-hub-feed
const CONFIG_PATH = path.join(SKILL_DIR, 'config.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.
const REQUIRED_SCOPES = ['articles:read', 'marks:read', 'marks:write'];
......@@ -307,10 +309,50 @@ function truncate(s, maxLen) {
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 = {
SKILL_DIR,
CONFIG_PATH,
TEMPLATE_PATH,
PROFILE_PATH,
REPORTS_DIR,
REQUIRED_SCOPES,
loadConfig,
requireApi,
......@@ -324,4 +366,7 @@ module.exports = {
parseMarksCsv,
fmtTime,
truncate,
ensureDir,
todayStr,
sanitizeFilename,
};
This diff is collapsed.
This diff is collapsed.
'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