Commit 45dcc7de authored by 谢宇轩's avatar 谢宇轩

feat: skill 文档精简 + CDP 登录引导 + tag/category 改造 + 日期修复

SKILL.md 精简 (476→296行):
- Document Formats 移到 references/document-formats.md (每次调用少加载1/3上下文)
- 摘要 batching 改成单一决策树 (N≤8 一次写 / N>8 批≤4),消除4处重复
- 添加新源流程统一为 inspect --write 4步闭环,消除与 source-management.md 的冲突
- 新增 References 小节索引三个 references 文件

CDP 登录引导 (跨平台):
- ensure-chromium.js 新增 --list (检测平台+可用浏览器) 和 --login (窗口化登录)
- --login 用固定 profile (~/.news-harvester/chromium-profile),端口占用时报告退出
- SKILL.md 的 Dedicated profile 从 macOS-only 手写命令改为跨平台3步流程
- --no-sandbox 仅在 root 下添加 (修掉 macOS 的不受支持标记警告)
- config-template.json 加 browser method 示例源

tag/category 改造:
- tags 从 [category, source] 改为 agent 从摘要提取的 ≤3 语义关键词
- 新增 frontmatter category 字段 (独立于 tags,支持 Obsidian 筛选)
- finalize 回写原文.md 的 tags+category frontmatter (与摘要.md 一致)
- index.md 文章列表新增"原文标题"列,标签显示提取的
- 摘要文件移到 <dayDir>/summary/ 子目录 (与 assets/ 同级,wikilink 保持裸标题)

