diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml new file mode 100644 index 0000000..6d3843c --- /dev/null +++ b/.github/workflows/generate.yml @@ -0,0 +1,75 @@ +name: AI Generate Post + +# Generates a blog post from memory/ via the selected AI provider, then opens +# a Pull Request against develop for human review. Nothing is published +# directly — merging the PR (and later develop -> main) does the publishing. +# +# Triggers: +# - daily on a schedule +# - manually via the Actions tab +# +# Secrets (optional): ANTHROPIC_API_KEY or OPENAI_API_KEY. If neither is set, +# the generator runs its dry-run stub and still opens a PR for review. + +on: + schedule: + # 22:43 UTC daily (~06:43 Asia/Shanghai) — before the publish workflow. + - cron: "43 22 * * *" + workflow_dispatch: + inputs: + provider: + description: "Provider override (anthropic | openai | dryrun)" + required: false + default: "" + +permissions: + contents: write + pull-requests: write + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: develop + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Install AI SDK (if a key is configured) + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + if [ -n "$ANTHROPIC_API_KEY" ]; then npm install @anthropic-ai/sdk; fi + if [ -n "$OPENAI_API_KEY" ]; then npm install openai; fi + + - name: Generate post + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENBLOG_PROVIDER: ${{ github.event.inputs.provider }} + run: node scripts/generate.mjs --date "$(date -u +%F)" + + - name: Open pull request + uses: peter-evans/create-pull-request@v6 + with: + base: develop + branch: ai/post-${{ github.run_id }} + add-paths: posts/** + commit-message: "AI: new post from memory ($(date -u +%F))" + title: "AI draft: new post from memory ($(date -u +%F))" + body: | + Auto-generated blog post from `memory/` notes, for review. + + - Generated by the **AI Generate Post** workflow. + - Review the content, then merge into `develop`. + - Release happens separately via `develop` -> `main`. + labels: ai-generated + delete-branch: true diff --git a/AGENTS.md b/AGENTS.md index 6772eef..e8c8f26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,15 +18,21 @@ Pages by CI. . ├── AGENTS.md # this file ├── README.md # positioning + usage -├── package.json # scripts: build, serve -├── posts/ # Markdown articles (the content source) +├── package.json # scripts: build, serve, generate +├── memory/ # daily raw thoughts (input for AI generation) +│ └── *.md # one note per thought; front matter: date, tags +├── posts/ # Markdown articles (the site content source) │ └── *.md # front matter: title, date, tags, summary ├── scripts/ -│ └── build.mjs # static-site generator (Markdown -> public/) +│ ├── build.mjs # static-site generator (Markdown -> public/) +│ ├── serve.mjs # local preview server +│ ├── generate.mjs # AI article generator (memory/ -> posts/) +│ └── providers/ # pluggable AI providers (anthropic/openai/dryrun) ├── public/ # build output (git-ignored, regenerated) └── .github/ └── workflows/ - └── publish.yml # scheduled generate + publish to Pages + ├── publish.yml # scheduled build + publish to Pages (from main) + └── generate.yml # scheduled AI generate -> PR to develop ``` ## Branch policy — IMPORTANT @@ -47,6 +53,7 @@ Toolchain: Node.js (ESM), deps `marked` + `gray-matter`. npm ci # install (CI) — or npm install locally npm run build # render posts/ -> public/ npm run serve # build, then serve public/ at http://localhost:8080 +npm run generate -- --provider dryrun # memory/ -> a new posts/*.md (no API key needed) ``` Validation for a change: `npm run build` succeeds and produces @@ -72,6 +79,17 @@ summary: One-line teaser shown on the index. All fields are optional — missing title falls back to the first heading or the filename; missing date falls back to file mtime. +## AI generation (memory/ -> posts/) + +`scripts/generate.mjs` reads recent notes from `memory/`, asks the selected AI +provider to distill them into one article, and writes it to `posts/`. Provider +selection (`scripts/providers/index.mjs`): `OPENBLOG_PROVIDER` override, else +`ANTHROPIC_API_KEY` -> anthropic, else `OPENAI_API_KEY` -> openai, else +**dryrun** (a zero-dependency, no-network stub so the pipeline always runs). The +SDKs are `optionalDependencies` — install only when wiring a real key. The +`generate.yml` workflow runs this and opens a PR to `develop` for review; it +never publishes directly. + ## Conventions - Keep changes focused and minimal; match the style of surrounding files. diff --git a/README.md b/README.md index 24b2dd7..f0b39c3 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,8 @@ OpenBlog 用 AI Agent 把这条链路自动化: - [x] 静态站点生成器(Markdown → HTML) - [x] 自动构建与发布(GitHub Pages,定时 + 触发) -- [ ] 记忆采集与结构化存储 -- [ ] AI 文章生成流水线(由记忆自动撰写) +- [x] AI 文章生成流水线(由记忆自动撰写,provider 可插拔) +- [ ] 记忆采集与结构化存储(目前为手动写入 `memory/`) - [ ] 站点主题与个人化配置 ## 快速开始 @@ -100,13 +100,38 @@ summary: 显示在首页的一句话简介。 所有字段都可省略 —— 没有 title 就用第一个标题或文件名,没有 date 就用文件修改时间。运行 `npm run build` 后,文章就会出现在站点上。 +## 让 AI 由记忆自动写文章 + +把每天零散的所思所想写进 `memory/`(一篇 `.md` 一段想法),然后让 AI 把它们沉淀成一篇博客: + +```bash +npm run generate # 读取近 7 天的 memory/ → 生成一篇文章到 posts/ +npm run generate -- --days 3 --max 5 # 只看最近 3 天、最多 5 条记忆 +npm run generate -- --provider dryrun # 强制用占位生成器(不调用 AI) +``` + +**Provider 可插拔,模型待定 —— 无需 key 也能跑:** + +| 条件 | 使用的 provider | +| --- | --- | +| 设了 `ANTHROPIC_API_KEY` | `anthropic`(Claude,默认 `claude-opus-4-8`) | +| 设了 `OPENAI_API_KEY` | `openai` | +| 都没设 | `dryrun`(占位生成器,把记忆整理成文章骨架,**零依赖、不联网**) | + +> 想接真实模型:设好对应的环境变量,再 `npm install @anthropic-ai/sdk`(或 `openai`)即可。可用 `OPENBLOG_PROVIDER` / `OPENBLOG_MODEL` 覆盖默认选择。 + +CI 里的 **AI Generate Post** 工作流会每日定时(或手动触发)运行生成器,并把结果**开成一个针对 `develop` 的 PR 供你审阅**;审阅合并后再走 `develop → main` 发布。API key 通过仓库 Secrets(`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`)注入;没配也会用 dryrun 开 PR。 + ## 目录结构 ``` -posts/ # Markdown 文章(内容源) -scripts/build.mjs # 静态站点生成器 +memory/ # 每日零散想法(AI 生成的输入源) +posts/ # Markdown 文章(站点内容) +scripts/build.mjs # 静态站点生成器(Markdown → public/) +scripts/generate.mjs # AI 文章生成器(memory/ → posts/) +scripts/providers/ # 可插拔的 AI provider(anthropic / openai / dryrun) public/ # 构建产物(自动生成,已 gitignore) -.github/workflows/ # 定时生成 + 自动发布到 Pages +.github/workflows/ # 定时生成开 PR + 自动发布到 Pages ``` ## 分支规则 diff --git a/memory/.gitkeep b/memory/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/memory/2026-06-12-example.md b/memory/2026-06-12-example.md new file mode 100644 index 0000000..a8d93dc --- /dev/null +++ b/memory/2026-06-12-example.md @@ -0,0 +1,11 @@ +--- +date: 2026-06-12 +tags: [思考, 工具] +--- + +# 今天的零散想法 + +- 读到一句话:「工具的价值不在于它能做什么,而在于它让你不必做什么。」一直在想,博客最该被自动化掉的不是「写」,而是「坚持写」这件事本身。 +- 试着把一天里冒出来的念头随手记到这个 memory/ 目录,哪怕只是一句话。它们是原料,不是成品。 +- 一个观察:碎片想法之所以容易丢,是因为它们没有「被处理」的下一步。如果记下来之后会自动被沉淀成一篇文章,记录的动力就完全不一样了。 +- 待深入:记忆 → 文章 的转化,关键不是润色,而是「从一堆零散里找出那条主线」。 diff --git a/package-lock.json b/package-lock.json index 971ee96..6af4e96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,75 @@ "version": "0.1.0", "dependencies": { "gray-matter": "^4.0.3", - "marked": "^12.0.0" + "marked": "^12.0.0", + "sanitize-html": "^2.17.5" + }, + "optionalDependencies": { + "@anthropic-ai/sdk": "^0.40.0", + "openai": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.40.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.40.1.tgz", + "integrity": "sha512-DJMWm8lTEM9Lk/MSFL+V+ugF7jKOn0M2Ujvb5fN8r2nY14aHbGPZ1k6sgjL+tpJ3VuOGJNG+4R83jEpOuYPv8w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" } }, "node_modules/argparse": { @@ -21,6 +89,220 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT", + "optional": true + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "optional": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -34,6 +316,16 @@ "node": ">=4" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -46,6 +338,106 @@ "node": ">=0.10.0" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT", + "optional": true + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "optional": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gray-matter": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", @@ -61,6 +453,77 @@ "node": ">=6.0" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "optional": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "optional": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -70,6 +533,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-yaml": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", @@ -92,6 +564,15 @@ "node": ">=0.10.0" } }, + "node_modules/launder": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.7" + } + }, "node_modules/marked": { "version": "12.0.2", "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", @@ -104,6 +585,192 @@ "node": ">= 18" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/sanitize-html": { + "version": "2.17.5", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz", + "integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^10.1.0", + "is-plain-object": "^5.0.0", + "launder": "^1.7.1", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -117,6 +784,15 @@ "node": ">=4" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -131,6 +807,48 @@ "engines": { "node": ">=0.10.0" } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT", + "optional": true + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } } } } diff --git a/package.json b/package.json index ea4f143..d42bb22 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,16 @@ "type": "module", "scripts": { "build": "node scripts/build.mjs", - "serve": "node scripts/build.mjs && node -e \"import('node:http').then(h=>import('node:fs').then(f=>import('node:path').then(p=>{const t={'.html':'text/html;charset=utf-8','.css':'text/css','.js':'text/javascript'};h.createServer((q,s)=>{let u=decodeURIComponent(q.url.split('?')[0]);if(u==='/')u='/index.html';const fp=p.join('public',u);f.readFile(fp,(e,d)=>{if(e){s.writeCode?0:0;s.statusCode=404;return s.end('Not found')}s.setHeader('Content-Type',t[p.extname(fp)]||'application/octet-stream');s.end(d)})}).listen(8080,()=>console.log('OpenBlog dev server: http://localhost:8080'))})))\"" + "serve": "node scripts/build.mjs && node scripts/serve.mjs", + "generate": "node scripts/generate.mjs" }, "dependencies": { "gray-matter": "^4.0.3", - "marked": "^12.0.0" + "marked": "^12.0.0", + "sanitize-html": "^2.17.5" + }, + "optionalDependencies": { + "@anthropic-ai/sdk": "^0.40.0", + "openai": "^4.0.0" } } diff --git a/scripts/build.mjs b/scripts/build.mjs index c660e13..c385421 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -20,6 +20,26 @@ import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; import matter from "gray-matter"; import { marked } from "marked"; +import sanitizeHtml from "sanitize-html"; + +// Posts may be AI-generated, so treat their Markdown as untrusted: render to +// HTML, then strip anything that could execute (scripts, event handlers, +// javascript: URLs) while keeping the tags a blog post legitimately uses. +const SANITIZE_OPTS = { + allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img", "h1", "h2"]), + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + a: ["href", "name", "target", "rel"], + img: ["src", "alt", "title"], + code: ["class"], // language-* hints from fenced blocks + span: ["class"], + }, + allowedSchemes: ["http", "https", "mailto"], +}; + +function renderMarkdown(md) { + return sanitizeHtml(marked.parse(md), SANITIZE_OPTS); +} const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const POSTS_DIR = join(ROOT, "posts"); @@ -129,7 +149,7 @@ function renderPost(post) { const body = `

