> **All commands assume `cd <skill-root>` first** (the directory containing this `SKILL.md`). Scripts live in the `scripts/` subfolder — **always prefix with `scripts/`**, e.g. `node scripts/harvest.js`, never `node harvest.js` (that fails with `MODULE_NOT_FOUND`).
...
...
@@ -127,9 +128,14 @@ Each source can have a **recipe** — a `helpers/<source-id>.md` file whose YAML
> **Precedence**: when a recipe exists, its `urlPattern`/selectors are the source of truth and `config.json`'s `articleUrlPattern` is **ignored**. Sources with a recipe therefore don't need (and shouldn't carry) `articleUrlPattern` in config — it's a legacy-direct-source field only. `source-manage.js list` shows a `RECIPE` column so you can tell at a glance which sources are on which path.
Recipes solve the "can't find high-quality data on new CDP sources" problem: instead of one hard-coded regex scanning the whole page (which mixes nav/sidebar/footer links with real articles), a recipe scopes link collection to a `listSelector` container, triggers lazy-load via `scrollSteps`, and validates URLs with a tightened `urlPattern`. The recipe is just markdown — edit it to adapt to a site redesign without touching code.
Recipes solve the "can't find high-quality data on new CDP sources" problem: instead of one hard-coded regex scanning the whole page (which mixes nav/sidebar/footer links with real articles), a recipe scopes link collection to a `listSelector` container, triggers lazy-load via `scrollSteps`, and validates URLs with a `urlPattern`. Two layers of filtering keep the candidate pool clean:
**Adding a new browser source** (3-step closed loop: add → inspect --write → verify):
1. **Link collection** (`listSelector` + `urlPattern`): scope to the article-card containers and match a URL shape. This is the coarse filter — it runs once on the list page.
2. **Article validation** (`validateArticle`, optional): when a candidate page is actually fetched, probe `og:type` + JSON-LD `@type` + body length and reject non-article pages (section/category pages that share an article's URL shape). This is the fine filter — it lets you use a *broad* `urlPattern` (no fragile "slug must have N hyphens" guesswork) and still keep section pages out of the archive.
The recipe is just markdown — edit it to adapt to a site redesign without touching code.
**Adding a new browser source** (4-step closed loop: add → inspect --write → verify → lock with regression tests):
```bash
# 1. Add to config (browser sources should get a recipe — articleUrlPattern is legacy-only)
node scripts/source-manage.js add --id mysrc --name "My Source" --url https://example.com/ --method browser --folder "My Source"
# 4. Lock against regressions (after the recipe is stable)
node scripts/recipe-test.js mysrc # needs helpers/mysrc.fixtures.json — see below
```
**Convergence check (step 3)**: preview passes when the fetched links are real articles (not nav/section pages) and `charCount > 500`. Diagnose failures against the candidate count `harvest.js` prints (`Collected N candidate links`):
- **Too few candidates** → the `listSelector` is too narrow. Run `inspect` (without `--write`) and check whether articles live in *multiple* card-container classes. If so, use a **composite `listSelector`** (see pitfall below).
- **Candidates are nav/section pages** → either tighten `urlPattern`, or (preferred for sites where article and section URLs share a shape) set `validateArticle: true` and let `fetchPage` reject section pages by `og:type`/body.
- **Empty body** → fix `bodySelectors`.
- **0 candidates, all `/location/`-style nav links** → `listSelector: null` (whole page) is almost always wrong; nav dropdowns flood the `maxLinks` cap before real articles are reached. Scope to card containers instead.
Re-run preview after each edit. **Do not hand-edit the recipe blindly** — re-run `inspect` (without `--write`) to see the container groups if unsure.
#### 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` to enumerate which `*ArticleCard*` classes carry links if unsure.
#### 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` marks the section page `isArticle=false` (via `og:type=website` / `@type=Thing` / empty body) and `harvest.js` skips it.
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.
#### Locking a stable recipe with regression tests (step 4)
Once a recipe converges, capture a fixture set so a future site redesign is caught instead of silently dropping articles. Create `helpers/<id>.fixtures.json`:
**Convergence check**: preview passes when the fetched links are real articles (not nav/section pages) and `charCount > 500`. If it fails — links are nav/section → tighten `urlPattern` in `helpers/mysrc.md`; too few links → raise `scrollSteps`; empty body → fix `bodySelectors`. Re-run preview after each edit. **Do not hand-edit the recipe blindly** — re-run `inspect` (without `--write`) to see the container groups if unsure.
Then `node scripts/recipe-test.js <id>` asserts: every article URL fetches `isArticle=true` with `bodyChars ≥ bodyMinChars`; every section URL fetches `isArticle=false`; the homepage yields ≥ `minLinks` and zero `excludeUrlPattern` matches. Pick fixture URLs across the path depths the site uses (2-seg and 3-seg) so depth regressions surface. When the site archives a fixture article, swap in a current one from the same path shape.
**Once verified, archive** by following Flow A below (harvest writes originals → you write `.summaries.json` → finalize). The harvest stderr prints the `Day dir` and the exact next command — follow that, don't parse the stdout manifest.
| `scrollSteps` | `0` | Bottom-scroll iterations to trigger lazy-loaded lists (fixed-steps mode; used by `--preview` and when `maxScrollSteps` doesn't apply) |
| `maxScrollSteps` | `12` | Scroll ceiling in target-count mode (archive harvest). When the harvest needs more candidates than a fixed pass yields, it keeps scrolling up to this many iterations, exiting early once enough candidates are collected or the stream goes stale (3 consecutive scrolls add nothing) |
| `scrollWaitMs` | `1500` | Pause after each scroll |
| `bodySelectors` | (6 selectors) | Tried in order; first yielding ≥ `bodyMinParagraphs` wins |
| `bodyMinParagraphs` | `3` | Min paragraph count for a selector to qualify |
| `validateArticle` | `false` | When `true`, `fetchPage` probes `og:type` + JSON-LD `@type` + body length and returns `isArticle`; `harvest.js` skips candidates where `isArticle === false`. Use with a *broad* `urlPattern` (so candidate URLs aren't pre-filtered by fragile slug heuristics) to reject section/category pages that slip through once the page is actually fetched. Off by default → existing recipes unchanged. See `helpers/nikkei-tech.md`for a worked example. |
| `bodyMinChars` | `500` | Floor on body length for`isArticle`(only consulted when `validateArticle: true`) |
| `dateSelector` | `time[datetime]` | Falls back to JSON-LD `datePublished` |
| `authorSelector` | `[rel="author"], [class*="author"] a` | Falls back to JSON-LD `author.name` |