日期修复:
- frontmatter date: 存完整 ISO 8601 时间戳 (原 substring(0,10) 截断导致跨时区差一天)
- fetcher-direct.js 正则放宽,捕获含毫秒/时区偏移的完整 ISO
- registry article 加 date 字段 (之前未存,导致 index 日期列空)
- callout 发布时间显示上海时区 (UTC+8),来源/URL 各自另起一行
- index 表格用紧凑 YYYY-MM-DD (dateCell),存储用完整 ISO (dateShort)
parent 1d7e3152
This diff is collapsed.
......@@ -16,6 +16,14 @@
"articleUrlPattern": "theconversation\\.com/[\\w-]+-\\d+$",
"vaultFolder": "The Conversation",
"enabled": true
},
{
"id": "reuters-world",
"name": "路透社 World",
"homepageUrl": "https://www.reuters.com/world/",
"method": "browser",
"vaultFolder": "路透社 World",
"enabled": true
}
]
}
# Document Formats (reference)
These docs are **generated by the scripts**, not hand-written by the agent —
shown here for debugging or when reading archived files. The agent only writes
`.summaries.json`; `finalize.js` produces the Chinese summary docs and patches
the originals.
## Original Doc (written by `harvest.js`)
Filename: `<英文标题> - <SourceName>原文.md`
Between Step 1 (harvest) and Step 3 (finalize), the Chinese-title links in this file are a **sentinel placeholder** `__SUMMARY_<slug>__` — in both the frontmatter `related` field and the footer wikilink. `finalize.js` replaces them with the real `titleZh`, and overwrites the `tags` block + `category` line with the agent-extracted values. Image captions: only rendered when `alt` text exists (empty alt → bare embed, no `**`).
```markdown
---
title: "<英文标题>"
date: <ISO 8601 published timestamp, e.g. 2026-07-30T21:47:28Z>
tags: # agent-extracted (≤3); placeholder [source] until finalize
- <tag1>
- <tag2>
category: <分类> # agent-supplied; overrides the script's keyword guess
source: "<url>"
publisher: <source>
authors: "<authors>"
type: 原文
related: "[[<中文标题>]]" # sentinel __SUMMARY_<slug>__ until finalize
saved: YYYY-MM-DD
---
# <英文标题>
> [!info] Source
> **Published:** <Shanghai time, e.g. 2026-07-31 05:47>
> **URL:** <url>
> **中文摘要:** [[<中文标题>]]
---
## 配图
![[assets/<slug>-1.jpg]]
*<alt text — omitted if empty>*
---
## 正文
<full article body>
---
[[<中文标题>]]
```
## Chinese Summary Doc (written by `finalize.js`)
File: `<dayDir>/summary/<中文标题>.md` (concise title, ≤30 chars). The `summary/` subdirectory is a sibling of `assets/`. Wikilinks from 原文.md and index.md use the bare title `[[<中文标题>]]` — Obsidian resolves them by basename.
```markdown
---
title: <中文标题>
date: <ISO 8601 published timestamp, e.g. 2026-07-30T21:47:28Z>
tags: # agent-extracted from the summary/body (≤3)
- <tag1>
- <tag2>
category: <分类> # agent-supplied; enables Obsidian property/Bases filtering
source: "<original URL>"
publisher: <source name>
authors: "<author names>"
type: 摘要
related: "[[<英文标题> - <SourceName>原文]]"
saved: YYYY-MM-DD
---
# <中文标题>
> [!info] 文章信息
> **发布:** <Shanghai time, e.g. 2026-07-31 05:47>
> **来源:** <source>
> **作者:** <authors>
> **原文:** [[<英文标题> - <SourceName>原文]]
---
## 事件概述
<2-3 sentences summary in Chinese>
## 背景
<context>
## 关键引语
> "<translated quote>"
## 影响分析
<analysis>
---
[[<英文标题> - <SourceName>原文]]
```
## index.md Format
File: `<vaultFolder>/YYYYMMDD/index.md` (written by `harvest.js`, rewritten by `finalize.js`)
Image asset count is a **real file count** of `assets/` (not "articles with images"), so it reflects downloaded files even before finalize.
```markdown
---
date: YYYY-MM-DD
type: daily-index
source: <source name>
total_articles: N
last_updated: "<ISO timestamp>"
---
# <SourceName> Daily Index · YYYY-MM-DD
> **最近更新:** YYYY-MM-DD HH:MM CST
> **当日归档:** N 篇 | **图片资产:** N 张
## 📊 分类统计
| 分类 | 数量 |
|------|------|
| ... | ... |
## 📋 文章列表
| # | 标题 | 原文标题 | 分类 | 标签 | 发布时间 | 状态 |
|---|------|----------|------|------|----------|------|
| 1 | [[中文标题]] | English Title | ... | tag1, tag2 | YYYY-MM-DD | ✅ |
> 发布时间列是紧凑 YYYY-MM-DD(本地时区),便于表格浏览;frontmatter `date:` 存完整 ISO 8601 时间戳(无损,Obsidian 可按任意时区解析)。
## 🗂️ 分类导航
### 分类1(N篇)
- [[文章1]]
```
## registry.json Format
File: `<vaultFolder>/YYYYMMDD/registry.json` (written incrementally by `harvest.js`, patched by `finalize.js`)
`titleZh` is the sentinel `__SUMMARY_<slug>__` until finalize supplies the real title.
```json
{
"date": "YYYYMMDD",
"source": "<source_id>",
"articles": [
{
"url": "https://...",
"titleZh": "中文标题",
"titleEn": "English Title",
"date": "2026-07-24T14:30:00.000Z",
"processedAt": "2026-07-24T15:00:00.000Z",
"category": "地缘政治",
"tags": ["tag1", "tag2"],
"slug": "theconversation-com-some-article-286430",
"originalFilename": "Some Article - The Conversation原文.md"
}
]
}
```
......@@ -85,5 +85,9 @@ maxBodyChars: 15000
# imageSelector / imageMinWidth / excludeImage / noisePrefixes
# titleStripSuffix / maxBodyChars
#
# 完整说明见 references/source-management.md 的 "Helper recipes" 小节。
# 写新 recipe 流程: inspect → scaffold(source-manage helper <id>) → 填字段 → preview 验证
# 完整说明见 references/source-management.md 的 "Authoring workflow" 小节。
# 写新 recipe 流程(一步生成): node scripts/inspect-source.js <url> --scroll 3 --write <id>
# → 自动测 listSelector + 推断 urlPattern,直接写好 helpers/<id>.md
# → node scripts/harvest.js <id> 3 --preview 验证
# → node scripts/recipe-test.js <id> 锁定回归测试
# (source-manage.js helper <id> 仅在想要空白模板手填时用)
......@@ -20,6 +20,11 @@ Prompt the user for (or infer from context):
| `name` | Display name | `路透社` |
| `homepageUrl` | URL to scrape for links | `https://www.reuters.com/` |
| `method` | `browser` (needs JS) or `direct` (plain HTTP) | `browser` |
> A `browser` source that needs login (subscription/paywall): first run
> `node scripts/ensure-chromium.js --login --url <homepage>` to open a windowed
> browser and persist a logged-in profile, then set `chromium.userDataDir` in
> config.json. See `SKILL.md` → *Dedicated profile (login state)*.
| `articleUrlPattern` | Regex to match article URLs — **legacy direct sources only**; ignored when a recipe (`helpers/<id>.md`) exists | `theconversation\\.com/[\\w-]+-\\d+$` |
| `vaultFolder` | Subfolder under vaultPath | `路透社` |
| `enabled` | Start enabled? | `true` |
......@@ -70,25 +75,32 @@ recipe's selectors/scroll/filter) instead of the legacy `fetcher-browser.js`/
### Authoring workflow (for a new browser source)
This is the canonical 4-step flow — identical to the one in `SKILL.md` (single
source of truth, no divergence):
1. **Add the source** to config (see Add Source above).
2. **Inspect the page** to discover where article links actually live:
```bash
node scripts/inspect-source.js <homepageUrl> --scroll 3 --pattern '<domain>\.com/.+'
```
This prints links grouped by container, biggest group first, plus a
ready-to-paste helper.md skeleton.
3. **Scaffold + edit the recipe**:
2. **Inspect + auto-write the recipe in one shot**`inspect --write` measures
`listSelector` (biggest container group) and infers `urlPattern` from real
URLs, then writes `helpers/<id>.md` directly:
```bash
node scripts/source-manage.js helper <id> # writes helpers/<id>.md template
node scripts/inspect-source.js <homepageUrl> --scroll 3 --write <id>
```
Fill in `listSelector` (from the biggest inspect group) and tighten
`urlPattern` (from the sample URLs inspect printed).
4. **Verify** (read-only):
(Use `--force` to overwrite an existing recipe.)
3. **Verify** (read-only):
```bash
node scripts/harvest.js <id> 3 --preview
```
Confirm the links are real articles (not nav/section pages) and the body
char counts look right. Iterate on the recipe until clean.
char counts look right. Iterate on the recipe until clean — see
*Troubleshooting recipes* below.
4. **Lock with regression tests** (see *Locking a stable recipe* in `SKILL.md`):
```bash
node scripts/recipe-test.js <id> # needs helpers/<id>.fixtures.json
```
> `node scripts/source-manage.js helper <id>` still scaffolds a blank template
> if you'd rather hand-author from scratch — but `inspect --write` is the
> default path and does the measuring for you.
### Frontmatter schema
......@@ -134,6 +146,51 @@ All fields optional except `urlPattern`; defaults reproduce legacy behaviour.
> (`datePublished` / `author.name`). Many SPA sites (Nikkei) only embed metadata
> there, so you usually don't need a custom selector.
### Troubleshooting recipes
The quick convergence-check table lives in `SKILL.md` → *Convergence check*.
The deeper rationale for the two most common pitfalls is here.
#### Pitfall: articles spread across multiple card containers
`inspect-source.js` reports the *single largest* container group as
`listSelector`, but on many sites (Nikkei, large news hubs) real articles are
spread across several card-container classes of similar size, and no one
container holds them all. Setting `listSelector` to just the biggest group
silently drops the rest.
Symptom: `inspect` shows ~30 article-like links on the page but
`harvest --preview` collects only ~12. Fix: write `listSelector` as a
**comma-separated composite** of every article-card class prefix, e.g.
`[class*="StreamArticleCard"], [class*="SpotlightArticleCard"], [class*="SecondaryArticleCard"]`.
Re-run `inspect` (without `--write`) to enumerate which `*ArticleCard*` classes
carry links if unsure.
Why not `listSelector: null` (whole page)? On sites with country/topic nav
dropdowns (Nikkei's `/location/<country>` links, ~30 entries), a whole-page scan
floods the `maxLinks` cap before real articles are reached. Scoping to card
containers avoids this. `inspect` without `--write` shows the container groups
so you can pick the right composite.
#### When to use `validateArticle` (and when not to)
Set `validateArticle: true` when article and section/category URLs on the site
**share a path shape** — i.e. you can't write a `urlPattern` that admits articles
and rejects section pages purely from the URL. Nikkei is the canonical case:
`/business/tech/semiconductors/<slug>` (article) and
`/business/tech/semiconductors` (section) differ only by the trailing slug, and
slug length is an unreliable signal. With `validateArticle`, `fetchPage` probes
`og:type` + JSON-LD `@type` + body length, marks the section page
`isArticle=false` (via `og:type=website` / `@type=Thing` / empty body), and
`harvest.js` skips it. This lets you use a *broad* `urlPattern` (no fragile
"slug must have N hyphens" guesswork) and still keep section pages out.
Don't set it when the URL alone cleanly separates articles from sections (e.g.
Reuters' `/world/<slug>/<date>/` vs. `/world/`) — a tight `urlPattern` is
cheaper than a per-candidate page fetch. `validateArticle` defaults to `false`,
so existing recipes are unaffected. See `helpers/nikkei-tech.md` for a worked
example with measured signals.
### Managing recipes
```bash
......
This diff is collapsed.
......@@ -75,9 +75,11 @@ function extractArticle(html, url) {
let title = titleMatch ? titleMatch[1].trim() : '';
title = title.replace(/ \| The Conversation| \| Reuters/g, '').trim();
// Date
const dateMatch = html.match(/["']datePublished["']\s*:\s*["']([\d\-T:Z+]+)["']/) ||
html.match(/<time[^>]+datetime=["']([\d\-T:Z+]+)["']/i);
// Date — capture the full ISO 8601 timestamp (incl. fractional seconds and
// timezone offsets like -05:00 / +09:00). The earlier [\d\-T:Z+] class missed
// '.' and the second '-' / '+' in offsets, truncating those values.
const dateMatch = html.match(/["']datePublished["']\s*:\s*["']([^"']+)["']/) ||
html.match(/<time[^>]+datetime=["']([^"']+)["']/i);
const date = dateMatch ? dateMatch[1] : '';
// Authors
......
......@@ -130,7 +130,11 @@ function main() {
}
const sentinel = sentinelFor(slug);
const summaryFilePath = path.join(dayDir, `${titleZh}.md`);
// Summary docs live in a `summary/` subdirectory (sibling of `assets/`).
// Wikilinks stay bare-title ([[中文标题]]); Obsidian resolves them by basename.
const summaryDir = path.join(dayDir, 'summary');
fs.mkdirSync(summaryDir, { recursive: true });
const summaryFilePath = path.join(summaryDir, `${titleZh}.md`);
const origFilePath = path.join(dayDir, origName || originalFilename(titleEn, sourceName));
// 1. Write the Chinese summary doc
......@@ -146,11 +150,21 @@ function main() {
}, sourceName);
fs.writeFileSync(summaryFilePath, summaryDoc);
// 2. Patch the sentinel in the original doc (frontmatter related + footer wikilink)
// 2. Patch the original doc: replace the title sentinel, then overwrite the
// frontmatter `tags:` block and `category:` line with the agent-extracted
// values so 原文.md carries the same tags/category as the summary doc.
if (fs.existsSync(origFilePath)) {
const orig = fs.readFileSync(origFilePath, 'utf8');
// Replace all occurrences of the sentinel with the real title.
const patchedOrig = orig.split(sentinel).join(titleZh);
let patchedOrig = orig.split(sentinel).join(titleZh);
// Overwrite the tags block (tags:\n - x\n - y\n) with agent-extracted tags.
const finalTags = (summary.tags && summary.tags.length) ? summary.tags : article.tags;
const newTagsYaml = (finalTags || []).map(t => ` - ${t}`).join('\n');
patchedOrig = patchedOrig.replace(/(^tags:\n)(?: - .*\n)+/m, `$1${newTagsYaml}\n`);
// Overwrite the category line if the agent supplied one.
if (summary.category) {
patchedOrig = patchedOrig.replace(/^category: .*/m, `category: ${summary.category}`);
}
fs.writeFileSync(origFilePath, patchedOrig);
} else {
console.error(` ⚠️ Original doc missing: ${origName} — summary written but link unpatched`);
......@@ -175,6 +189,7 @@ function main() {
// Index links resolve to summary docs where a titleZh exists, else sentinel.
const indexArticles = registry.articles.map(a => ({
titleZh: a.titleZh,
titleEn: a.titleEn || '',
date: a.date || '',
category: a.category || '国际',
tags: a.tags || []
......
......@@ -56,8 +56,39 @@ function todayStr() {
return `${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}`;
}
// Return the full ISO 8601 timestamp for frontmatter `date:` fields. Storing
// the complete timestamp (not a truncated YYYY-MM-DD) is lossless and lets
// Obsidian render it in any timezone — truncating the raw UTC string caused
// off-by-one errors for articles published near UTC midnight.
function dateShort(dateStr) {
return (dateStr || '').substring(0, 10) || todayStr().replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3');
return (dateStr && dateStr.trim()) || todayStr().replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3');
}
// Compact YYYY-MM-DD for the index.md table cell (full ISO is too wide there).
// Falls back to the raw string if it isn't a valid date.
function dateCell(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr.substring(0, 10);
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}`;
}
// Format a published timestamp for display in the Shanghai timezone (UTC+8),
// e.g. "2026-07-31 05:47". Used in the info callouts of 原文.md / 摘要.md.
function dateShanghai(dateStr) {
if (!dateStr) return '—';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false
}).formatToParts(d);
const get = t => (parts.find(p => p.type === t) || {}).value || '';
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}`;
}
function getCategory(url, title) {
......@@ -162,6 +193,7 @@ title: "${titleZh}"
date: ${dateShort(date)}
tags:
${tagsYaml}
category: ${category}
source: "${url}"
publisher: ${sourceName}
authors: "${authors || ''}"
......@@ -173,7 +205,8 @@ saved: ${todayStr().replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
# ${titleZh}
> [!info] 文章信息
> **发布:** ${dateShort(date)} | **来源:** ${sourceName}
> **发布:** ${dateShanghai(date)}
> **来源:** ${sourceName}
> **作者:** ${authors || '—'}
> **原文:** [[${origName}]]
......@@ -188,7 +221,7 @@ ${data.summaryZh || '(摘要生成中)'}
}
function generateOriginalDoc(data, sourceName, imgFiles) {
const { titleEn, titleZh, date, authors, url, body } = data;
const { titleEn, titleZh, date, authors, url, body, category } = data;
const tags = (data.tags || []).map(t => ` - ${t}`).join('\n');
// Empty-alt guard: only render the caption line when alt text exists, so we
// never emit a bare `**` (empty bold) for avatar/tracking images.
......@@ -204,6 +237,7 @@ title: "${titleEn}"
date: ${dateShort(date)}
tags:
${tags}
category: ${category}
source: "${url}"
publisher: ${sourceName}
authors: "${authors || ''}"
......@@ -215,7 +249,8 @@ saved: ${todayStr().replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
# ${titleEn}
> [!info] Source
> **Published:** ${dateShort(date)} | **URL:** ${url}
> **Published:** ${dateShanghai(date)}
> **URL:** ${url}
> **中文摘要:** [[${titleZh}]]
---
......@@ -258,7 +293,7 @@ function updateIndex(dayDir, articles, sourceName) {
articles.forEach(a => { catCount[a.category] = (catCount[a.category] || 0) + 1; });
const catRows = Object.entries(catCount).map(([c, n]) => `| ${CATEGORY_EMOJI[c] || '📰'} ${c} | ${n} |`).join('\n');
const tableRows = articles.map((a, i) =>
`| ${i+1} | [[${a.titleZh}]] | ${a.category} | ${(a.tags||[]).slice(0,3).join(', ')} | ${dateShort(a.date)} | ✅ |`
`| ${i+1} | [[${a.titleZh}]] | ${a.titleEn || ''} | ${a.category} | ${(a.tags||[]).slice(0,3).join(', ')} | ${dateCell(a.date)} | ✅ |`
).join('\n');
const catNav = Object.entries(catCount).map(([cat, n]) => {
......@@ -293,8 +328,8 @@ ${catRows}
## 📋 文章列表
| # | 标题 | 分类 | 标签 | 发布时间 | 状态 |
|---|------|------|------|----------|------|
| # | 标题 | 原文标题 | 分类 | 标签 | 发布时间 | 状态 |
|---|------|----------|------|------|----------|------|
${tableRows}
---
......@@ -451,17 +486,25 @@ Examples:
// Normalize URLs (strip query params for dedup)
const normalizeUrl = (u) => { try { const p = new URL(u); p.search = ''; return p.toString(); } catch(e) { return u; } };
const normalizedLinks = [...new Set(links.map(u => normalizeUrl(u)))];
const newLinks = normalizedLinks.filter(u => !seen.has(u)).slice(0, count);
// Keep ALL new candidates rather than slicing to `count`: recipe sources with
// validateArticle reject section/category pages only at fetch time, so capping
// candidates here would shortchange the article target (section pages mixed
// into the first `count` candidates eat slots the buffer was meant to backfill).
// The fetcher already capped collection at desiredCount (count + seen + buffer
// ≤ MAX_CANDIDATE_LINKS), so this list is bounded; the fetch loops below stop
// once `count` articles are successfully archived.
const newLinks = normalizedLinks.filter(u => !seen.has(u));
const skipped = normalizedLinks.filter(u => seen.has(u)).length;
console.error(` New: ${newLinks.length} | Already seen: ${skipped}`);
console.error(` New: ${newLinks.length} candidates | Already seen: ${skipped}`);
if (!preview && newLinks.length < count) {
console.error(` ⚠️ Only ${newLinks.length} new of ${count} requested — stream may be exhausted (fewer fresh articles than asked). Re-running won't help; wait for new articles or broaden the source.`);
console.error(` ⚠️ Only ${newLinks.length} new candidates of ${count} requested — stream may be exhausted (fewer fresh articles than asked). Re-running won't help; wait for new articles or broaden the source.`);
}
// ── Preview mode: fetch only, print compact list, write nothing ───────────
if (preview) {
const out = [];
for (let i = 0; i < newLinks.length; i++) {
if (out.length >= count) break; // cap on successful articles, not candidates
const url = newLinks[i];
console.error(`\n[${i+1}/${newLinks.length}] ${url}`);
let articleData;
......@@ -521,6 +564,7 @@ Examples:
const processed = [];
for (let i = 0; i < newLinks.length; i++) {
if (processed.length >= count) break; // cap on successful articles, not candidates
const url = newLinks[i];
console.error(`\n[${i+1}/${newLinks.length}] ${url}`);
......@@ -545,7 +589,7 @@ Examples:
const { title: titleEn, date, authors, imgs = [], body } = articleData;
const slug = slugFromUrl(url);
const category = getCategory(url, titleEn);
const tags = [category, source.name];
const tags = [source.name]; // placeholder; finalize overwrites with agent-extracted tags
const titleZh = sentinelFor(slug); // patched by finalize.js
const origName = originalFilename(titleEn, source.name);
......@@ -571,7 +615,7 @@ Examples:
// Append to registry (dedup record, written immediately so a crash keeps it)
appendRegistry(config, source, today, {
url, titleZh, titleEn, processedAt: new Date().toISOString(),
url, titleZh, titleEn, date, processedAt: new Date().toISOString(),
category, tags, slug, originalFilename: origName
});
......@@ -583,6 +627,10 @@ Examples:
await sleep(1000);
}
if (!preview && processed.length < count) {
console.error(` ⚠️ Archived ${processed.length} of ${count} requested remaining candidates were non-article/low-content pages; stream ran out of real articles. Re-running won't help.`);
}
// Compact manifest for the agent: NO full body, only a preview slice.
const newArticles = processed.map(a => ({
url: a.url,
......
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