${escapeHtml(post.title)}

${formatDate(post.date)} ${tags}
-${marked.parse(post.content)} +${renderMarkdown(post.content)} ← 返回首页
`; return layout({ title: `${post.title} · ${SITE.title}`, body }); diff --git a/scripts/generate.mjs b/scripts/generate.mjs new file mode 100644 index 0000000..85818a5 --- /dev/null +++ b/scripts/generate.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +// OpenBlog article generator. +// +// Reads recent notes from memory/, asks the selected AI provider to distill +// them into one blog article, and writes it as a Markdown file into posts/ +// (with front matter, ready for scripts/build.mjs to render). +// +// Usage: +// node scripts/generate.mjs [--date YYYY-MM-DD] [--provider name] +// [--days N] [--max N] [--force] +// +// Provider is chosen automatically (see scripts/providers/index.mjs): +// ANTHROPIC_API_KEY -> anthropic, OPENAI_API_KEY -> openai, else dryrun. +// With no key set it runs the dryrun stub, so the pipeline works out of the box. + +import { readdir, readFile, writeFile, mkdir, stat } from "node:fs/promises"; +import { join, basename, extname, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import matter from "gray-matter"; +import { getProvider } from "./providers/index.mjs"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const MEMORY_DIR = join(ROOT, "memory"); +const POSTS_DIR = join(ROOT, "posts"); + +function parseArgs(argv) { + const args = { days: 7, max: 20, force: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--date") args.date = argv[++i]; + else if (a === "--provider") args.provider = argv[++i]; + else if (a === "--days") args.days = Number(argv[++i]); + else if (a === "--max") args.max = Number(argv[++i]); + else if (a === "--force") args.force = true; + else if (a === "--help" || a === "-h") args.help = true; + } + return args; +} + +function isoToday() { + // Scripts can't use new Date() reliably in all envs; require --date in CI. + // For local convenience, fall back to the system date here. + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( + d.getDate() + ).padStart(2, "0")}`; +} + +async function exists(p) { + try { await stat(p); return true; } catch { return false; } +} + +// Collect recent memory notes, newest first, bounded by --days and --max. +async function collectNotes({ days, max, date }) { + if (!(await exists(MEMORY_DIR))) return []; + const cutoff = new Date(date); + cutoff.setDate(cutoff.getDate() - days); + + const files = (await readdir(MEMORY_DIR)).filter( + (f) => extname(f) === ".md" && !f.startsWith(".") && f !== "README.md" + ); + + const notes = []; + for (const file of files) { + const full = join(MEMORY_DIR, file); + const raw = await readFile(full, "utf8"); + const { data, content } = matter(raw); + const fileStat = await stat(full); + const noteDate = data.date ? new Date(data.date) : fileStat.mtime; + if (noteDate < cutoff) continue; + notes.push({ name: file, date: noteDate, content }); + } + notes.sort((a, b) => b.date - a.date); + return notes.slice(0, max); +} + +function slugify(title) { + // Keep ASCII letters/numbers only; CJK and punctuation collapse to dashes. + const slug = String(title) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + // CJK-only titles collapse to empty -> caller falls back to a date-based name. + return slug || "post"; +} + +async function uniquePath(date, slug, force) { + let candidate = join(POSTS_DIR, `${date}-${slug}.md`); + if (force || !(await exists(candidate))) return candidate; + for (let n = 2; n < 100; n++) { + candidate = join(POSTS_DIR, `${date}-${slug}-${n}.md`); + if (!(await exists(candidate))) return candidate; + } + return candidate; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log( + "Usage: node scripts/generate.mjs [--date YYYY-MM-DD] [--provider name] [--days N] [--max N] [--force]" + ); + return; + } + const date = args.date || isoToday(); + + const notes = await collectNotes({ days: args.days, max: args.max, date }); + if (notes.length === 0) { + console.log( + `No memory notes within the last ${args.days} day(s) of ${date}. ` + + `Add notes to memory/ and re-run. Nothing generated.` + ); + return; + } + + const provider = await getProvider(args.provider); + console.log(`OpenBlog: generating from ${notes.length} note(s) via "${provider.name}" provider...`); + + const result = await provider.generate({ notes, date }); + + const slug = slugify(result.title, date); + await mkdir(POSTS_DIR, { recursive: true }); + const outPath = await uniquePath(date, slug, args.force); + + const fileContent = matter.stringify(result.body, { + title: result.title, + date, + tags: result.tags, + summary: result.summary, + generated_by: provider.name, + }); + + await writeFile(outPath, fileContent); + console.log(`OpenBlog: wrote ${basename(outPath)} (title: "${result.title}")`); +} + +main().catch((err) => { + console.error("generate failed:", err.message); + process.exit(1); +}); diff --git a/scripts/providers/anthropic.mjs b/scripts/providers/anthropic.mjs new file mode 100644 index 0000000..178a84b --- /dev/null +++ b/scripts/providers/anthropic.mjs @@ -0,0 +1,58 @@ +// Anthropic (Claude) provider. Uses the official @anthropic-ai/sdk, imported +// dynamically so it's only required when this provider is actually selected +// (the dryrun path has zero dependencies). +// +// Requires ANTHROPIC_API_KEY in the environment. Install the SDK with: +// npm install @anthropic-ai/sdk + +import { SYSTEM_PROMPT, buildUserPrompt, parseResult } from "./prompt.mjs"; + +// Latest Claude model, suited to writing tasks. Override with OPENBLOG_MODEL. +const DEFAULT_MODEL = "claude-opus-4-8"; + +/** + * @param {{notes: Array<{name: string, content: string}>, date: string}} input + * @returns {Promise<{title: string, tags: string[], summary: string, body: string}>} + */ +export async function generate({ notes, date }) { + if (!process.env.ANTHROPIC_API_KEY) { + throw new Error( + "anthropic provider selected but ANTHROPIC_API_KEY is not set. " + + "Set the key, or use --provider dryrun." + ); + } + + let Anthropic; + try { + ({ default: Anthropic } = await import("@anthropic-ai/sdk")); + } catch { + throw new Error( + "anthropic provider selected but @anthropic-ai/sdk is not installed. " + + "Run: npm install @anthropic-ai/sdk (it's an optionalDependency)." + ); + } + + const client = new Anthropic(); + const model = process.env.OPENBLOG_MODEL || DEFAULT_MODEL; + + // Stream because output may be long; collect the final message. + const stream = client.messages.stream({ + model, + max_tokens: 8000, + thinking: { type: "adaptive" }, + system: SYSTEM_PROMPT, + messages: [{ role: "user", content: buildUserPrompt({ notes, date }) }], + }); + const message = await stream.finalMessage(); + + if (message.stop_reason === "refusal") { + throw new Error("Claude declined to generate this article (stop_reason: refusal)."); + } + + const text = message.content + .filter((b) => b.type === "text") + .map((b) => b.text) + .join(""); + + return parseResult(text); +} diff --git a/scripts/providers/dryrun.mjs b/scripts/providers/dryrun.mjs new file mode 100644 index 0000000..fb3572a --- /dev/null +++ b/scripts/providers/dryrun.mjs @@ -0,0 +1,41 @@ +// Dry-run provider: no network, no API key. Organizes the recent memory notes +// into a readable placeholder article so the whole generate → build → publish +// pipeline can be exercised end to end before a real model is wired up. + +import { buildUserPrompt } from "./prompt.mjs"; + +/** + * @param {{notes: Array<{name: string, content: string}>, date: string}} input + * @returns {Promise<{title: string, tags: string[], summary: string, body: string}>} + */ +export async function generate({ notes, date }) { + // Pull a rough set of bullet points out of the notes to make the stub + // feel grounded in the actual memory content. + const bullets = notes + .flatMap((n) => n.content.split("\n")) + .map((line) => line.replace(/^[-*]\s+/, "").trim()) + .filter((line) => line && !line.startsWith("#") && !line.startsWith("---") && !line.includes(":")) + .slice(0, 6); + + const body = `> 本文由 OpenBlog 的 **dry-run** 占位生成器产出 —— 它没有调用任何 AI 模型,只是把最近的记忆整理成了文章骨架。配置好 \`ANTHROPIC_API_KEY\` 或 \`OPENAI_API_KEY\` 后,这里会换成真正的 AI 写作。 + +## 最近在想些什么 + +${bullets.length ? bullets.map((b) => `- ${b}`).join("\n") : "- (最近的记忆里还没有可提炼的要点)"} + +## 一点沉淀 + +把零散的念头记下来,本身就是一种对抗遗忘的方式。等到 AI 接管写作的那一天,这些记忆会自动长成一篇篇文章 —— 而今天,它们先以这样朴素的形式被保存下来。 + +_生成日期:${date} · 共参考了 ${notes.length} 条记忆_`; + + return { + title: "每日沉淀", + tags: ["memory", "dryrun"], + summary: "由近期记忆自动整理的占位文章(dry-run,未调用 AI 模型)。", + body, + }; +} + +// Exposed for debugging: lets you inspect the prompt the real providers receive. +export { buildUserPrompt }; diff --git a/scripts/providers/index.mjs b/scripts/providers/index.mjs new file mode 100644 index 0000000..31ef499 --- /dev/null +++ b/scripts/providers/index.mjs @@ -0,0 +1,33 @@ +// Provider selector. Resolves which AI backend generates the article: +// +// OPENBLOG_PROVIDER=anthropic|openai|dryrun explicit override +// else ANTHROPIC_API_KEY present -> anthropic +// else OPENAI_API_KEY present -> openai +// else -> dryrun (no key, no network) +// +// Every provider exposes the same async generate({ notes, date }) signature +// returning { title, tags, summary, body }. + +export function selectProviderName() { + const explicit = (process.env.OPENBLOG_PROVIDER || "").trim().toLowerCase(); + if (explicit) return explicit; + if (process.env.ANTHROPIC_API_KEY) return "anthropic"; + if (process.env.OPENAI_API_KEY) return "openai"; + return "dryrun"; +} + +export async function getProvider(name = selectProviderName()) { + switch (name) { + case "anthropic": + return { name, ...(await import("./anthropic.mjs")) }; + case "openai": + return { name, ...(await import("./openai.mjs")) }; + case "dryrun": + return { name, ...(await import("./dryrun.mjs")) }; + default: + throw new Error( + `Unknown provider "${name}". Use one of: anthropic, openai, dryrun ` + + `(via OPENBLOG_PROVIDER or --provider).` + ); + } +} diff --git a/scripts/providers/openai.mjs b/scripts/providers/openai.mjs new file mode 100644 index 0000000..081419f --- /dev/null +++ b/scripts/providers/openai.mjs @@ -0,0 +1,47 @@ +// OpenAI provider. Uses the official `openai` SDK, imported dynamically so it's +// only required when this provider is actually selected. +// +// Requires OPENAI_API_KEY in the environment. Install the SDK with: +// npm install openai + +import { SYSTEM_PROMPT, buildUserPrompt, parseResult } from "./prompt.mjs"; + +const DEFAULT_MODEL = "gpt-4o"; // override with OPENBLOG_MODEL + +/** + * @param {{notes: Array<{name: string, content: string}>, date: string}} input + * @returns {Promise<{title: string, tags: string[], summary: string, body: string}>} + */ +export async function generate({ notes, date }) { + if (!process.env.OPENAI_API_KEY) { + throw new Error( + "openai provider selected but OPENAI_API_KEY is not set. " + + "Set the key, or use --provider dryrun." + ); + } + + let OpenAI; + try { + ({ default: OpenAI } = await import("openai")); + } catch { + throw new Error( + "openai provider selected but the `openai` package is not installed. " + + "Run: npm install openai (it's an optionalDependency)." + ); + } + + const client = new OpenAI(); + const model = process.env.OPENBLOG_MODEL || DEFAULT_MODEL; + + const completion = await client.chat.completions.create({ + model, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: buildUserPrompt({ notes, date }) }, + ], + response_format: { type: "json_object" }, + }); + + const text = completion.choices[0]?.message?.content || ""; + return parseResult(text); +} diff --git a/scripts/providers/prompt.mjs b/scripts/providers/prompt.mjs new file mode 100644 index 0000000..3446ab5 --- /dev/null +++ b/scripts/providers/prompt.mjs @@ -0,0 +1,74 @@ +// Shared prompt scaffolding for AI providers. +// +// All providers turn the same input — recent memory notes + a target date — +// into the same structured output: { title, tags, summary, body }. + +export const SYSTEM_PROMPT = `你是 OpenBlog 的常驻写作者。OpenBlog 的理念是:每日所思即记忆,每日分享即 IP。 + +你的任务:把作者最近的零散笔记(记忆)沉淀成一篇结构完整、有主线、值得发布的中文博客文章。 + +要求: +- 不要逐条复述笔记,而是从这些零散想法里提炼出一条主线,围绕它展开。 +- 语气自然、真诚,像作者本人在分享思考,而不是营销文案。 +- 用 Markdown 写作正文;可以有小标题、列表、引用,但不要堆砌。 +- 不要编造笔记里没有的事实或经历。 +- 篇幅适中(约 400–900 字)。`; + +// The instruction appended to the user message; asks for strict JSON so the +// orchestrator can parse a stable shape regardless of provider. +export const OUTPUT_INSTRUCTION = `请只输出一个 JSON 对象,不要包含任何额外文字或代码块标记,字段如下: +{ + "title": "文章标题(简洁、有吸引力)", + "tags": ["3-5 个", "中文或英文标签"], + "summary": "一句话摘要,显示在首页列表", + "body": "文章正文,Markdown 格式,不要重复标题" +}`; + +/** + * Build the user-facing prompt from the collected notes. + * @param {{notes: Array<{name: string, content: string}>, date: string}} input + */ +export function buildUserPrompt({ notes, date }) { + const noteBlocks = notes + .map((n, i) => `### 笔记 ${i + 1}(${n.name})\n${n.content.trim()}`) + .join("\n\n"); + + return `今天是 ${date}。以下是作者最近的零散笔记: + +${noteBlocks} + +请基于这些笔记写一篇博客文章。 + +${OUTPUT_INSTRUCTION}`; +} + +/** + * Parse a model's text response into the structured result, tolerating a + * stray code fence around the JSON. Throws if no valid object is found. + */ +export function parseResult(text) { + let s = text.trim(); + // Strip a ```json ... ``` fence if the model added one despite instructions. + const fence = s.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/); + if (fence) s = fence[1].trim(); + + let obj; + try { + obj = JSON.parse(s); + } catch { + // Last resort: grab the first {...} span. + const span = s.match(/\{[\s\S]*\}/); + if (!span) throw new Error("provider 返回内容不是有效 JSON:\n" + text.slice(0, 500)); + obj = JSON.parse(span[0]); + } + + if (!obj.title || !obj.body) { + throw new Error("provider 返回的 JSON 缺少 title 或 body 字段"); + } + return { + title: String(obj.title), + tags: Array.isArray(obj.tags) ? obj.tags.map(String) : [], + summary: obj.summary ? String(obj.summary) : "", + body: String(obj.body), + }; +} diff --git a/scripts/serve.mjs b/scripts/serve.mjs new file mode 100644 index 0000000..aa5842b --- /dev/null +++ b/scripts/serve.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// Minimal static file server for previewing the built site in public/. +// Run `npm run build` first (or use `npm run serve`, which builds then serves). + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { join, extname, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "public"); +const PORT = Number(process.env.PORT) || 8080; + +const MIME = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".ico": "image/x-icon", +}; + +const server = createServer(async (req, res) => { + let urlPath = decodeURIComponent((req.url || "/").split("?")[0]); + if (urlPath.endsWith("/")) urlPath += "index.html"; + + const filePath = join(PUBLIC_DIR, urlPath); + // Prevent path traversal outside public/. + if (!filePath.startsWith(PUBLIC_DIR)) { + res.statusCode = 403; + return res.end("Forbidden"); + } + + try { + const data = await readFile(filePath); + res.setHeader("Content-Type", MIME[extname(filePath)] || "application/octet-stream"); + res.end(data); + } catch { + res.statusCode = 404; + res.end("Not found"); + } +}); + +server.listen(PORT, () => { + console.log(`OpenBlog dev server: http://localhost:${PORT}`); +});