@event4u/agent-config 9.7.0 → 9.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +144 -0
- package/README.md +5 -5
- package/dist/agent-src/commands/commit/in-chunks.md +8 -0
- package/dist/agent-src/commands/commit.md +17 -0
- package/dist/agent-src/commands/pr/create.md +4 -0
- package/dist/agent-src/contexts/override-system.md +75 -5
- package/dist/agent-src/rules/context-hygiene.md +2 -0
- package/dist/agent-src/rules/doc-screenshot-hygiene.md +97 -0
- package/dist/agent-src/rules/domain-safety-pii.md +1 -0
- package/dist/agent-src/rules/external-code-graph-interop.md +13 -3
- package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
- package/dist/agent-src/rules/git-history-discipline.md +2 -0
- package/dist/agent-src/rules/language-and-tone.md +2 -0
- package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
- package/dist/agent-src/rules/media-governance-routing.md +2 -0
- package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
- package/dist/agent-src/rules/no-roadmap-references.md +3 -0
- package/dist/agent-src/rules/non-destructive-by-default.md +20 -0
- package/dist/agent-src/rules/onboarding-gate.md +2 -0
- package/dist/agent-src/rules/output-discipline.md +2 -0
- package/dist/agent-src/rules/persona-governance.md +2 -0
- package/dist/agent-src/rules/preservation-guard.md +3 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
- package/dist/agent-src/rules/secret-vcs-guard.md +106 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +1 -1
- package/dist/agent-src/rules/skill-quality.md +2 -0
- package/dist/agent-src/rules/source-confidentiality.md +2 -0
- package/dist/agent-src/rules/source-of-truth.md +2 -0
- package/dist/agent-src/rules/telegraph-speak.md +2 -0
- package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
- package/dist/agent-src/rules/tool-safety.md +1 -0
- package/dist/agent-src/rules/verify-before-complete.md +2 -0
- package/dist/agent-src/skills/adr-create/SKILL.md +17 -1
- package/dist/agent-src/skills/code-intelligence/SKILL.md +94 -0
- package/dist/agent-src/skills/gated-reach/SKILL.md +185 -0
- package/dist/agent-src/skills/gated-reach/evals/triggers.json +72 -0
- package/dist/agent-src/skills/image-analyser/SKILL.md +14 -12
- package/dist/agent-src/skills/image-editing/SKILL.md +1 -0
- package/dist/agent-src/skills/readme-writing/SKILL.md +6 -1
- package/dist/agent-src/skills/screenshot-hygiene/SKILL.md +171 -0
- package/dist/agent-src/skills/secrets-management/SKILL.md +44 -1
- package/dist/agent-src/skills/subagent-orchestration/prompts/adversarial-verification-council.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge-two-stage.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-competitively.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-parallel.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-steps.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-worktrees.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/judge-with-debate.md +29 -0
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +4 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/github-workflows/secret-scan.yml +65 -0
- package/dist/cli/configRoot.js +112 -0
- package/dist/cli/configRoot.js.map +1 -0
- package/dist/cli/configRoot.test.js +97 -0
- package/dist/cli/configRoot.test.js.map +1 -0
- package/dist/cli/help.js +4 -1
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +19 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +161 -44
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +13 -8
- package/dist/discovery/trust-report.md +11 -11
- package/dist/discovery/workspaces.json +11 -6
- package/dist/install/install.mjs +16 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/router.json +1 -1
- package/dist/server/app.js +10 -0
- package/dist/server/app.js.map +1 -1
- package/dist/server/routes/ping.js +18 -0
- package/dist/server/routes/ping.js.map +1 -1
- package/dist/server/schemas/settings.js +8 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/server/serverInfo.js +6 -2
- package/dist/server/serverInfo.js.map +1 -1
- package/dist/server/token.js +11 -4
- package/dist/server/token.js.map +1 -1
- package/dist/server/writeRoot.js +15 -1
- package/dist/server/writeRoot.js.map +1 -1
- package/dist/shared/capabilities.js +33 -0
- package/dist/shared/capabilities.js.map +1 -0
- package/dist/shared/capabilities.test.js +39 -0
- package/dist/shared/capabilities.test.js.map +1 -0
- package/dist/ui/assets/{index-BiEZiZ31.js → index-CrmZ34Rg.js} +16 -16
- package/dist/ui/assets/index-CrmZ34Rg.js.map +1 -0
- package/dist/ui/index.html +14 -5
- package/docs/CLAIMS.md +62 -37
- package/docs/architecture.md +2 -2
- package/docs/benchmark.md +137 -0
- package/docs/catalog.md +8 -3
- package/docs/comparison.yaml +44 -2
- package/docs/contracts/local-server-ports.md +53 -0
- package/docs/contracts/no-runtime-boundary.md +1 -1
- package/docs/contracts/subagent-boundary.md +15 -0
- package/docs/customization.md +14 -3
- package/docs/decisions/ADR-016-installer-architecture.md +2 -0
- package/docs/decisions/ADR-088-no-external-runtime-federation.md +1 -1
- package/docs/decisions/ADR-094-agent-memory-layer-removal.md +1 -1
- package/docs/decisions/ADR-109-subagent-v1-contract.md +2 -0
- package/docs/decisions/ADR-110-discipline-profile-resolution-locus.md +2 -0
- package/docs/decisions/ADR-113-ci-native-release-label-trigger.md +11 -3
- package/docs/decisions/ADR-124-embedded-engine-doctrine.md +255 -0
- package/docs/decisions/ADR-125-doc-screenshot-anonymization.md +131 -0
- package/docs/decisions/ADR-126-internet-reach-operator-tooling.md +254 -0
- package/docs/decisions/ADR-127-enforcement-claims-must-resolve.md +150 -0
- package/docs/decisions/ADR-128-executable-evidence-and-measured-escalation.md +201 -0
- package/docs/decisions/INDEX.md +21 -0
- package/docs/decisions/engine-reclassification-2026-07.md +104 -0
- package/docs/distribution/awesome-list-submission.md +1 -1
- package/docs/distribution/registries.md +36 -27
- package/docs/enforcement-by-host.md +7 -0
- package/docs/featured-skills.md +2 -2
- package/docs/getting-started-by-role.md +1 -1
- package/docs/getting-started.md +1 -1
- package/docs/governance-advantage.md +1 -1
- package/docs/guides/gated-platform-reads.md +221 -0
- package/docs/positioning-evidence.md +1 -1
- package/docs/proof.md +92 -16
- package/docs/threat-model.md +1 -0
- package/package.json +5 -2
- package/src/config/agent-settings.template.yml +43 -0
- package/src/config/reach-channels.yml +217 -0
- package/src/config/reach-prescriptions-intake.yml +200 -0
- package/src/scripts/_dispatch.bash +15 -0
- package/src/scripts/_lib/exec_evidence.ts +251 -0
- package/src/scripts/_lib/fixtures/secret-detector/negatives.txt +10 -0
- package/src/scripts/_lib/fixtures/secret-detector/positives.txt +11 -0
- package/src/scripts/_lib/kernel_rules.ts +46 -0
- package/src/scripts/_lib/reddit_thread_parse.ts +598 -0
- package/src/scripts/_lib/risk_paths.ts +160 -0
- package/src/scripts/_lib/secret_detector.test.ts +103 -0
- package/src/scripts/_lib/secret_detector.ts +319 -0
- package/src/scripts/_lib/tool_probe.ts +359 -0
- package/src/scripts/adr/regenerate_index.ts +81 -6
- package/src/scripts/audit_adr_coverage.ts +33 -0
- package/src/scripts/build_proof.ts +162 -0
- package/src/scripts/check_adr_frontmatter.ts +195 -0
- package/src/scripts/check_artefact_count_messaging.ts +4 -0
- package/src/scripts/check_backstop_debt.ts +200 -0
- package/src/scripts/check_claims.ts +188 -8
- package/src/scripts/check_comparison.ts +12 -0
- package/src/scripts/check_enforcement_coverage.ts +592 -0
- package/src/scripts/check_kernel_rule_bundle.ts +16 -12
- package/src/scripts/check_reach_channels.ts +407 -0
- package/src/scripts/check_reach_staleness.ts +364 -0
- package/src/scripts/check_secret_leak.test.ts +47 -0
- package/src/scripts/check_secret_leak.ts +290 -0
- package/src/scripts/code_graph/build.ts +0 -0
- package/src/scripts/code_graph/cli.ts +207 -0
- package/src/scripts/code_graph/detect.ts +134 -0
- package/src/scripts/code_graph/extract.ts +386 -0
- package/src/scripts/code_graph/loader.ts +123 -0
- package/src/scripts/code_graph/query.ts +176 -0
- package/src/scripts/code_graph/sanitize.ts +30 -0
- package/src/scripts/code_graph/types.ts +83 -0
- package/src/scripts/code_graph/validate.ts +63 -0
- package/src/scripts/external_sources_denylist.json +3 -1
- package/src/scripts/generate_subagent_floor.ts +194 -0
- package/src/scripts/hook_manifest.yaml +13 -3
- package/src/scripts/hooks/code_graph_nudge_hook.ts +184 -0
- package/src/scripts/iron_law_sha.ts +3 -12
- package/src/scripts/lint_override_kernel_guard.ts +219 -0
- package/src/scripts/measure_rule_budget.ts +3 -12
- package/src/scripts/postinstall_gui.ts +129 -0
- package/src/scripts/reach_doctor.ts +1651 -0
- package/src/scripts/release.ts +24 -3
- package/src/scripts/risk_escalation_shadow.ts +160 -0
- package/src/scripts/schemas/agent-settings.schema.json +23 -0
- package/src/scripts/schemas/reach-channels.schema.json +134 -0
- package/src/scripts/schemas/reach-doctor-payload.schema.json +220 -0
- package/src/scripts/schemas/rule.schema.json +10 -0
- package/src/scripts/skill_linter.ts +55 -0
- package/src/scripts/validate_reach_prescriptions.ts +751 -0
- package/src/server/schemas/settings.ts +16 -0
- package/src/shared/capabilities.test.ts +43 -0
- package/src/shared/capabilities.ts +75 -0
- package/src/templates/minimal/agents-overrides-readme.md +20 -0
- package/dist/ui/assets/index-BiEZiZ31.js.map +0 -1
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* reddit_thread_parse.ts — deterministic old.reddit thread → JSON extractor.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. Reach research needs the *shape* of a discussion (who said
|
|
6
|
+
* what, to whom, and how well it landed), not a screenshot. The public JSON
|
|
7
|
+
* endpoints are rate-limited and auth-gated; the rendered `old.reddit.com` HTML
|
|
8
|
+
* is not. So this reads that HTML and emits a stable record.
|
|
9
|
+
*
|
|
10
|
+
* DETERMINISTIC BY CONSTRUCTION. stdin → stdout. No network, no filesystem
|
|
11
|
+
* write, no clock, no environment read, no state. The same bytes in always
|
|
12
|
+
* produce the same bytes out, which is what makes the committed fixtures under
|
|
13
|
+
* `tests/fixtures/reddit-thread/` a real regression lock rather than a smoke
|
|
14
|
+
* test. Fetching is the CALLER's job (`curl … | tsx this-file`) — keeping the
|
|
15
|
+
* request outside this module is what keeps it testable offline.
|
|
16
|
+
*
|
|
17
|
+
* NESTING IS THE POINT — NOT A FLAT REGEX. old.reddit renders a reply as a
|
|
18
|
+
* `div.thing` inside its parent's `div.child > div.sitetable.listing`. Reply
|
|
19
|
+
* order and parent linkage ARE the signal being extracted, so `depth` and
|
|
20
|
+
* `parent_id` come from a real tag-stack walk over the container structure
|
|
21
|
+
* (`_scanDivs` → `_collectComments`). A regex over `data-fullname` would return
|
|
22
|
+
* the right ids in the wrong tree and quietly destroy the only thing worth
|
|
23
|
+
* reading. There is no DOM library in this package's runtime dependencies
|
|
24
|
+
* (`happy-dom` is dev-only, and adding a dep for this is not warranted), hence
|
|
25
|
+
* the hand-rolled scanner — which also skips `<script>` / `<style>` bodies and
|
|
26
|
+
* HTML comments so markup inside them cannot shift the depth count.
|
|
27
|
+
*
|
|
28
|
+
* NULL OVER GUESSES. A score-hidden comment yields `score: null`, never `0` —
|
|
29
|
+
* old.reddit omits the `span.score.unvoted` entirely for those, and reporting
|
|
30
|
+
* `0` would invent a downvoted comment that does not exist. Same for a missing
|
|
31
|
+
* or `[deleted]` author.
|
|
32
|
+
*
|
|
33
|
+
* HOSTILE INPUT BY DEFINITION. This parses third-party HTML that any anonymous
|
|
34
|
+
* user can write into. Every emitted string therefore passes `sanitize_text`
|
|
35
|
+
* (the shared retrieval floor) which strips bidi / zero-width / Unicode-Tag
|
|
36
|
+
* hidden-instruction vectors and control-char noise. Visible text is preserved
|
|
37
|
+
* verbatim as DATA — a comment body that reads like an instruction stays inert
|
|
38
|
+
* prose in a JSON string, and is never treated as one (untrusted-input floor).
|
|
39
|
+
*
|
|
40
|
+
* Entities are decoded to a FIXED POINT (bounded, `_ENTITY_PASSES`): measured
|
|
41
|
+
* reality on this content is that `&#32;` survives a single unescape pass,
|
|
42
|
+
* so one pass would leak a literal ` ` into the output. Decoding runs
|
|
43
|
+
* BEFORE sanitizing, so a numeric escape cannot smuggle a hidden codepoint past
|
|
44
|
+
* the floor.
|
|
45
|
+
*
|
|
46
|
+
* Output shape (stdout, one JSON object):
|
|
47
|
+
* { thread: { title, author, score, permalink },
|
|
48
|
+
* comments: [ { id, author, score, depth, parent_id, body } ],
|
|
49
|
+
* login_wall: boolean }
|
|
50
|
+
*
|
|
51
|
+
* Exit codes:
|
|
52
|
+
* 0 — parsed (including a legitimately empty / login-walled page).
|
|
53
|
+
* 3 — unusable input: nothing on stdin.
|
|
54
|
+
*
|
|
55
|
+
* Invocation:
|
|
56
|
+
* curl -sSL … 'https://old.reddit.com/r/x/comments/ID/' \
|
|
57
|
+
* | tsx src/scripts/_lib/reddit_thread_parse.ts
|
|
58
|
+
*/
|
|
59
|
+
import process from 'node:process';
|
|
60
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
61
|
+
import * as fs from 'node:fs';
|
|
62
|
+
import * as path from 'node:path';
|
|
63
|
+
|
|
64
|
+
import { sanitize_text } from './retrieval_sanitize.js';
|
|
65
|
+
|
|
66
|
+
/** Header fields of the submission itself. `null` for anything not present. */
|
|
67
|
+
export interface ThreadHeader {
|
|
68
|
+
readonly title: string | null;
|
|
69
|
+
readonly author: string | null;
|
|
70
|
+
readonly score: number | null;
|
|
71
|
+
readonly permalink: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One comment, located in the reply tree by `depth` + `parent_id`. */
|
|
75
|
+
export interface ParsedComment {
|
|
76
|
+
/** Reddit fullname, e.g. `t1_onfj038` — unique within the page. */
|
|
77
|
+
readonly id: string;
|
|
78
|
+
/** `null` when absent or `[deleted]` — never a placeholder string. */
|
|
79
|
+
readonly author: string | null;
|
|
80
|
+
/** `null` when the score is hidden — never `0`. */
|
|
81
|
+
readonly score: number | null;
|
|
82
|
+
/** 0 for a top-level comment; +1 per enclosing comment container. */
|
|
83
|
+
readonly depth: number;
|
|
84
|
+
/** Fullname of the enclosing comment, or `null` at top level. */
|
|
85
|
+
readonly parent_id: string | null;
|
|
86
|
+
/** Visible body text, entity-decoded then sanitized. */
|
|
87
|
+
readonly body: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ParsedThread {
|
|
91
|
+
readonly thread: ThreadHeader;
|
|
92
|
+
readonly comments: ParsedComment[];
|
|
93
|
+
/**
|
|
94
|
+
* True when the page is a login / interstitial rather than a thread —
|
|
95
|
+
* detected structurally (no comment nodes AND a login marker), so a real
|
|
96
|
+
* thread that merely links to `/login` is never misread as a wall.
|
|
97
|
+
*/
|
|
98
|
+
readonly login_wall: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Entity-decode passes. Two are needed in practice (`&#32;` → ` ` →
|
|
103
|
+
* ` `); the third is headroom. Bounded on purpose — an unbounded loop over
|
|
104
|
+
* attacker-supplied text is a denial-of-service lever.
|
|
105
|
+
*/
|
|
106
|
+
const _ENTITY_PASSES = 3;
|
|
107
|
+
|
|
108
|
+
const _NAMED_ENTITIES: Readonly<Record<string, string>> = {
|
|
109
|
+
amp: '&',
|
|
110
|
+
lt: '<',
|
|
111
|
+
gt: '>',
|
|
112
|
+
quot: '"',
|
|
113
|
+
apos: "'",
|
|
114
|
+
nbsp: ' ',
|
|
115
|
+
// Reddit's markdown renderer emits these directly.
|
|
116
|
+
hellip: '…',
|
|
117
|
+
mdash: '—',
|
|
118
|
+
ndash: '–',
|
|
119
|
+
lsquo: '‘',
|
|
120
|
+
rsquo: '’',
|
|
121
|
+
ldquo: '“',
|
|
122
|
+
rdquo: '”',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** One entity-decode pass: named + decimal + hex numeric references. */
|
|
126
|
+
function _decodeOnce(text: string): string {
|
|
127
|
+
return text.replace(/&(#[Xx]?[0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]{1,31});/g, (whole, bodyRaw: string) => {
|
|
128
|
+
const body = bodyRaw;
|
|
129
|
+
if (body.startsWith('#')) {
|
|
130
|
+
const isHex = body[1] === 'x' || body[1] === 'X';
|
|
131
|
+
const digits = isHex ? body.slice(2) : body.slice(1);
|
|
132
|
+
if (digits === '') {
|
|
133
|
+
return whole;
|
|
134
|
+
}
|
|
135
|
+
const cp = Number.parseInt(digits, isHex ? 16 : 10);
|
|
136
|
+
// Surrogates and out-of-range values are not codepoints; leave the
|
|
137
|
+
// reference visible rather than emitting a replacement char.
|
|
138
|
+
if (!Number.isFinite(cp) || cp < 0 || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) {
|
|
139
|
+
return whole;
|
|
140
|
+
}
|
|
141
|
+
return String.fromCodePoint(cp);
|
|
142
|
+
}
|
|
143
|
+
const named = _NAMED_ENTITIES[body.toLowerCase()];
|
|
144
|
+
return named ?? whole;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Decode HTML entities to a fixed point (at most `_ENTITY_PASSES` passes).
|
|
150
|
+
* Stops as soon as a pass is a no-op, so well-formed input costs one pass.
|
|
151
|
+
*/
|
|
152
|
+
export function decodeEntities(text: string): string {
|
|
153
|
+
let current = text;
|
|
154
|
+
for (let pass = 0; pass < _ENTITY_PASSES; pass += 1) {
|
|
155
|
+
const next = _decodeOnce(current);
|
|
156
|
+
if (next === current) {
|
|
157
|
+
return current;
|
|
158
|
+
}
|
|
159
|
+
current = next;
|
|
160
|
+
}
|
|
161
|
+
return current;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Decode-then-sanitize. The ONLY way a string reaches the output. */
|
|
165
|
+
function _clean(text: string): string {
|
|
166
|
+
return sanitize_text(decodeEntities(text));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface DivToken {
|
|
170
|
+
readonly isEnd: boolean;
|
|
171
|
+
/** Raw attribute text of an open tag (`''` for a close tag). */
|
|
172
|
+
readonly attrs: string;
|
|
173
|
+
/** Offset of `<`. */
|
|
174
|
+
readonly start: number;
|
|
175
|
+
/** Offset just past `>`. */
|
|
176
|
+
readonly end: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const _RAW_TEXT_TAGS = new Set(['script', 'style', 'textarea', 'title']);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Walk every tag in the document and yield the `div` tokens in order.
|
|
183
|
+
*
|
|
184
|
+
* Every tag is parsed — not just divs — because only a full walk knows where a
|
|
185
|
+
* tag actually ends: a quoted attribute may contain `>` (old.reddit ships
|
|
186
|
+
* inline `onclick=` handlers), and `<script>` / `<style>` / `<title>` bodies
|
|
187
|
+
* are raw text where a literal `<div` must NOT count as an element. Skipping
|
|
188
|
+
* those is what keeps the depth arithmetic honest.
|
|
189
|
+
*/
|
|
190
|
+
function _scanDivs(html: string): DivToken[] {
|
|
191
|
+
const tokens: DivToken[] = [];
|
|
192
|
+
const length = html.length;
|
|
193
|
+
let index = 0;
|
|
194
|
+
|
|
195
|
+
while (index < length) {
|
|
196
|
+
const lt = html.indexOf('<', index);
|
|
197
|
+
if (lt === -1) {
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// HTML comment / CDATA / doctype-ish bogus comment.
|
|
202
|
+
if (html.startsWith('<!--', lt)) {
|
|
203
|
+
const close = html.indexOf('-->', lt + 4);
|
|
204
|
+
index = close === -1 ? length : close + 3;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (html.startsWith('<!', lt) || html.startsWith('<?', lt)) {
|
|
208
|
+
const close = html.indexOf('>', lt + 2);
|
|
209
|
+
index = close === -1 ? length : close + 1;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const isEnd = html[lt + 1] === '/';
|
|
214
|
+
const nameStart = lt + (isEnd ? 2 : 1);
|
|
215
|
+
const nameMatch = /^[A-Za-z][A-Za-z0-9-]*/.exec(html.slice(nameStart, nameStart + 32));
|
|
216
|
+
if (!nameMatch) {
|
|
217
|
+
// A stray `<` in text — not a tag. Step past it.
|
|
218
|
+
index = lt + 1;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const name = nameMatch[0].toLowerCase();
|
|
222
|
+
|
|
223
|
+
// Find the tag end, honouring quoted attribute values.
|
|
224
|
+
let cursor = nameStart + nameMatch[0].length;
|
|
225
|
+
let quote: string | null = null;
|
|
226
|
+
let tagEnd = -1;
|
|
227
|
+
while (cursor < length) {
|
|
228
|
+
const ch = html[cursor];
|
|
229
|
+
if (quote !== null) {
|
|
230
|
+
if (ch === quote) {
|
|
231
|
+
quote = null;
|
|
232
|
+
}
|
|
233
|
+
} else if (ch === '"' || ch === "'") {
|
|
234
|
+
quote = ch;
|
|
235
|
+
} else if (ch === '>') {
|
|
236
|
+
tagEnd = cursor;
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
cursor += 1;
|
|
240
|
+
}
|
|
241
|
+
if (tagEnd === -1) {
|
|
242
|
+
break; // truncated tag at EOF
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const attrs = html.slice(nameStart + nameMatch[0].length, tagEnd);
|
|
246
|
+
index = tagEnd + 1;
|
|
247
|
+
|
|
248
|
+
if (!isEnd && _RAW_TEXT_TAGS.has(name) && !attrs.trimEnd().endsWith('/')) {
|
|
249
|
+
// Raw-text element: everything up to its close tag is text.
|
|
250
|
+
const closeRe = new RegExp(`</${name}\\s*>`, 'i');
|
|
251
|
+
const rest = html.slice(index);
|
|
252
|
+
const closeMatch = closeRe.exec(rest);
|
|
253
|
+
index = closeMatch ? index + closeMatch.index + closeMatch[0].length : length;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (name === 'div') {
|
|
258
|
+
tokens.push({ isEnd, attrs: isEnd ? '' : attrs, start: lt, end: index });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return tokens;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Read one attribute out of a raw attribute string. */
|
|
266
|
+
function _attr(attrs: string, key: string): string | null {
|
|
267
|
+
const re = new RegExp(`\\b${key}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i');
|
|
268
|
+
const match = re.exec(attrs);
|
|
269
|
+
if (!match) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
return match[2] ?? match[3] ?? null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** True when the tag's `class` attribute carries the given whole word. */
|
|
276
|
+
function _hasClass(attrs: string, className: string): boolean {
|
|
277
|
+
const raw = _attr(attrs, 'class');
|
|
278
|
+
if (raw === null) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
// Reddit writes `class=" thing id-x   comment "` — the entity is a
|
|
282
|
+
// separator, so decode before word-splitting.
|
|
283
|
+
return decodeEntities(raw).split(/\s+/).includes(className);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
interface CommentNode {
|
|
287
|
+
readonly id: string;
|
|
288
|
+
readonly attrs: string;
|
|
289
|
+
readonly depth: number;
|
|
290
|
+
readonly parent_id: string | null;
|
|
291
|
+
/** Offset just past this comment's own open tag. */
|
|
292
|
+
readonly contentStart: number;
|
|
293
|
+
/** Offset just past its matching `</div>`; `html.length` if unbalanced. */
|
|
294
|
+
contentEnd: number;
|
|
295
|
+
/** Start offset of its first descendant comment, if any. */
|
|
296
|
+
firstChildStart: number | null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Tag-stack walk producing every comment node with a REAL `depth` /
|
|
301
|
+
* `parent_id` taken from container nesting.
|
|
302
|
+
*
|
|
303
|
+
* A `div.thing[data-type="comment"]` is a comment. Sibling `data-type`s
|
|
304
|
+
* (`morechildren` stubs, the `link` header) are deliberately not: the
|
|
305
|
+
* `morechildren` stub reuses its parent's `id`, so keying off `id` alone would
|
|
306
|
+
* emit a duplicate node.
|
|
307
|
+
*/
|
|
308
|
+
function _collectComments(html: string): CommentNode[] {
|
|
309
|
+
const tokens = _scanDivs(html);
|
|
310
|
+
const nodes: CommentNode[] = [];
|
|
311
|
+
/** Every open div, by nesting order. `null` for a non-comment div. */
|
|
312
|
+
const divStack: (CommentNode | null)[] = [];
|
|
313
|
+
/** The open comment ancestors, innermost last. */
|
|
314
|
+
const commentStack: CommentNode[] = [];
|
|
315
|
+
|
|
316
|
+
for (const token of tokens) {
|
|
317
|
+
if (token.isEnd) {
|
|
318
|
+
const popped = divStack.pop();
|
|
319
|
+
if (popped) {
|
|
320
|
+
popped.contentEnd = token.start;
|
|
321
|
+
commentStack.pop();
|
|
322
|
+
}
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const isComment = _attr(token.attrs, 'data-type') === 'comment' && _hasClass(token.attrs, 'thing');
|
|
327
|
+
if (!isComment) {
|
|
328
|
+
divStack.push(null);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const parent = commentStack[commentStack.length - 1] ?? null;
|
|
333
|
+
const id = _attr(token.attrs, 'data-fullname') ?? _attr(token.attrs, 'id') ?? `#${nodes.length}`;
|
|
334
|
+
const node: CommentNode = {
|
|
335
|
+
id,
|
|
336
|
+
attrs: token.attrs,
|
|
337
|
+
depth: commentStack.length,
|
|
338
|
+
parent_id: parent?.id ?? null,
|
|
339
|
+
contentStart: token.end,
|
|
340
|
+
contentEnd: html.length,
|
|
341
|
+
firstChildStart: null,
|
|
342
|
+
};
|
|
343
|
+
// Ancestors record where their own content stops and replies begin.
|
|
344
|
+
for (const ancestor of commentStack) {
|
|
345
|
+
if (ancestor.firstChildStart === null) {
|
|
346
|
+
ancestor.firstChildStart = token.start;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
nodes.push(node);
|
|
350
|
+
divStack.push(node);
|
|
351
|
+
commentStack.push(node);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return nodes;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The slice of a comment that belongs to IT and not to its replies — from just
|
|
359
|
+
* past its open tag to the start of its first nested reply.
|
|
360
|
+
*
|
|
361
|
+
* This is defence-in-depth, NOT a fix for an observed bug — measured, and the
|
|
362
|
+
* measurement is recorded here so nobody re-derives it: removing the cut changes
|
|
363
|
+
* **nothing** on either the committed fixture (15 comments) or a 134-comment live
|
|
364
|
+
* thread spanning 7 depths — 0 differing fields. The reason is that both
|
|
365
|
+
* `_extractScore` and `_extractBody` take the FIRST match in the region, and a
|
|
366
|
+
* comment's own score span and body always precede its first child's.
|
|
367
|
+
*
|
|
368
|
+
* Keep the cut anyway: it is two lines, and it is what makes those extractors free
|
|
369
|
+
* to stop being first-match (a last-match, a max-of, or a multi-capture variant
|
|
370
|
+
* would silently start reading the child's values without it). Do not add a test
|
|
371
|
+
* asserting it — there is no behavioural difference to assert.
|
|
372
|
+
*/
|
|
373
|
+
function _ownRegion(html: string, node: CommentNode): string {
|
|
374
|
+
const end = node.firstChildStart ?? node.contentEnd;
|
|
375
|
+
return html.slice(node.contentStart, Math.max(node.contentStart, end));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Score from the `unvoted` variant — the only one that is the real number
|
|
380
|
+
* (`dislikes` is score−1, `likes` is score+1). Absent → `null`, because
|
|
381
|
+
* old.reddit omits the span entirely on a score-hidden comment.
|
|
382
|
+
*/
|
|
383
|
+
function _extractScore(region: string): number | null {
|
|
384
|
+
const match = /<(?:span|div)\b[^>]*\bclass\s*=\s*"[^"]*\bscore\b[^"]*\bunvoted\b[^"]*"[^>]*\btitle\s*=\s*"(-?\d+)"/i.exec(
|
|
385
|
+
region,
|
|
386
|
+
);
|
|
387
|
+
if (!match?.[1]) {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
const value = Number.parseInt(match[1], 10);
|
|
391
|
+
return Number.isFinite(value) ? value : null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const _BLOCK_BREAK_RE =
|
|
395
|
+
/<\s*\/?\s*(?:p|br|div|blockquote|li|ul|ol|h[1-6]|pre|tr|table|hr)\b[^>]*>/gi;
|
|
396
|
+
|
|
397
|
+
/** Markup → visible text. Block boundaries become newlines; tags vanish. */
|
|
398
|
+
function _htmlToText(fragment: string): string {
|
|
399
|
+
return fragment
|
|
400
|
+
.replace(_BLOCK_BREAK_RE, '\n')
|
|
401
|
+
.replace(/<[^>]*>/g, '')
|
|
402
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
403
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
404
|
+
.trim();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The comment's markdown body. Takes the FIRST `div.md` in the own-region:
|
|
409
|
+
* the rendered body precedes both the reply form's empty template and any
|
|
410
|
+
* nested reply, so "first" is unambiguous.
|
|
411
|
+
*/
|
|
412
|
+
function _extractBody(region: string): string {
|
|
413
|
+
const open = /<div\b[^>]*\bclass\s*=\s*"[^"]*\bmd\b[^"]*"[^>]*>/i.exec(region);
|
|
414
|
+
if (!open) {
|
|
415
|
+
return '';
|
|
416
|
+
}
|
|
417
|
+
// Balanced-div walk so a nested <div> inside the body (code block, table)
|
|
418
|
+
// does not truncate it at the first </div>.
|
|
419
|
+
const bodyStart = open.index + open[0].length;
|
|
420
|
+
const tokens = _scanDivs(region.slice(bodyStart));
|
|
421
|
+
let depth = 0;
|
|
422
|
+
let bodyEnd = region.length - bodyStart;
|
|
423
|
+
for (const token of tokens) {
|
|
424
|
+
if (token.isEnd) {
|
|
425
|
+
if (depth === 0) {
|
|
426
|
+
bodyEnd = token.start;
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
depth -= 1;
|
|
430
|
+
} else {
|
|
431
|
+
depth += 1;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return _htmlToText(region.slice(bodyStart, bodyStart + bodyEnd));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** `[deleted]` / `[removed]` are placeholders, not authors → `null`. */
|
|
438
|
+
function _normalizeAuthor(raw: string | null): string | null {
|
|
439
|
+
if (raw === null) {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
const author = _clean(raw).trim();
|
|
443
|
+
if (author === '' || author === '[deleted]' || author === '[removed]') {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
return author;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Header of the submission — from the `data-type="link"` thing. */
|
|
450
|
+
function _extractThread(html: string): ThreadHeader {
|
|
451
|
+
const tokens = _scanDivs(html);
|
|
452
|
+
const linkToken = tokens.find(
|
|
453
|
+
(token) => !token.isEnd && _attr(token.attrs, 'data-type') === 'link' && _hasClass(token.attrs, 'thing'),
|
|
454
|
+
);
|
|
455
|
+
if (!linkToken) {
|
|
456
|
+
return { title: null, author: null, score: null, permalink: null };
|
|
457
|
+
}
|
|
458
|
+
const { attrs } = linkToken;
|
|
459
|
+
const region = html.slice(linkToken.end, linkToken.end + 20_000);
|
|
460
|
+
|
|
461
|
+
const titleMatch = /<a\b[^>]*\bclass\s*=\s*"[^"]*\btitle\b[^"]*"[^>]*>([\s\S]*?)<\/a>/i.exec(region);
|
|
462
|
+
const rawTitle = titleMatch?.[1] ?? null;
|
|
463
|
+
const title = rawTitle === null ? null : _clean(_htmlToText(rawTitle)) || null;
|
|
464
|
+
|
|
465
|
+
const rawScore = _attr(attrs, 'data-score');
|
|
466
|
+
let score: number | null = null;
|
|
467
|
+
if (rawScore !== null && /^-?\d+$/.test(rawScore.trim())) {
|
|
468
|
+
score = Number.parseInt(rawScore.trim(), 10);
|
|
469
|
+
} else {
|
|
470
|
+
score = _extractScore(region);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const permalinkRaw = _attr(attrs, 'data-permalink') ?? _attr(attrs, 'data-url');
|
|
474
|
+
const permalink = permalinkRaw === null ? null : _clean(permalinkRaw).trim() || null;
|
|
475
|
+
|
|
476
|
+
return { title, author: _normalizeAuthor(_attr(attrs, 'data-author')), score, permalink };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Login / interstitial detection — STRUCTURAL, and only ever consulted when the
|
|
481
|
+
* page yielded zero comment nodes. A real thread routinely links to `/login`
|
|
482
|
+
* (every vote arrow is `login-required`), so a marker alone must never flip
|
|
483
|
+
* this; the absence of any comment node is the load-bearing half.
|
|
484
|
+
*/
|
|
485
|
+
function _detectLoginWall(html: string, commentCount: number): boolean {
|
|
486
|
+
if (commentCount > 0) {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
const hasLoginForm = /<form\b[^>]*\b(?:id|class|action)\s*=\s*"[^"]*login[^"]*"/i.test(html);
|
|
490
|
+
const hasPasswordField =
|
|
491
|
+
/<input\b[^>]*\btype\s*=\s*"password"/i.test(html) || /<input\b[^>]*\bname\s*=\s*"passwd"/i.test(html);
|
|
492
|
+
const hasRefreshRedirect = /<meta\b[^>]*\bhttp-equiv\s*=\s*"refresh"/i.test(html);
|
|
493
|
+
const hasBlockedMarker = /\b(?:you must log in|log ?in to continue|whoa there, pardner|rate ?limit)\b/i.test(html);
|
|
494
|
+
return hasLoginForm || hasPasswordField || hasRefreshRedirect || hasBlockedMarker;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Parse a thread page. Pure: no I/O, no clock, no globals. Returns an empty
|
|
499
|
+
* `comments` array (never a fabricated one) when the page carries no comment
|
|
500
|
+
* nodes.
|
|
501
|
+
*/
|
|
502
|
+
export function parseThread(html: string): ParsedThread {
|
|
503
|
+
const nodes = _collectComments(html);
|
|
504
|
+
const loginWall = _detectLoginWall(html, nodes.length);
|
|
505
|
+
|
|
506
|
+
if (loginWall) {
|
|
507
|
+
// A wall has no thread to report and no comments to invent.
|
|
508
|
+
return {
|
|
509
|
+
thread: { title: null, author: null, score: null, permalink: null },
|
|
510
|
+
comments: [],
|
|
511
|
+
login_wall: true,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const comments: ParsedComment[] = nodes.map((node) => {
|
|
516
|
+
const region = _ownRegion(html, node);
|
|
517
|
+
return {
|
|
518
|
+
id: _clean(node.id).trim(),
|
|
519
|
+
author: _normalizeAuthor(_attr(node.attrs, 'data-author')),
|
|
520
|
+
score: _extractScore(region),
|
|
521
|
+
depth: node.depth,
|
|
522
|
+
parent_id: node.parent_id === null ? null : _clean(node.parent_id).trim(),
|
|
523
|
+
body: _clean(_extractBody(region)),
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
return { thread: _extractThread(html), comments, login_wall: false };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function _readStdin(): Promise<string> {
|
|
531
|
+
return new Promise((resolve, reject) => {
|
|
532
|
+
const chunks: Buffer[] = [];
|
|
533
|
+
process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
534
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
535
|
+
process.stdin.on('error', reject);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const HELP = `reddit_thread_parse — deterministic old.reddit thread HTML → JSON
|
|
540
|
+
|
|
541
|
+
Usage:
|
|
542
|
+
curl -sSL -H 'User-Agent: <a real browser UA>' \\
|
|
543
|
+
'https://old.reddit.com/r/<sub>/comments/<id>/' \\
|
|
544
|
+
| tsx src/scripts/_lib/reddit_thread_parse.ts
|
|
545
|
+
|
|
546
|
+
Reads HTML on stdin, writes one JSON object on stdout:
|
|
547
|
+
{ thread: { title, author, score, permalink },
|
|
548
|
+
comments: [ { id, author, score, depth, parent_id, body } ],
|
|
549
|
+
login_wall: boolean }
|
|
550
|
+
|
|
551
|
+
Offline and deterministic: no request, no write, no clock. Fetching is the
|
|
552
|
+
caller's job, which is what makes the committed fixtures a real regression lock.
|
|
553
|
+
\`depth\` / \`parent_id\` come from container nesting, not a flat regex.
|
|
554
|
+
A hidden score is \`null\`, never \`0\`. Every string passes the sanitize floor
|
|
555
|
+
(bidi / zero-width / Unicode-Tag vectors stripped; visible text kept as DATA).
|
|
556
|
+
|
|
557
|
+
Exit codes: 0 parsed (incl. an empty or login-walled page) · 3 empty stdin.
|
|
558
|
+
`;
|
|
559
|
+
|
|
560
|
+
export async function main(argv: string[] = process.argv.slice(2)): Promise<number> {
|
|
561
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
562
|
+
process.stdout.write(HELP);
|
|
563
|
+
return 0;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const html = await _readStdin();
|
|
567
|
+
if (html.trim() === '') {
|
|
568
|
+
process.stderr.write('❌ reddit_thread_parse: no HTML on stdin (pipe a thread page in)\n');
|
|
569
|
+
return 3;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
process.stdout.write(`${JSON.stringify(parseThread(html), null, 2)}\n`);
|
|
573
|
+
return 0;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function _isCliEntry(): boolean {
|
|
577
|
+
if (!process.argv[1]) {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
581
|
+
if (import.meta.url === argvUrl) {
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
// A symlinked invocation makes the raw URLs differ (import.meta.url is the
|
|
585
|
+
// resolved real path while argv[1] keeps the symlink path) — compare
|
|
586
|
+
// realpaths so the CLI still fires through a projection symlink.
|
|
587
|
+
try {
|
|
588
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
589
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
590
|
+
return here === argv;
|
|
591
|
+
} catch {
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (_isCliEntry()) {
|
|
597
|
+
process.exit(await main());
|
|
598
|
+
}
|