Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in
Toggle navigation
M
macos-hub-skills
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
郭阔
macos-hub-skills
Commits
71c850ae
Commit
71c850ae
authored
Aug 14, 2026
by
谢宇轩
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: edit bloomberg bundles
parent
7dfdb0e2
Changes
7
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
1371 additions
and
12 deletions
+1371
-12
.gitignore
.gitignore
+4
-0
SKILL.md
skills/news-hub-feed/SKILL.md
+184
-12
api-client.js
skills/news-hub-feed/scripts/api-client.js
+45
-0
digest.js
skills/news-hub-feed/scripts/digest.js
+516
-0
profile.js
skills/news-hub-feed/scripts/profile.js
+344
-0
report.js
skills/news-hub-feed/scripts/report.js
+262
-0
bloomberg-technology.nhsource.json
...s/news-source-analyzer/bloomberg-technology.nhsource.json
+16
-0
No files found.
.gitignore
View file @
71c850ae
...
@@ -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
skills/news-hub-feed/SKILL.md
View file @
71c850ae
This diff is collapsed.
Click to expand it.
skills/news-hub-feed/scripts/api-client.js
View file @
71c850ae
...
@@ -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
,
};
};
skills/news-hub-feed/scripts/digest.js
0 → 100644
View file @
71c850ae
This diff is collapsed.
Click to expand it.
skills/news-hub-feed/scripts/profile.js
0 → 100644
View file @
71c850ae
This diff is collapsed.
Click to expand it.
skills/news-hub-feed/scripts/report.js
0 → 100644
View file @
71c850ae
'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
);
});
skills/news-source-analyzer/bloomberg-technology.nhsource.json
0 → 100644
View file @
71c850ae
{
"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"
:
"---
\n
source: bloomberg-technology
\n\n
# ── 发现阶段 ──
\n
# Bloomberg Technology 首页文章分散在多个 LineupContent* 卡片容器 +
\n
# styles_itemTextContainer 列表中,用复合 listSelector 一次性圈中。
\n
listSelector: '[class*=
\"
LineupContent
\"
], [class*=
\"
itemTextContainer
\"
]'
\n
linkSelector: 'a[href]'
\n\n
# 文章 URL 形如 /news/articles/YYYY-MM-DD/<slug> 或 /news/features/YYYY-MM-DD/<slug>
\n
# 视频 /news/videos/ 用 excludeUrlPattern 排除。
\n
urlPattern: 'bloomberg
\\
.com/news/(articles|features)/.+'
\n
excludeUrlPattern: 'bloomberg
\\
.com/news/videos/'
\n\n
scrollSteps: 3
\n
scrollWaitMs: 2000
\n
waitMs: 20000
\n
maxLinks: 30
\n\n
# ── 提取阶段 ──
\n
# Bloomberg 正文段落 class 为 ArticleBodyText_articleBodyContent__xxxxx,
\n
# 直接命中正文 <p>,避免误抓 newsletter / MostRead 等侧边栏内容。
\n
# body-content p 作为 fallback(整个正文容器内的所有 <p>)。
\n
bodySelectors:
\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'
\n
bodyMinParagraphs: 3
\n\n
dateSelector: 'time[datetime]'
\n
# Bloomberg 作者署名在 byline 容器中,JSON-LD 作为最终 fallback。
\n
authorSelector: '[rel=
\"
author
\"
], [class*=
\"
author
\"
] a, [class*=
\"
byline
\"
] a, [class*=
\"
Byline
\"
] a'
\n\n
imageSelector: 'img'
\n
imageMinWidth: 200
\n
excludeImage:
\n
- logo
\n
- icon
\n
- avatar
\n
- profile
\n\n
# Bloomberg 文末常见署名/订阅推广噪声 + 推荐文章标题噪声。
\n
noisePrefixes:
\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\n
titleStripSuffix: ' - Bloomberg$'
\n
maxBodyChars: 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
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment