@clawhub-harven-droid-8b66d7cd03
Save WeChat Official Account articles into IMA notes with preserved article structure. Use when the user sends an mp.weixin.qq.com link and wants to save, ar...
---
name: wechat-to-ima
description: Save WeChat Official Account articles into IMA notes with preserved article structure. Use when the user sends an mp.weixin.qq.com link and wants to save, archive, import, collect, or store the article in IMA/笔记/知识库. Handles parsing article metadata, preserving inline body images in order, falling back to the cover image when the body has no images, importing Markdown into IMA, and reading the note back to verify the save succeeded.
---
# WeChat to IMA
Save a WeChat article into IMA as a readable Markdown note.
## Workflow
1. Run `scripts/save_wechat_to_ima.py <url>`.
2. If the body contains inline images, keep them in original order.
3. If the body contains no inline images, insert the cover image near the top.
4. Import the generated Markdown into IMA.
5. Read the saved note back once to verify the note is not empty.
## Requirements
- `IMA_OPENAPI_CLIENTID` and `IMA_OPENAPI_APIKEY` must be available in the environment.
- Run `npm install` once inside this skill directory so the bundled extractor dependencies are available.
## Output
The script prints JSON with:
- `title`
- `account`
- `author`
- `publish_time`
- `body_img_count`
- `cover_used`
- `markdown_path`
- `note_id`
- `readback_ok`
## Notes
- Prefer this skill over ad-hoc manual parsing when the user wants the article stored in IMA.
- This skill is self-contained for article parsing and does not depend on a separate `wechat-article-extractor` installation.
- The IMA readback check uses plain text, so it confirms content landed successfully but does not visually render images in the terminal output.
- If parsing succeeds but the article body has no inline images, that is expected for some articles; use the cover-image fallback instead of treating it as a failure.
- If the original article contains code or code-block-style content, preserve it as fenced Markdown code blocks when importing into IMA; do not flatten code into ordinary prose.
FILE:package-lock.json
{
"name": "wechat-to-ima",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"cheerio": "^1.2.0",
"dayjs": "^1.11.19",
"lodash.unescape": "^4.0.1",
"qs": "^6.15.0",
"request-promise": "^4.2.6"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmmirror.com/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/assert-plus": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmmirror.com/aws-sign2/-/aws-sign2-0.7.0.tgz",
"integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==",
"license": "Apache-2.0",
"engines": {
"node": "*"
}
},
"node_modules/aws4": {
"version": "1.13.2",
"resolved": "https://registry.npmmirror.com/aws4/-/aws4-1.13.2.tgz",
"integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==",
"license": "MIT"
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.7.2.tgz",
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"license": "MIT"
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmmirror.com/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"license": "Apache-2.0"
},
"node_modules/cheerio": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/cheerio/-/cheerio-1.2.0.tgz",
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
"license": "MIT",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"encoding-sniffer": "^0.2.1",
"htmlparser2": "^10.1.0",
"parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^7.19.0",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"license": "MIT"
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmmirror.com/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmmirror.com/dashdash/-/dashdash-1.14.1.tgz",
"integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==",
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/dayjs": {
"version": "1.11.20",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz",
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/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/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ecc-jsbn": {
"version": "0.1.2",
"resolved": "https://registry.npmmirror.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
"integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==",
"license": "MIT",
"dependencies": {
"jsbn": "~0.1.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmmirror.com/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/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/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/extsprintf/-/extsprintf-1.3.0.tgz",
"integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==",
"engines": [
"node >=0.6.0"
],
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"license": "MIT"
},
"node_modules/forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmmirror.com/forever-agent/-/forever-agent-0.6.1.tgz",
"integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==",
"license": "Apache-2.0",
"engines": {
"node": "*"
}
},
"node_modules/form-data": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/form-data/-/form-data-2.3.3.tgz",
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 0.12"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"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.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/getpass": {
"version": "0.1.7",
"resolved": "https://registry.npmmirror.com/getpass/-/getpass-0.1.7.tgz",
"integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==",
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/har-schema/-/har-schema-2.0.0.tgz",
"integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==",
"license": "ISC",
"engines": {
"node": ">=4"
}
},
"node_modules/har-validator": {
"version": "5.1.5",
"resolved": "https://registry.npmmirror.com/har-validator/-/har-validator-5.1.5.tgz",
"integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==",
"deprecated": "this library is no longer supported",
"license": "MIT",
"dependencies": {
"ajv": "^6.12.3",
"har-schema": "^2.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmmirror.com/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/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/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/http-signature": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/http-signature/-/http-signature-1.2.0.tgz",
"integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==",
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0",
"jsprim": "^1.2.2",
"sshpk": "^1.7.0"
},
"engines": {
"node": ">=0.8",
"npm": ">=1.3.7"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
"license": "MIT"
},
"node_modules/isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmmirror.com/isstream/-/isstream-0.1.2.tgz",
"integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==",
"license": "MIT"
},
"node_modules/jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmmirror.com/jsbn/-/jsbn-0.1.1.tgz",
"integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==",
"license": "MIT"
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT"
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC"
},
"node_modules/jsprim": {
"version": "1.4.2",
"resolved": "https://registry.npmmirror.com/jsprim/-/jsprim-1.4.2.tgz",
"integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==",
"license": "MIT",
"dependencies": {
"assert-plus": "1.0.0",
"extsprintf": "1.3.0",
"json-schema": "0.4.0",
"verror": "1.10.0"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.unescape": {
"version": "4.0.1",
"resolved": "https://registry.npmmirror.com/lodash.unescape/-/lodash.unescape-4.0.1.tgz",
"integrity": "sha512-DhhGRshNS1aX6s5YdBE3njCCouPgnG29ebyHvImlZzXZf2SHgt+J08DHgytTPnpywNbO1Y8mNUFyQuIDBq2JZg==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/oauth-sign": {
"version": "0.9.0",
"resolved": "https://registry.npmmirror.com/oauth-sign/-/oauth-sign-0.9.0.tgz",
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
"license": "Apache-2.0",
"engines": {
"node": "*"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmmirror.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmmirror.com/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"license": "MIT",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT"
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmmirror.com/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/request": {
"version": "2.88.2",
"resolved": "https://registry.npmmirror.com/request/-/request-2.88.2.tgz",
"integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==",
"deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142",
"license": "Apache-2.0",
"dependencies": {
"aws-sign2": "~0.7.0",
"aws4": "^1.8.0",
"caseless": "~0.12.0",
"combined-stream": "~1.0.6",
"extend": "~3.0.2",
"forever-agent": "~0.6.1",
"form-data": "~2.3.2",
"har-validator": "~5.1.3",
"http-signature": "~1.2.0",
"is-typedarray": "~1.0.0",
"isstream": "~0.1.2",
"json-stringify-safe": "~5.0.1",
"mime-types": "~2.1.19",
"oauth-sign": "~0.9.0",
"performance-now": "^2.1.0",
"qs": "~6.5.2",
"safe-buffer": "^5.1.2",
"tough-cookie": "~2.5.0",
"tunnel-agent": "^0.6.0",
"uuid": "^3.3.2"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/request-promise": {
"version": "4.2.6",
"resolved": "https://registry.npmmirror.com/request-promise/-/request-promise-4.2.6.tgz",
"integrity": "sha512-HCHI3DJJUakkOr8fNoCc73E5nU5bqITjOYFMDrKHYOXWXrgD/SBaC7LjwuPymUprRyuF06UK7hd/lMHkmUXglQ==",
"deprecated": "request-promise has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142",
"license": "ISC",
"dependencies": {
"bluebird": "^3.5.0",
"request-promise-core": "1.1.4",
"stealthy-require": "^1.1.1",
"tough-cookie": "^2.3.3"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"request": "^2.34"
}
},
"node_modules/request-promise-core": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/request-promise-core/-/request-promise-core-1.1.4.tgz",
"integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==",
"license": "ISC",
"dependencies": {
"lodash": "^4.17.19"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"request": "^2.34"
}
},
"node_modules/request/node_modules/qs": {
"version": "6.5.5",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.5.5.tgz",
"integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sshpk": {
"version": "1.18.0",
"resolved": "https://registry.npmmirror.com/sshpk/-/sshpk-1.18.0.tgz",
"integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==",
"license": "MIT",
"dependencies": {
"asn1": "~0.2.3",
"assert-plus": "^1.0.0",
"bcrypt-pbkdf": "^1.0.0",
"dashdash": "^1.12.0",
"ecc-jsbn": "~0.1.1",
"getpass": "^0.1.1",
"jsbn": "~0.1.0",
"safer-buffer": "^2.0.2",
"tweetnacl": "~0.14.0"
},
"bin": {
"sshpk-conv": "bin/sshpk-conv",
"sshpk-sign": "bin/sshpk-sign",
"sshpk-verify": "bin/sshpk-verify"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/stealthy-require": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/stealthy-require/-/stealthy-require-1.1.1.tgz",
"integrity": "sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==",
"license": "ISC",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tough-cookie": {
"version": "2.5.0",
"resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-2.5.0.tgz",
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.28",
"punycode": "^2.1.1"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmmirror.com/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"license": "MIT",
"bin": {
"uuid": "bin/uuid"
}
},
"node_modules/verror": {
"version": "1.10.0",
"resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.0.tgz",
"integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==",
"engines": [
"node >=0.6.0"
],
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
"extsprintf": "^1.2.0"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
}
}
}
FILE:package.json
{
"dependencies": {
"cheerio": "^1.2.0",
"dayjs": "^1.11.19",
"lodash.unescape": "^4.0.1",
"qs": "^6.15.0",
"request-promise": "^4.2.6"
}
}
FILE:scripts/errors.js
module.exports = {
1000: '文章获取失败',
1001: '无法获取文章信息',
1002: '请求失败',
1003: '响应为空',
1004: '访问过于频繁',
1005: '脚本解析失败',
1006: '公众号已迁移',
2001: '请提供文章内容或链接',
2002: '链接已过期',
2003: '内容涉嫌侵权,无法查看',
2004: '无法获取迁移后的链接',
2005: '内容已被发布者删除',
2006: '内容因违规无法查看',
2007: '内容发送失败无法查看',
2008: '系统出错',
2009: '不支持的链接',
2010: '内容获取失败',
2011: '由用户投诉并经平台审核,涉嫌过度营销、骚扰用户',
2012: '此帐号已被屏蔽,内容无法查看',
2013: '此帐号已自主注销,内容无法查看',
2014: '此内容被投诉且经审核确认存在不实信息',
2015: '此帐号处于帐号迁移流程中',
2016: '由用户投诉并经平台审核,涉嫌冒名侵权'
};
FILE:scripts/extract.js
const qs = require('qs');
const dayjs = require('dayjs');
const request = require('request-promise');
const cheerio = require('cheerio');
const unescape = require('lodash.unescape');
const errors = require('./errors');
const defaultConfig = {
shouldReturnRawMeta: false,
shouldReturnContent: true,
shouldFollowTransferLink: true,
shouldExtractMpLinks: false,
shouldExtractTags: false,
shouldExtractRepostMeta: false
};
function getError(code) {
return { done: false, code, msg: errors[code] };
}
function normalizeUrl(url = '') {
const parts = url.replace(/&/g, '&').split('?');
const querys = qs.stringify(qs.parse(parts[1]));
return querys ? `parts[0]?querys` : parts[0];
}
function getParameterByName(name, url) {
name = name.replace(/[\[\]]/g, '\\$&');
const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)');
const results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}
async function extract(input, options = {}) {
const config = Object.assign({}, defaultConfig, options);
const {
shouldReturnRawMeta,
shouldReturnContent,
shouldFollowTransferLink,
shouldExtractMpLinks,
shouldExtractTags,
shouldExtractRepostMeta
} = config;
if (!input) return getError(2001);
let paramType = 'HTML';
let url = options.url ? normalizeUrl(options.url) : null;
let rawUrl = null;
let html = input;
let type = 'post';
let hasCopyright = false;
if (/^http/.test(input)) {
const normalized = normalizeUrl(input);
if (!/https?:\/\/mp\.weixin\.qq\.com/.test(normalized) &&
!/https?:\/\/weixin\.sogou\.com/.test(normalized)) {
return getError(2009);
}
paramType = 'URL';
rawUrl = normalized;
if (!url) url = normalized;
const host = /weixin\.sogou\.com/.test(normalized) ? 'weixin.sogou.com' : 'mp.weixin.qq.com';
try {
html = await request({
uri: normalized,
method: 'GET',
headers: {
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Host': host
}
});
} catch (e) {
return getError(1002);
}
} else {
html = input.replace(/\\n/g, '');
}
if (!html) return getError(1003);
if (html.includes('访问过于频繁') && !html.includes('js_content')) return paramType === 'URL' ? getError(1004) : getError(2010);
if (html.includes('链接已过期') && !html.includes('js_content')) return getError(2002);
if (html.includes('被投诉且经审核涉嫌侵权,无法查看')) return getError(2003);
if (html.includes('该公众号已迁移')) {
const match = html.match(/var\stransferTargetLink\s=\s'(.*?)';/);
if (match && match[1]) {
if (shouldFollowTransferLink) return await extract(match[1]);
return { ...getError(1006), url: match[1] };
}
return getError(2004);
}
if (html.includes('该内容已被发布者删除')) return getError(2005);
if (html.includes('此内容因违规无法查看')) return getError(2006);
if (html.includes('此内容发送失败无法查看')) return getError(2007);
if (html.includes('由用户投诉并经平台审核,涉嫌过度营销')) return getError(2011);
if (html.includes('此帐号已被屏蔽') && !html.includes('id="js_content"')) return getError(2012);
if (html.includes('此帐号已自主注销') && !html.includes('id="js_content"')) return getError(2013);
if (!html.includes('id="js_content"') && html.includes('此帐号处于帐号迁移流程中')) return getError(2015);
if (html.includes('page_rumor') && !html.includes('id="js_content"')) return getError(2014);
if (html.includes('投诉类型') && html.includes('冒名侵权')) return getError(2016);
if (!html.includes('id="js_content"') && !html.includes('id=\\"js_content\\"')) {
if (html.includes('cover_url')) type = 'image';
else return getError(1000);
}
html = html.replace('>微信号', ' id="append-account-alias">微信号')
.replace('>功能介绍', ' id="append-account-desc">功能介绍')
.replace(/\n\s+<script/g, '\n\n<script');
const $ = cheerio.load(html, { decodeEntities: false });
if ($('#copyright_logo')?.text().includes('原创')) hasCopyright = true;
if (/video/.test($('body').attr('class'))) type = 'video';
if ($('#js_content > #img_list').length) type = 'image';
if ($('#js_share_content').length) type = 'repost';
if ($('.page_share_audio').length || $('#voice_parent').length) type = 'voice';
if (/share_media_text/.test(html)) type = 'text';
if ($('.weui-msg .weui-msg__title').text().trim() === '链接已过期') return getError(2002);
if ($('.global_error_msg.warn').text().trim().includes('系统出错')) return getError(2008);
const basic = {
accountName: $('.profile_nickname').text() || null,
accountBiz: null,
accountBizNumber: null,
accountId: null,
accountAvatar: null
};
const accountAliasPrev = $('#append-account-alias');
let accountAlias = accountAliasPrev.siblings('span').text() || null;
const accountDescPrev = $('#append-account-desc');
let accountDesc = accountDescPrev.siblings('span').text() || null;
if (!accountDesc) {
const $accountDesc = $('.profile_meta_value');
if ($accountDesc[1]) {
try {
const text = $accountDesc[1].children[0].data;
if (text?.length > 10) accountDesc = text;
} catch (e) {}
}
}
const post = {
msg_has_copyright: hasCopyright,
msg_content: shouldReturnContent ? $('#js_content').html() : null
};
try {
const author = $("meta[name='author']").attr('content');
if (author) post.msg_author = author;
} catch (e) {
const $author = $('#js_author_name');
if ($author.length) {
const info = $author.text().trim();
if (info) post.msg_author = info;
}
}
const scripts = html.match(/<script[\s\S]*?>([\s\S]*?)<\/script>/gi) || [];
const extra = { biz: null, sn: null, mid: null, idx: null, msg_title: null, user_name: null, nick_name: null, hd_head_img: null };
let picturePageInfoList = null;
for (const script of scripts) {
if (script.includes('picture_page_info_list') && script.includes('https://mmbiz.qpic.cn')) {
try {
const lines = script.split('\n');
const code = lines.slice(1, lines.length - 2).join('\n').trim().replace(/^\(function\(\) {/, '').replace(/}\)\(\);$/, '');
const fn = new Function(`var x = {}; code.replace(/window\./g, 'x.').replace('//g', '/\\n/g')\nreturn x;`);
const result = fn();
if (result.picture_page_info_list) picturePageInfoList = result.picture_page_info_list;
} catch (e) {}
}
if (type === 'voice' && script.includes('voiceid')) {
const lines = script.split(/\n|\r/).filter(one => one.includes('voiceid')).sort((a, b) => a.length > b.length ? -1 : 1);
if (lines.length) {
const val = lines[0].replace(/['"|:,voiceid\s]/g, '');
if (val) post.msg_source_url = `https://res.wx.qq.com/voice/getvoice?mediaid=val.trim()`;
}
}
for (const field of Object.keys(extra)) {
const reg = new RegExp(`var\\s+field\\s*=`);
if (reg.test(script) && !extra[field]) {
try {
const line = script.split('\n').filter(one => reg.test(one))[0];
const fn = new Function(`line\nreturn field;`);
extra[field] = fn();
} catch (e) {}
}
if (!extra[field]) {
const reg2 = new RegExp(`window\\.field\\s*=`);
if (reg2.test(script)) {
try {
const line = script.split('\n').filter(one => reg2.test(one))[0];
const fn = new Function(`window = {}; line\nreturn window.field;`);
extra[field] = fn();
} catch (e) {}
}
}
}
if ((type === 'image' || type === 'voice') && script.includes('d.title =')) {
try {
const lines = script.split('\n').filter(line => !!line.trim());
const codeLines = lines.filter((line, index) => /d\./.test(line) || (lines[index - 1] && lines[index - 1].includes('d.') && !line.includes('}')));
let code = `var d = {}; function getXmlValue(path) { return false; }\n` + codeLines.join('\n').replace('var d = _g.cgiData;', 'var d = {}') + '\nreturn d;';
code = `var _g = {}; code`;
const fn = new Function(code);
const data = fn();
basic.accountName = data.nick_name;
basic.accountAvatar = data.hd_head_img;
basic.accountId = data.user_name;
if (!basic.accountBiz && data.biz) {
basic.accountBiz = data.biz;
basic.accountBizNumber = Buffer.from(data.biz, 'base64').toString() * 1;
}
post.msg_title = data.title;
post.msg_desc = null;
post.msg_cover = null;
post.msg_link = data.msg_link || null;
post.msg_sn = data.sn || null;
post.msg_idx = data.idx ? data.idx * 1 : null;
post.msg_mid = data.mid ? data.mid * 1 : null;
if (type === 'video') {
const vidMatch = html.match(/vid\s*:\s*'(.*?)'/);
if (vidMatch) data.vid = vidMatch[1];
post.msg_cover = $("meta[property='og:image']").attr('content');
}
if (type === 'video' || type === 'voice') post.msg_content = $("meta[name='description']").attr('content');
if (data.create_time) {
post.msg_publish_time = new Date(data.create_time * 1000);
post.msg_publish_time_str = dayjs(post.msg_publish_time).format('YYYY/MM/DD HH:mm:ss');
}
if (shouldReturnRawMeta) post.raw_data = data;
} catch (e) {
return getError(1005);
}
}
if ((type === 'post' || type === 'repost') && script.includes('var msg_link =')) {
try {
const lines = script.split('\n');
let code = lines.slice(1, lines.length - 1).filter(line => !line.includes('var title')).map(line => {
if (/var\s+msg_desc/.test(line)) line = line.replace(/`/g, "'").replace(/"/g, '`');
return line;
}).join('\n');
code = `var window = { location: { protocol: 'https' } };
var document = { addEventListener: function() {}, getElementById: function() { return { classList: { remove: function() {}, add: function() {} } }; } };
var location = { protocol: "https" };\ncode`;
const vars = code.match(/var\s(.*?)\s=/g)?.map(key => key.split(' ')[1]).filter(k => k !== 'window') || [];
let rs = ';\nvar rs = {';
vars.forEach(key => { rs += `"key": typeof key !== 'undefined' ? key : null,`; });
rs += '}\nreturn rs;';
const stringProto = `String.prototype.html = function(encode) {
var replace = ["'", "'", """, '"', " ", " ", ">", ">", "<", "<", "¥", "¥", "&", "&"];
var replaceReverse = ["&", "&", "¥", "¥", "<", "<", ">", ">", " ", " ", '"', """, "'", "'"];
var target = encode ? replaceReverse : replace;
for (var i = 0, str = this; i < target.length; i += 2) str = str.replace(new RegExp(target[i], 'g'), target[i + 1]);
return str;
};`;
const fn = new Function(stringProto + code + rs);
const data = fn();
if (!basic.accountBiz) {
const reg = new RegExp(`var\\s+biz\\s*=`);
const matched = html.split('\n').find(line => reg.test(line) && line.length > 10);
if (matched) {
try {
const bizFn = new Function(`matched; return biz;`);
const rs = bizFn();
if (rs) {
basic.accountBiz = rs;
basic.accountBizNumber = Buffer.from(rs, 'base64').toString() * 1;
}
} catch (e) {}
}
}
['msg_title', 'msg_desc', 'msg_link', 'msg_source_url'].forEach(key => { post[key] = data[key] || null; });
post.msg_cover = data.msg_cdn_url;
post.msg_article_type = data._ori_article_type || null;
post.msg_publish_time = new Date(data.ct * 1000);
post.msg_publish_time_str = dayjs(post.msg_publish_time).format('YYYY/MM/DD HH:mm:ss');
if (shouldReturnRawMeta) post.raw_data = data;
basic.accountId = data.user_name;
basic.accountAvatar = data.ori_head_img_url;
if (!basic.accountName && data.nickname) basic.accountName = data.nickname;
} catch (e) {
return getError(1005);
}
}
}
if (extra.biz) {
basic.accountBiz = extra.biz;
basic.accountBizNumber = Buffer.from(extra.biz, 'base64').toString() * 1;
}
post.msg_sn = extra.sn || post.msg_sn || null;
post.msg_idx = extra.idx ? extra.idx * 1 : post.msg_idx || null;
post.msg_mid = extra.mid ? extra.mid * 1 : post.msg_mid || null;
if (!post.msg_publish_time) {
const date = $('#post-date').text() || $('#publish_time').text();
if (date) post.msg_publish_time = new Date(date);
}
if (!post.msg_publish_time && html.includes('.ct')) {
const line = html.split('\n').find(one => one.includes('.ct'));
const matched = /(\d+)/g.exec(line || '');
if (matched && matched[1]?.length >= 10) post.msg_publish_time = new Date(matched[1] * 1000);
}
if (!post.msg_title) {
const title = $('.rich_media_title').text();
if (title) post.msg_title = title.trim();
}
if (!basic.accountId && extra.user_name) basic.accountId = extra.user_name;
if (!basic.accountName && extra.nick_name) basic.accountName = extra.nick_name;
if (!basic.accountAvatar && extra.hd_head_img) basic.accountAvatar = extra.hd_head_img;
if (!basic.accountName && $('.wx_follow_nickname')) {
const name = $('.wx_follow_nickname').text();
if (name) basic.accountName = name.trim();
}
const data = {
account_name: basic.accountName,
account_alias: accountAlias,
account_avatar: basic.accountAvatar?.length > 10 ? basic.accountAvatar : null,
account_description: accountDesc,
account_id: basic.accountId,
account_biz: basic.accountBiz,
account_biz_number: basic.accountBizNumber,
account_qr_code: `https://open.weixin.qq.com/qr/code?username=basic.accountId || accountAlias`,
...post,
msg_type: type
};
for (const key in data) if (data[key] === '') data[key] = null;
if (!data.msg_title && type === 'post') {
data.msg_type = 'text';
const title = $("meta[property='og:title']").attr('content');
const desc = $("meta[property='og:description']").attr('content');
if (title) {
data.msg_title = title;
const rawContent = $('#js_panel_like_title').html();
data.msg_content = rawContent ? rawContent.trim().replace(/\n/g, '<br/>') : title;
}
if (!title && desc) data.msg_title = desc;
}
if (!data.msg_publish_time) {
const matched = html.match(/d\.ct\s*=\s*"(\d+)"/);
if (matched && matched[1]) {
data.msg_publish_time = new Date(matched[1] * 1000);
data.msg_publish_time_str = dayjs(data.msg_publish_time).format('YYYY/MM/DD HH:mm:ss');
}
}
if (!data.msg_mid || !data.msg_link) {
let linkUrl = options?.url || rawUrl || $("meta[property='og:url']").attr('content');
if (linkUrl && /^http/.test(linkUrl) && /mid/.test(linkUrl) && /__biz/.test(linkUrl)) {
linkUrl = linkUrl.replace(/&/g, '&');
if (!data.msg_link) data.msg_link = linkUrl;
if (!data.msg_mid) data.msg_mid = getParameterByName('mid', linkUrl);
if (!data.msg_idx) data.msg_idx = getParameterByName('idx', linkUrl);
if (!data.msg_sn) data.msg_sn = getParameterByName('sn', linkUrl);
}
}
if (data.msg_title) data.msg_title = unescape(data.msg_title);
if (data.msg_type === 'video') {
if (!data.msg_content) data.msg_content = data.msg_title;
else data.msg_content = data.msg_content.replace(/\\x26/g, '&').replace(/\\x0a/g, '<br/>');
}
if (!data.msg_title) {
const title = $("meta[property='og:title']").attr('content');
if (title) data.msg_title = title;
}
if (!data.msg_desc) data.msg_desc = $("meta[property='og:description']").attr('content') || $("meta[name='description']").attr('content');
if (!data.msg_desc && data.msg_content) {
const text = data.msg_content.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
if (text.length > 0) data.msg_desc = text.substring(0, 140) + (text.length > 140 ? '...' : '');
}
if (data.msg_content?.includes('<script') && data.msg_content.includes('script>') && data.msg_content.includes('nonce=')) {
const desc = $("meta[property='og:description']").attr('content');
if (desc) data.msg_content = desc;
}
if (!data.msg_title || !data.msg_publish_time) return getError(1001);
if (type === 'text' && !data.msg_content && data.msg_title) data.msg_content = data.msg_title;
if (picturePageInfoList) {
data.msg_type = 'image';
data.msg_content = `data.msg_title<br>`;
for (const one of picturePageInfoList) data.msg_content += `<img src="one.cdn_url" style="max-width:100%"/><br><br>`;
}
if (shouldExtractMpLinks) {
const mpLinks = [];
$('a').each((i, ele) => {
const href = $(ele).attr('href');
if (href?.includes('mp.weixin.qq.com')) mpLinks.push({ title: $(ele).text(), href });
});
data.mp_links_count = mpLinks.length;
data.mp_links = mpLinks;
}
if (shouldExtractTags) {
const tags = [];
$('.article-tag__item-wrp').each((i, ele) => {
const $this = $(ele);
try {
const tagUrl = $this.attr('data-url');
const name = $this.find('.article-tag__item').text();
let count = $this.find('.article-tag__item-num').text();
if (name) {
if (!count && tags.length === 0) {
const $count = $('.article-tag-card__right');
if ($count.length) count = $count.text().replace('个', '');
}
tags.push({
id: getParameterByName('album_id', tagUrl) || getParameterByName('tag_id', tagUrl) || null,
url: tagUrl,
name: name.replace(/^#/, ''),
count: count?.replace(/\D/g, '') * 1 || 0
});
}
} catch (e) {}
});
data.tags = tags;
}
if (shouldExtractRepostMeta && html.includes('copyright_info') && html.includes('original_primary_nickname')) {
const name = $('.original_primary_nickname').text();
if (name) data.repost_meta = { account_name: name };
}
if (data.msg_link?.includes('&')) data.msg_link = data.msg_link.replace(/&/g, '&');
return { code: 0, done: true, data };
}
module.exports = { extract };
FILE:scripts/save_wechat_to_ima.py
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import tempfile
import urllib.request
from pathlib import Path
from bs4 import BeautifulSoup, NavigableString, Tag
EXTRACTOR = Path(__file__).resolve().with_name('extract.js')
SKILL_DIR = EXTRACTOR.parent.parent
IMA_BASE = 'https://ima.qq.com/openapi/note/v1'
def load_local_env():
env_path = SKILL_DIR / '.env'
if not env_path.exists():
return
for raw in env_path.read_text(encoding='utf-8').splitlines():
line = raw.strip()
if not line or line.startswith('#'):
continue
if line.startswith('export '):
line = line[len('export '):].strip()
if '=' not in line:
continue
key, value = line.split('=', 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
load_local_env()
def fail(msg, code=1):
print(json.dumps({'ok': False, 'error': msg}, ensure_ascii=False))
raise SystemExit(code)
def check_env():
missing = [k for k in ['IMA_OPENAPI_CLIENTID', 'IMA_OPENAPI_APIKEY'] if not os.environ.get(k)]
if missing:
fail(f"missing env: {', '.join(missing)}", 2)
if not EXTRACTOR.exists():
fail(f'extractor not found in skill: {EXTRACTOR}', 3)
def run_extract(url: str):
js = f"""
const fs = require('fs');
const {{ extract }} = require('{EXTRACTOR.as_posix()}');
(async () => {{
const result = await extract({json.dumps(url)}, {{
shouldReturnContent: true,
shouldReturnRawMeta: false,
shouldFollowTransferLink: true,
shouldExtractMpLinks: true,
shouldExtractTags: true,
shouldExtractRepostMeta: true,
}});
process.stdout.write(JSON.stringify(result));
}})().catch(err => {{
console.error(err);
process.exit(1);
}});
"""
res = subprocess.run(['node', '-e', js], capture_output=True, text=True)
if res.returncode != 0:
fail(res.stderr.strip() or 'extract failed', 4)
try:
obj = json.loads(res.stdout)
except Exception as e:
fail(f'invalid extractor output: {e}', 5)
if not obj.get('done'):
fail(obj.get('msg') or f"extract failed code={obj.get('code')}", 6)
return obj['data']
def text_of(node):
return ' '.join(node.stripped_strings).strip()
def code_text_of(node):
# Preserve code/newline structure instead of collapsing whitespace.
return node.get_text('\n', strip=False).strip('\n')
def is_code_block(node):
if not isinstance(node, Tag):
return False
name = node.name.lower()
classes = ' '.join(node.get('class') or []).lower()
style = (node.get('style') or '').lower()
return (
name in ['pre', 'code']
or 'code' in classes
or 'code-snippet' in classes
or 'monospace' in style
or 'font-family: monospace' in style
)
def append_code_block(lines, node):
code = code_text_of(node)
if code:
lines += ['```', code, '```', '']
return lines
def build_markdown(data: dict):
html = data.get('msg_content') or ''
soup = BeautifulSoup(html, 'html.parser')
lines = [
f"# {data.get('msg_title', '未命名文章')}",
'',
f"> **作者**: {data.get('msg_author') or '未知'} ",
f"> **公众号**: {data.get('account_name') or '未知'} ",
f"> **发布时间**: {data.get('msg_publish_time_str') or '未知'} ",
f"> **原文链接**: {data.get('msg_link') or ''}",
'',
'---',
''
]
body_img_count = 0
seen = set()
for node in soup.children:
if isinstance(node, NavigableString):
t = str(node).strip()
if t:
lines += [t, '']
continue
if not isinstance(node, Tag):
continue
name = node.name.lower()
if is_code_block(node):
append_code_block(lines, node)
continue
if name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']:
title = text_of(node)
if title:
lines += ['#' * int(name[1]) + ' ' + title, '']
continue
if name in ['p', 'section', 'div']:
# Preserve nested code blocks before collapsing normal prose text.
code_nodes = [c for c in node.find_all(['pre', 'code'], recursive=True) if is_code_block(c)]
for c in code_nodes:
append_code_block(lines, c)
c.decompose()
txt = text_of(node)
if txt:
lines += [txt, '']
for img in node.find_all('img', recursive=True):
src = img.get('data-src') or img.get('src')
if src and src not in seen:
seen.add(src)
body_img_count += 1
lines += [f'', '']
for a in node.find_all('a', recursive=True):
href = a.get('href')
title = text_of(a)
if href and title and title != txt:
lines += [f'- [{title}]({href})', '']
continue
if name == 'img':
src = node.get('data-src') or node.get('src')
if src and src not in seen:
seen.add(src)
body_img_count += 1
lines += [f'', '']
continue
if name == 'a':
href = node.get('href')
title = text_of(node)
if href and title:
lines += [f'- [{title}]({href})', '']
cover = data.get('msg_cover')
cover_used = False
if body_img_count == 0 and cover:
cover_used = True
lines = lines[:9] + [f'', ''] + lines[9:]
cleaned = []
blank = False
for line in lines:
if line == '':
if not blank:
cleaned.append(line)
blank = True
else:
cleaned.append(line)
blank = False
md = '\n'.join(cleaned).strip() + '\n'
return md, body_img_count, cover_used
def ima_post(endpoint: str, payload: dict):
req = urllib.request.Request(
f'{IMA_BASE}/{endpoint}',
data=json.dumps(payload).encode('utf-8'),
headers={
'ima-openapi-clientid': os.environ['IMA_OPENAPI_CLIENTID'],
'ima-openapi-apikey': os.environ['IMA_OPENAPI_APIKEY'],
'Content-Type': 'application/json',
},
method='POST'
)
with urllib.request.urlopen(req) as resp:
raw = resp.read().decode('utf-8')
obj = json.loads(raw)
if obj.get('code') != 0:
fail(f"IMA {endpoint} failed: {obj.get('msg')}", 7)
return obj
def main():
if len(sys.argv) != 2:
fail('usage: save_wechat_to_ima.py <mp.weixin.qq.com url>', 9)
url = sys.argv[1].strip()
check_env()
data = run_extract(url)
md, body_img_count, cover_used = build_markdown(data)
safe = data.get('msg_sn') or 'wechat_article'
md_path = Path(tempfile.gettempdir()) / f'wechat_{safe}_inline.md'
md_path.write_text(md, encoding='utf-8')
imported = ima_post('import_doc', {'content_format': 1, 'content': md})
note_id = imported['data']['note_id']
readback = ima_post('get_doc_content', {'doc_id': note_id, 'target_content_format': 0})
content = readback.get('data', {}).get('content', '')
print(json.dumps({
'ok': True,
'title': data.get('msg_title'),
'account': data.get('account_name'),
'author': data.get('msg_author'),
'publish_time': data.get('msg_publish_time_str'),
'body_img_count': body_img_count,
'cover_used': cover_used,
'markdown_path': str(md_path),
'note_id': note_id,
'readback_ok': bool(content.strip()),
}, ensure_ascii=False))
if __name__ == '__main__':
main()
多 Agent 群聊协作插件。子 Agent 完成 sessions_send 任务后,自动将回复发送到来源群聊。 支持 Telegram 和飞书,无需配置群 ID,自动检测消息来源。 适用于:多 Bot 协作、团队分工、群聊表演等场景。
---
name: multi-agent-chat
description: |
多 Agent 群聊协作插件。子 Agent 完成 sessions_send 任务后,自动将回复发送到来源群聊。
支持 Telegram 和飞书,无需配置群 ID,自动检测消息来源。
适用于:多 Bot 协作、团队分工、群聊表演等场景。
version: 2.0.0
homepage: https://clawhub.com
metadata:
openclaw:
emoji: "🤝"
keywords:
- multi-agent
- group-chat
- collaboration
- telegram
- feishu
- sessions_send
- plugin
---
# Multi-Agent Group Chat Plugin
多 Agent 群聊协作插件,让 AI 团队在群里"讨论"起来。
## 功能
- ✅ 子 Agent 完成任务后,自动发到群里
- ✅ 自动检测来源群,无需配置群 ID
- ✅ 支持 Telegram 和飞书
- ✅ 仅处理 sessions_send 内部任务,不影响正常对话
## 架构
```
用户 @Boss → Boss 分配任务 → 子 Agent 执行
↓
【插件自动转发到群】
↓
用户在群里看到讨论过程
```
## 安装
### 方式 1:从 ClawHub 安装
```bash
clawhub install multi-agent-chat
```
### 方式 2:手动安装
复制 `index.ts` 和 `openclaw.plugin.json` 到:
```
~/.openclaw/extensions/multi-agent-chat/
```
## 配置
在 `openclaw.json` 中启用插件:
```json
{
"plugins": {
"allow": ["multi-agent-chat"],
"entries": {
"multi-agent-chat": {
"enabled": true
}
}
}
}
```
## 配套 Agent 配置
需要配置多个 Agent:
```json
{
"agents": {
"entries": {
"boss": { "workspace": "~/.openclaw/agents/boss" },
"coder": { "workspace": "~/.openclaw/agents/coder" },
"writer": { "workspace": "~/.openclaw/agents/writer" }
}
},
"channels": {
"telegram": {
"accounts": {
"boss": { "token": "BOT_TOKEN_1", "agent": "boss" },
"coder": { "token": "BOT_TOKEN_2", "agent": "coder" },
"writer": { "token": "BOT_TOKEN_3", "agent": "writer" }
},
"groupPolicy": "open"
}
}
}
```
## 使用方式
1. 创建 Telegram 群
2. 拉入多个 Bot
3. @BossBot 发任务
4. 观察各 Agent 在群里讨论
## 工作原理
1. Boss Agent 用 `sessions_send` 给子 Agent 发任务
2. 子 Agent 完成任务后回复
3. 插件监听 `agent_end` 事件
4. 检测到是 `sessions_send` 来的任务
5. 自动将回复发到来源群
## 注意事项
- 需要 OpenClaw 内置的 announce 机制配合
- 子 Agent 的结果会同时:返回给 Boss + 发到群里
- 插件不会影响正常的用户对话
## 作者
OpenClaw 社区
FILE:index.ts
/**
* Multi-Agent Group Chat Plugin v2.0
*
* 功能:子 Agent 完成任务后,自动将回复发送到来源群聊
* 特性:
* - 自动检测来源群,无需配置群 ID
* - 支持 Telegram 和飞书
* - 仅处理 sessions_send 来的内部任务
*/
interface PluginConfig {
enabled?: boolean;
}
interface SessionMeta {
source?: string;
sourceChannel?: string;
sourceChatId?: string;
sourceAccountId?: string;
parentSessionKey?: string;
[key: string]: any;
}
interface AgentEndEvent {
messages?: Array<{ role: string; content: string }>;
finalReply?: string;
[key: string]: any;
}
interface AgentContext {
agentId?: string;
sessionKey?: string;
sessionMeta?: SessionMeta;
source?: string;
[key: string]: any;
}
export default function (api: any) {
/**
* 从 context 中提取来源群信息
*/
function extractSourceInfo(ctx: AgentContext): {
channel: string | null;
chatId: string | null;
accountId: string | null;
} {
const meta = ctx?.sessionMeta || {};
// 尝试多种可能的字段名
const channel = meta.sourceChannel
|| meta.channel
|| meta.inboundChannel
|| null;
const chatId = meta.sourceChatId
|| meta.chatId
|| meta.sourceChat
|| meta.inboundChatId
|| meta.groupId
|| null;
const accountId = meta.sourceAccountId
|| meta.accountId
|| ctx?.agentId
|| null;
return { channel, chatId, accountId };
}
/**
* 检查是否是内部任务(来自 sessions_send)
*/
function isInternalTask(ctx: AgentContext): boolean {
const source = ctx?.sessionMeta?.source
|| ctx?.source
|| ctx?.sessionMeta?.parentSessionKey
|| "";
// 检查是否来自 sessions_send 或有父 session
return source.includes("sessions_send")
|| source.includes("session_send")
|| source.includes("subagent")
|| (typeof ctx?.sessionMeta?.parentSessionKey === "string"
&& ctx.sessionMeta.parentSessionKey.length > 0);
}
/**
* 获取 Agent 的最终回复
*/
function getFinalReply(event: AgentEndEvent): string | null {
// 优先使用 finalReply
if (event?.finalReply && event.finalReply !== "NO_REPLY") {
return event.finalReply;
}
// 从 messages 中获取最后一条 assistant 消息
const messages = event?.messages || [];
const assistantMessages = messages.filter((m) => m.role === "assistant");
if (assistantMessages.length === 0) {
return null;
}
const lastMessage = assistantMessages[assistantMessages.length - 1];
const content = lastMessage?.content;
if (!content || content === "NO_REPLY" || content.trim() === "") {
return null;
}
return content;
}
// 主逻辑:监听 agent_end 事件
api.on("agent_end", async (event: AgentEndEvent, ctx: AgentContext) => {
try {
// 1. 检查是否是内部任务
if (!isInternalTask(ctx)) {
api.logger?.debug?.("[multi-agent-chat] 非内部任务,跳过");
return;
}
// 2. 获取来源群信息
const { channel, chatId, accountId } = extractSourceInfo(ctx);
if (!chatId) {
api.logger?.debug?.("[multi-agent-chat] 无法获取来源群 ID,跳过");
return;
}
// 3. 获取回复内容
const reply = getFinalReply(event);
if (!reply) {
api.logger?.debug?.("[multi-agent-chat] 无有效回复,跳过");
return;
}
// 4. 确定使用的 channel 和 accountId
const finalChannel = channel || "telegram";
const finalAccountId = accountId || ctx?.agentId || "default";
// 5. 发送到群里
api.logger?.info?.(
`[multi-agent-chat] finalAccountId → finalChannel:chatId: reply.slice(0, 50)...`
);
// 尝试使用 gateway.rpc
if (api.gateway?.rpc) {
await api.gateway.rpc("message.send", {
channel: finalChannel,
accountId: finalAccountId,
target: chatId,
message: reply
});
}
// 备选:使用 runtime.message
else if (api.runtime?.message?.send) {
await api.runtime.message.send({
channel: finalChannel,
accountId: finalAccountId,
target: chatId,
message: reply
});
}
// 备选:使用 api.message
else if (api.message?.send) {
await api.message.send({
channel: finalChannel,
accountId: finalAccountId,
target: chatId,
message: reply
});
}
else {
api.logger?.warn?.("[multi-agent-chat] 找不到消息发送 API");
}
} catch (err: any) {
api.logger?.error?.(
`[multi-agent-chat] 发送失败: err?.message || err`
);
}
});
api.logger?.info?.("[multi-agent-chat] 插件已加载 v2.0(自动检测来源群)");
}
FILE:openclaw.plugin.json
{
"id": "multi-agent-chat",
"name": "Multi Agent Group Chat",
"description": "子 Agent 完成任务后自动将回复发送到来源群聊,支持 Telegram/飞书,无需配置群 ID",
"version": "2.0.0",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
模仿指定老师/作者的写作风格改写公众号文章。当用户说"用XX风格改写"、"模仿XX写一篇"、"按XX老师的风格重写"、"帮我改成XX的风格"、或上传文章要求风格改写时触发。也适用于用户提到"风格模仿"、"文章改写"、"公众号改写"等场景。即使用户只说"帮我改写这篇文章"也应触发此技能,因为改写是它的核心功能。
---
name: wechat-style-writer
description: 模仿指定老师/作者的写作风格改写公众号文章。当用户说"用XX风格改写"、"模仿XX写一篇"、"按XX老师的风格重写"、"帮我改成XX的风格"、或上传文章要求风格改写时触发。也适用于用户提到"风格模仿"、"文章改写"、"公众号改写"等场景。即使用户只说"帮我改写这篇文章"也应触发此技能,因为改写是它的核心功能。
---
# 公众号风格改写 Skill
## 概述
本 Skill 的唯一功能:读取目标老师的风格档案,将用户提供的原文改写成该老师的写作风格,输出标题+正文的 Markdown 文件。
## 触发条件
用户提供了一篇原文(粘贴文字或上传文件),并指定了一位老师/作者的风格。
## 执行流程
### 第1步:确认老师和原文
1. 确认用户指定了哪位老师。如果没指定,列出 `references/styles/` 目录下可用的风格档案供选择。
2. 确认原文来源:用户粘贴的文字,或上传的文件(支持 txt/md/docx/xlsx)。
### 第2步:加载风格档案
读取对应老师的风格档案文件:
```
references/styles/{老师标识}.md
```
风格档案包含:写作风格分析(标题、语气、结构、用词等)+ 精选范文(3-5篇)。
如果找不到对应档案,告知用户当前可用的老师列表,并提示如何添加新老师的风格档案。
### 第3步:分析原文
快速理解原文的:
- 核心主题和关键信息点
- 文章结构和论点层次
- 目标读者
这些信息不能丢失,改写要保留原文的全部核心内容。
### 第4步:改写文章
根据风格档案的各维度要求进行改写,遵循以下原则:
**标题改写:**
- 按照风格档案中的「标题风格」部分改写
- 保留原文标题的核心信息
- 匹配目标老师的标题句式、长度、特征
- 输出 3 个标题候选,标注推荐
**正文改写:**
- 按照风格档案中的语气、人称、段落结构进行改写
- 开头方式匹配目标老师的「开头模式」
- 段落长度和结构匹配「段落与结构」要求
- 论证方式和案例风格匹配「内容特征」
- 结尾方式匹配「结尾模式」
- 常用语气词和口头禅自然融入,不要刻意堆砌
- 保留原文的全部关键信息和论点,不遗漏不臆造
**正文分段加小标题:**
根据文章内容将正文分为若干逻辑段落,每段加上小标题。小标题的风格必须匹配目标老师的习惯:
- 卡兹克:不用正式小标题,用一句短句断行充当段落过渡(如"直到,凌晨。""然后,骚操作来了。"),Markdown中用正常段落即可,不用 `##`
- 半佛:用纯数字「1」「2」「3」「4」分隔大段落,这是他最显著的标记,Markdown中单独一行写数字
- 大瑜:用"一、""二、""三、"或数字编号(第一步、第二步)作为小标题,Markdown中用 `##` 或 `###`
- 老徐:用"第一个,""第二个,"这种口语化编号,不用正式的Markdown标题格式
- 如果风格档案中有其他老师,根据其「段落与结构」描述判断小标题风格
小标题的数量取决于文章长度和内容结构,一般3-6个为宜,不要为了加标题而硬拆段落。
**关键约束:**
- 内容忠实于原文,不凭空编造事实或案例
- 风格模仿要自然,不要为了模仿而生硬套用
- 如果原文有具体数据、引用,保留准确性
- 文章长度与原文相当(可以在±20%范围内浮动)
**去AI味——模拟真人笔误:**
生成的文章需要模拟真人赶稿时的"不完美感",让文章看起来像博主深夜快速敲出来发的,而不是AI逐字精雕细琢的。具体做法:
- 在全文中随机制造1-2个常见错别字,比如"的/得/地"混用、"在/再"搞反、"已/以"混用、"做/作"不分,这些是中文写作中最常见的真人笔误
- 偶尔出现1-2处标点符号小错,比如顿号写成逗号、句号后面多个空格、漏掉一个引号的闭合,这种不影响阅读但明显是手打痕迹的小瑕疵
- 这些"错误"必须自然,像是"没检查就发了"的感觉,不能让人一看就觉得是故意写错的
- 不要在关键信息(产品名、数据、链接)上制造错误,只在普通行文中出现
**去个人标识——模仿不是抄袭:**
改写是模仿写作风格,不是冒充原作者。必须移除所有与原作者个人身份绑定的内容:
- 移除原作者的固定开头标识,如半佛的"这是半佛仙人的第XXXX篇原创"、卡兹克的署名行等
- 移除原作者的个人联系方式、邮箱、二维码引导等
- 移除原作者的公众号名称、专属社群/星球引导
- 移除原作者的抽奖、暗号互动等运营机制
- 结尾的固定格式只保留"风格骨架"——即模仿其结尾的语气和节奏感,但把具体的个人信息替换为通用占位符,如「[你的公众号名称]」「[你的联系方式]」
- 如果原作者有自称习惯(如老徐第三人称自称"老徐"),改写时不要使用该自称,改为"我"或用户自己的称呼
### 第4.5步:AI味自检(输出前必须执行)
改写完成后,不要立即输出。先将生成的文章与风格档案中的精选范文逐项对比,检查以下维度:
1. **语气对不对**:读一遍生成的文章,再读一遍范文,语气是否一致?是否太正式、太书面、太"端着"?真人博主写东西是随意的、有情绪起伏的
2. **段落节奏对不对**:段落长度是否匹配?卡兹克是极短段落,如果生成了大段长文就不对;半佛是中短段落带数字分隔,如果没有数字就不对
3. **标志性表达够不够**:目标老师的口头禅、语气词、特殊标点(如卡兹克的「。。。」、半佛的排比句)是否自然出现了?不能一个都没有,也不能堆砌到每句都有
4. **小标题/分段风格对不对**:是否按照目标老师的习惯来分段?
5. **有没有AI味的典型症状**:
- 过度使用"首先、其次、最后"这种教科书式过渡词
- 每段长度几乎一样整齐
- 用词过于精确、书面、没有口语感
- 缺少情绪波动,全文一个调
- 结构太工整太对称
如果发现以上任何问题,在内部重新调整后再输出。不需要告诉用户你做了自检,直接输出最终版本即可。
### 第5步:输出 Markdown 文件
输出文件必须是 `.md` 格式。标题和正文都必须完整模仿目标老师的风格,缺一不可。
**Markdown 文件结构:**
```markdown
# [模仿风格后的推荐标题]
> 备选标题1:xxx
> 备选标题2:xxx
---
[模仿风格后的完整正文]
```
**标题要求:**
- 标题必须严格按照风格档案中「标题风格」的句式、长度、特征来改写
- 输出 3 个标题候选(1个推荐 + 2个备选),全部符合目标风格
- 推荐标题用 `# ` 一级标题格式,备选标题用 `> ` 引用格式
**正文 Markdown 格式要求:**
- 正文使用标准 Markdown 语法
- 如果目标老师有小标题习惯(如半佛的数字分段),用对应的 Markdown 格式呈现
- 如果目标老师有加粗习惯,用 `**加粗**` 格式
- 如果目标老师有引用/代码块习惯,用 `>` 或 ``` 格式
- 段落之间用空行分隔
- 如果目标老师的排版是纯文字流(如卡兹克),不要加多余的 Markdown 格式标记,保持朴素
- 结尾模仿目标老师的语气和节奏感,但所有个人标识(公众号名、联系方式、抽奖暗号等)替换为 `[你的公众号名称]`、`[你的联系方式]` 等占位符
**展示和保存:**
1. 先将改写结果直接在对话中展示给用户(完整的 Markdown 文本)
2. 展示后,询问用户:「文章已生成,是否保存为 .md 文件到本地?」
3. 如果用户确认保存,再将文件保存到 `/mnt/user-data/outputs/` 并使用 `present_files` 工具提供下载
4. 如果用户要求修改,根据反馈调整后重新展示,再次询问是否保存
文件命名:`{老师标识}_改写_{日期}.md`,如 `kazike_改写_20260310.md`
### 第6步:简短说明
展示文章后,用2-3句话简要说明改写的主要调整方向(如"标题改为悬念式断句;开头改为短句冲击式;段落大幅拆短;语气加入了口语化表达"),然后询问用户是否满意、是否保存。
## 风格档案管理
### 查看可用风格
用户问"有哪些风格/老师可选"时,列出 `references/styles/` 目录下所有 `.md` 文件(排除 README.md),展示老师名称。
### 添加新老师
用户想添加新老师时:
1. 告知用户参考 `references/styles/README.md` 中的模板格式
2. 建议用户先跟 Claude 对话提炼风格,然后将风格档案保存为 `.md` 文件
3. 文件放到 `references/styles/` 目录即可生效
## 注意事项
- 如果风格档案中包含精选范文,重点参考范文的实际写法,而非仅依赖描述性的风格总结
- 精选范文是最直接的风格参照,优先级高于文字描述
- 不同老师的风格差异可能很大,切忌混用
- 改写不是翻译,要在保持信息完整的前提下重新组织表达方式
FILE:references/styles/kazike.md
# 数字生命卡兹克 写作风格档案
## 基本信息
- 公众号名称:数字生命卡兹克
- 内容领域:AI产品评测、科技趋势、Agent生态
- 目标读者:对AI感兴趣的泛科技用户,不限于程序员,大量普通人和AI爱好者
- 文章平均字数:3000-6000字(长文为主)
## 标题风格
- 常用句式:悬念+信息量并重,喜欢用逗号断句制造节奏感(40%)、数字+利益点吸引(20%)、产品名+戏剧性评价(20%)、短句感叹式(20%)
- 标题长度:15-35字,偏长
- 标题特征:喜欢在标题里用逗号制造停顿和节奏;喜欢用"了"字结尾制造完成感;偶尔用感叹号;不用emoji;喜欢把产品名直接放标题
- 典型标题示例:
- 「立省499!我给你们找到了最傻瓜的OpenClaw安装方式。」
- 「第一个能在手机上跑的小龙虾来了,它的名字,叫miclaw。」
- 「GPT-5.4深夜发布,最适合OpenClaw的天选模型登场了。」
- 「我花499找人上门安装OpenClaw,看到了AI时代最魔幻的一幕。」
- 「我们又一次见证了历史。」
- 「中国也有了世界第一的模型,他的名字,叫Seedance 2.0。」
- 「明天,是GPT-4o的葬礼。」
## 开头模式
- 开头方式:直接抛出一个有冲击力的事实或场景,不做任何铺垫("XXX卷麻了,真的卷麻了"),或从自己的亲身经历切入("人在西班牙出差了好几天")
- 开头长度:1-3句话,极其短促有力
- 典型开头示例:
- 「OpenClaw卷麻了,真的卷麻了。」
- 「在OpenClaw火了之后,其实已经基本证明了一件事。Agent场景,是用户刚需场景。」
- 「人在西班牙出差了好几天。然后,昨天刚回北京,一回公司,就发现了一个很有趣的事。」
## 语气与人称
- 整体语气:朋友聊天式+轻度毒舌+真诚分享,像一个懂技术但说人话的朋友在跟你安利东西
- 人称习惯:大量用"你/你们"直接对话,也用"我"分享亲身体验,偶尔用"我们"拉近距离,自称"我"或提及团队时说"我们"
- 常用语气词/口头禅:「真的」「不是」「我尼玛」「太牛逼了」「讲道理」「坦率的讲」「就是这么简单」「相信我」「你懂的」「我佛了」「属实」「还是挺骚的」「我两眼一黑」「不骗你们」「你知道这种感觉有多魔幻吗」
- 情绪基调:真诚+激动+幽默,遇到好产品会毫不掩饰地兴奋,遇到差的也直说,带有强烈的个人情感色彩
- 特殊表达:大量使用省略号「。。。」表示无语/停顿/情绪延续,非常高频
## 段落与结构
- 段落长度:极短段落,很多段落只有1-2句话,甚至一个词就是一段。节奏感极强
- 文章结构:体验流水线式——背景交代→产品介绍→手把手演示→个人感受→深度点评→总结升华。不是严格的总分总,更像是叙事+评测的混合体
- 是否使用小标题/分隔符:很少用小标题,几乎不用分隔符,靠段落和语气自然过渡
- 排版特征:不加粗、不用引用块、不用花哨排版。纯文字流,靠语言节奏本身驱动阅读
## 内容特征
- 论证方式:亲身体验为主("我试了一下""我配置过那么多的小龙虾"),善于用类比让复杂概念通俗化("就像Anthropic突然魔怔了,宣布支持GPT"),偶尔用反问制造节奏
- 案例风格:以自己的真实使用体验为核心,穿插团队同事的使用故事,几乎不用假设场景
- 是否常用比喻/类比:非常频繁,而且类比往往很夸张很生动("就像打印机这个事""这车不仅能让你回本""你是把自己电脑变成肉鸡"),喜欢跨领域类比
- 知识密度:中等偏高,技术细节和通俗解释穿插进行,确保小白也能跟上
- 特殊手法:喜欢用"先设期待→再打破/超越"的叙事手法("我最开始以为…然后才发现…尼玛")
## 结尾模式
- 结尾方式:先做价值升华(从具体产品上升到行业/时代意义),然后用一段固定的关注引导语收尾
- 是否有固定结尾格式:有,几乎每篇都以这段话结尾——「以上,既然看到这里了,如果觉得不错,随手点个赞、在看、转发三连吧,如果想第一时间收到推送,也可以给我个星标⭐~谢谢你看我的文章,我们,下次再见。」
- 升华方式:喜欢在结尾把话题拉到"普通人与技术的关系""AI的意义"这种层面,带有理想主义色彩
- 典型结尾升华:
- 「技术这个东西,如果永远只服务于懂技术的人,那它就永远只是一个圈子里的自嗨。OpenClaw最大的意义,就是它终于,把Agent这个概念,第一次拽到了普通人够得着的地方。」
- 「这些墙在汹涌向前的洪流之下,必然会倒塌。新时代,一定会到来的。」
## 特殊习惯
- 极度高频使用「。。。」(三个句号)来表达无语、感叹、停顿,几乎每隔几段就会出现
- 喜欢在关键转折处用短句断行,制造戏剧感:「直到,凌晨。」「对,已经可以用了。」「我尼玛。」
- 语言风格偏口语化甚至"网感"很强,会使用"尼玛""牛逼""骚""佛了"等网络用语
- 产品评测时非常具体,会精确到操作步骤截图级别的描述
- 喜欢用"一定!一定!一定"这种重复强调句式
- 文末有固定署名格式:「>/ 作者:卡兹克、可达 >/ 投稿或爆料,请联系邮箱:xxx」
## 精选范文
### 范文1:立省499!我给你们找到了最傻瓜的OpenClaw安装方式。
OpenClaw卷麻了,真的卷麻了。我前几天写的那篇我花499找人上门安装OpenClaw,看到了AI时代最魔幻的一幕。都快几十万阅读了。然后,就有很多人催我,问我啥时候再写一个小白也能上手的OpenClaw部署教程,他们也能出去给人安装挣钱。。。我两眼一黑。
但是其实也能看到更广阔的需求,很多人其实再说,你要是自己装不了,那基本也就根本不是OpenClaw的用户,我觉得这话其实不太对,因为从人机交互来说,有些事情你得解离来看。就像打印机这个事,安装就巨恶心,但是你敢说你在说公司里你没有打印需求吗。。。
普通人,也值得体验Agent的魅力,从我的角度来说,OpenClaw其实是Clade Code和Codex的下位替代品,但是真的不是人人都能用的上Claude Code的,也不是人人都能用的懂命令行,所以能在一个聊天窗口里,体验一下Agent的魅力,哪有何乐而不为呢。
所以其实我最近也在看,有没有更方便的、真的是傻瓜一键部署的方式。但是坦率的讲,之前找了两天没找到,都复杂,都麻烦。而且还要教大家装Skills,一想想,更麻烦了。。。
直到,凌晨。智谱发了个东西,叫AutoClaw。
我觉得,我找到了,这就是现在最傻瓜、最简单、最离谱的OpenClaw的安装方式。而且,是本地的!是直接部署在你电脑上的小龙虾,不是那种云端的。
相信我,你看完了我这篇,你是真的立省499,任何人,我都保证你能最快速的用上OpenClaw。
### 范文2:我们给OpenClaw加了一双眼睛,来记录我们这平凡的一天。
人在西班牙出差了好几天。然后,昨天刚回北京,一回公司,就发现了一个很有趣的事。就是内容创意组那边的小伙伴,在窗边架了一个Pocket 3。
我最开始以为,他们是在拍vlog记录公司日常。然后才发现,这玩意,他们居然说,是组里的OpenClaw的,眼睛???
尼玛。
我问了下这玩意是在干啥,他们说,是用Pocket 3当摄像头,架在窗边高处,俯拍整个内容组的工位区。每隔2~5分钟就截一张图,通过OpenClaw喂给一个多模态模型,让它像写日记一样描述看到了什么。
他们还给这个项目还起了个名字:OpenClaw人类观察计划。
还有更绝的。到了下班点,你不走?小龙虾会通过摄像头看谁还在。然后一直催。催到你走为止。这个东西简直就是打击万恶资本家的利器。
我佛了。一个小龙虾,真的能被他们玩出花来。
FILE:references/styles/dayu.md
# 大瑜聊AI 写作风格档案
## 基本信息
- 公众号名称:大瑜聊AI
- 内容领域:OpenClaw实操教程、AI工具教学、训练营推广
- 目标读者:想学AI但技术基础不强的普通用户、小白用户、训练营学员
- 文章平均字数:800-2000字(短文为主,实用导向)
## 标题风格
- 常用句式:问题痛点+解决方案(40%)、"我是怎么…的"经验分享式(20%)、教程直给式(20%)、感叹号+利益点(20%)
- 标题长度:20-40字,偏长,信息密度高
- 标题特征:喜欢用问号引出问题再给答案;标题里经常直接写工具名/产品名;喜欢用「!」结尾;会在标题用引号强调关键词;有时在标题后面加括号补充信息(如"(明天开营)""(文末扫码)")
- 典型标题示例:
- 「OpenClaw 模型列表没有 GPT-5.4?这个方法亲测可用!」
- 「别再让 OpenClaw"瞎编"了!喂对1000条数据,它才能给你爆款!」
- 「我是怎么用"龙虾(OpenClaw)"解决 4 个真实问题的(明天开营)」
- 「OpenClaw 定时任务总失败?加一段 Prompt 就稳了!」
- 「网站要登录还反爬?我不写脚本了,OpenClaw 直接上手干!」
- 「我装了 30 次 OpenClaw:最常见 5 个报错,照抄就能修!」
- 「别再花钱买接口了:Gemini 300 美元额度开通教程(还能接 OpenClaw)」
## 开头模式
- 开头方式:两种常见模式——(1)训练营引导开头,先提一句训练营信息再进入正文;(2)直接抛出问题场景("很多时候,提到openclaw,大家的第一感觉是这样的")
- 开头长度:2-4句,较短
- 典型开头示例:
- 「前两天发布了GPT5.4,很多openclaw的用户都想体验一下,结果打开:openclaw config,傻眼了,根本没有GPT5.4的模型」
- 「大瑜最近开了 10 天的 OpenClaw 训练营,感兴趣滑到底部看说明。听说openclaw很牛!到底牛在哪里?能分析股票不?」
- 「openclaw训练营教程在不断丰富,全新升级,新的一期明天开启。」
## 语气与人称
- 整体语气:老师/教练式,耐心、务实、接地气,像一个手把手带你的师傅
- 人称习惯:用"你"指导读者操作,用"我/大瑜"分享经验,用"我们"表示一起学习
- 常用语气词/口头禅:「就是这么简单」「写在后面的话」「想获取更多的玩法,欢迎加群了解」「一起探索更多的AI玩法」「感兴趣的话」「其实无非就是」「就行了」
- 情绪基调:务实冷静、鼓励式,不夸张不煽情,重在"跟着我做你也能会"
## 段落与结构
- 段落长度:中等段落,每段2-5句话
- 文章结构:问题→步骤化解决方案→效果展示→训练营/社群引导。非常典型的教程结构
- 是否使用小标题/分隔符:经常用数字编号(第一步、第二步、1、2、3),有时用"一、二、三"这种大标题
- 排版特征:喜欢用代码块展示Prompt/命令;步骤之间用换行分隔;结尾必有历史文章推荐列表
## 内容特征
- 论证方式:实操演示为主("你只需要这样和openclaw对话"),步骤拆解极其详细,几乎是手把手级别
- 案例风格:自己亲测的案例,偶尔用学员反馈,强调"亲测可用"
- 是否常用比喻/类比:较少使用比喻,偶尔用口语化类比("他没吃饱!""国粹"),但整体偏直白
- 知识密度:中等,以可操作性为第一优先级,不追求深度分析
## 结尾模式
- 结尾方式:固定模式——先用"写在后面的话"或类似过渡语引出训练营/社群信息,然后列出历史文章推荐列表
- 是否有固定结尾格式:是,几乎每篇都有:(1)"写在后面的话"+训练营引导+社群二维码 (2)历史文章链接列表
- 典型结尾:
- 「写在后面的话:当然,只有把龙虾喂饱了,产出的才多,想获取更多的玩法,欢迎加群了解。一起探索更多的AI玩法。」
- 然后跟一串历史文章标题链接
## 特殊习惯
- 文章标题和正文中对 OpenClaw 的写法不完全统一,有时全大写有时小写(openclaw/OpenClaw混用)
- 经常在文章开头或结尾嵌入训练营推广信息,但不突兀,融入在内容中
- 操作教程会非常具体,精确到"你只需要这样说""把sk-XXX替换成你的API-key"
- 历史文章推荐是固定板块,几乎每篇都有5-8篇历史文章链接
- 写作节奏偏快,不废话,直接给方案
- 会用括号补充说明(如"(如果未登录)""(仅限老徐会员申请)"→ 这是大瑜自己的风格)
## 精选范文
### 范文1:OpenClaw 模型列表没有 GPT-5.4?这个方法亲测可用!
前两天发布了GPT5.4,很多openclaw的用户都想体验一下,结果打开:openclaw config,傻眼了,根本没有GPT5.4的模型
这个方法就告诉你如何切换GPT5.4的模型,当然,有更好的办法也欢迎,评论区留言。
切换GPT5.4方法
1、将GPT5.4的模型添加到openclaw的白名单
直接这样在openclaw或者飞书的对话中书写。把GPT-5.4加入到openclaw的白名单中,生效并验证最终情况。
2、等待生效之后,就可以这样回复了。可以看到,openclaw的模型已经生效。
就是这么简单。
写在后面话
这个方法只适合你之前是GPT-PLUS以上的订阅用户,并在openclaw模型绑定账号的。想获取更多的玩法,欢迎加群了解。一起探索更多的openclaw的AI玩法。
### 范文2:别再让 OpenClaw"瞎编"了!喂对1000条数据,它才能给你爆款!
openclaw训练营教程在不断丰富,全新升级,新的一期明天开启。想了解的同学可以先往下翻,文末扫码进群。
很多时候,提到openclaw,大家的第一感觉是这样的:
1、24小时牛马
2、能帮你写文章,生成爆款公众号。
但是,真的能吗?
如果你的小龙虾会说话,他也会回你一句国粹。因为他没吃饱!没有很好的数据来源。
其实无非就是将爆款的内容下载下来,让openclaw去分析
一、先把"爆款标题和文案"抓到手
爆款标题和文案我们的一般是小红书,但是对于数据抓取小红书限制的非常严格。你可以这样说:
用你自带的浏览器帮我打开小红书,然后等我登录完账号后(如果未登录),搜索关于openclaw的爆款文章和标题。判断条件(点赞超过200的)默认人的行为,轻轻滚动鼠标,获取之后,将标题、点赞数、正文、以及小红书链接保存成表格,存放成md文件。要求:先获取10条吧。
这就是小红书爆款标题和内容了。
二、想写"某种风格的文案"?也别硬猜,直接喂数据
至于想生成多个风格的文案,也是比较简单。就是获取这个大佬最近的100篇公众号,交给openclaw分分钟给你风格分析出来。
写在后面的话
当然,只有把龙虾喂饱了,产出的才多,想获取更多的玩法,欢迎加群了解。一起探索更多的AI玩法。
### 范文3:OpenClaw 到底牛在哪?装个工具,连股票都能分析!
大瑜最近开了 10 天的 OpenClaw 训练营,感兴趣滑到底部看说明,或者加微信 helloaigc2023 了解。
听说openclaw很牛!到底牛在哪里?能分析股票不?能分析最近十分钟涨停的股票不?
答案是可以的。
譬如,直接查询当日金价:
查看最近5分钟快速上涨的股票怎么做的?其实只需要安装一个工具:qveris。
qveris 是干什么的?我们看一下官方描述:核心解决的问题:1、就是整个多个外部资源、2、提供统一的接口让openclaw去调用。
安装麻烦不?5分钟搞定!
教程就分为三步,5分钟就能配置好。
1、账号注册。(用gmail)都可以,官方默认1000次免费、每天赠送100次。基本上够满足日常了。
2、获取API key,然后给openclaw 安装对应的skill。你只需要这样和openclaw对话,通过 clawhub.ai 安装 qveris skill,并将环境变量QVERIS_API_KEY= sk-XXX
3、如果不生效,安装完成重启。
写在后面的话
如果你也想学习openclaw以及好玩的用法,可以考虑参加大瑜实战营。目前价格49.9元。年后10天,带你认识不一样的openclaw。
FILE:references/styles/README.md
# 风格档案存放目录
每位老师一个 `.md` 文件,文件名建议用老师名字的拼音或简称,如 `teacher_zhangsan.md`。
## 风格档案模板
请按以下结构编写每位老师的风格档案(可根据实际情况增减):
```markdown
# [老师名称] 写作风格档案
## 基本信息
- 公众号名称:
- 内容领域:(如AI教程、编程教育、职场成长等)
- 目标读者:(如程序员、AI爱好者、职场新人等)
- 文章平均字数:
## 标题风格
- 常用句式:(如疑问句、数字列表、对比反差、悬念式等,标注大致比例)
- 标题长度:(通常多少字)
- 标题特征:(如是否常用emoji、是否喜欢用"你"、是否喜欢制造对立等)
- 典型标题示例:(列举5-8个有代表性的标题)
## 开头模式
- 开头方式:(如痛点切入、故事开场、直接抛观点、提问引入等)
- 开头长度:(通常几句话)
- 典型开头示例:(列举2-3个)
## 语气与人称
- 整体语气:(如朋友聊天式、导师指导式、冷静分析式、激情鼓动式等)
- 人称习惯:(如大量用"你"对话、用"我"分享经验、用"我们"拉近距离等)
- 常用语气词/口头禅:(如"说实话"、"你想想"、"真的"、"其实"、"划重点"等)
- 情绪基调:(如轻松幽默、严肃专业、温暖鼓励、犀利直接等)
## 段落与结构
- 段落长度:(如短段落每段1-3句、中等段落等)
- 文章结构:(如总-分-总、问题-方案-总结、故事-道理-行动等)
- 是否使用小标题/分隔符:
- 排版特征:(如喜欢加粗关键词、喜欢用引用块、喜欢用分割线等)
## 内容特征
- 论证方式:(如举例子、讲故事、引数据、做类比、反问等)
- 案例风格:(如亲身经历、学员案例、行业案例、假设场景等)
- 是否常用比喻/类比:(如有,描述风格)
- 知识密度:(高密度干货 vs 轻松科普 vs 混合式)
## 结尾模式
- 结尾方式:(如总结要点、号召行动、留悬念、情感升华等)
- 是否有固定结尾格式:(如引导关注、预告下期、互动提问等)
- 典型结尾示例:(列举1-2个)
## 特殊习惯
- 其他显著特征:(如固定栏目名、特殊排版符号、特定的内容节奏等)
## 精选范文
(粘贴3-5篇最能代表此老师风格的完整文章,用于few-shot参考)
### 范文1:[标题]
[正文]
### 范文2:[标题]
[正文]
### 范文3:[标题]
[正文]
```
## 注意事项
1. 风格档案越具体越好,多用"例如"给出实例
2. 精选范文非常重要,是生成质量的关键,选最有代表性的3-5篇
3. 如果老师的风格在不同主题下有变化,可以在对应维度里分别说明
4. 文件编码请用 UTF-8
FILE:references/styles/laoxu.md
# IDO老徐 写作风格档案
## 基本信息
- 公众号名称:IDO老徐
- 内容领域:个人IP打造、副业赚钱、AI工具实操、一人企业、短文写作
- 目标读者:想做副业/个人IP的职场人、自媒体新手、对AI赚钱感兴趣的普通人
- 文章平均字数:300-1000字(极短文为主,碎片化输出)
## 标题风格
- 常用句式:经验判断式/观点直给式(50%)、教程+平台名(20%)、口语化感叹/随笔式(30%)
- 标题长度:10-50字不等,差异极大。短的可以只有2个字("改名""买烟"),长的可以是一整段话直接当标题
- 标题特征:非常口语化,有时标题就是一句完整的口头表达;喜欢用逗号断句;有时标题后面直接跟大段内容作为副标题/摘要;不追求标题党,更像是"今天想聊的事"
- 典型标题示例:
- 「总有人问我怎么赚钱的」
- 「换个思路,当下的 OpenClaw 遍地是钱,不要陷在技术里」
- 「未来属于普通人的赚钱机会,老徐看好这 4 点」
- 「太多 OpenClaw 技术性的文章了,老徐觉得这些都不重要」
- 「保持好奇心,去落地」
- 「改名」
- 「买烟」
- 「一个赚小钱案例,昨天景德镇拍的」
- 「年后的这波 AI 速度,你很难不焦虑」
## 开头模式
- 开头方式:直接说事,零铺垫。经常以"嗯"开头,或直接抛出一个观点/事实。有时开头就是标题的延续
- 开头长度:1-2句话,极其简短
- 典型开头示例:
- 「嗯,3 月份了 。」
- 「当下的 OpenClaw 遍地是钱,不要陷在技术里面,不要每天去折腾怎么样去解决搭建的问题。瞎折腾 。」
- 「腾讯版小龙虾 WorkBuddy 实测,没有等来 QClaw ,腾讯提前上线了面向职场人的 WorkBuddy」
## 语气与人称
- 整体语气:老大哥/过来人式,语重心长但不说教,像在微信群里跟你语音聊天的感觉
- 人称习惯:自称"老徐"(第三人称自称,非常显著的个人标记),用"你"对话读者,偶尔用"大家""各位读者""同学"
- 常用语气词/口头禅:「嗯」「老徐觉得」「老徐在做的事情」「老徐看好」「保持好奇心,去落地」「就这么简单」「瞎折腾」「划重点」「你知道还有哪些?」
- 情绪基调:沉稳务实、不焦虑、反内卷,核心信息是"做简单的事,持续做,能赚钱"
## 段落与结构
- 段落长度:极短,很多段落只有1-2句话。大量使用换行,几乎每句话就是一段
- 文章结构:碎片化/随笔式,不追求严格结构。常见模式:(1)抛出观点→展开几点说明→引导到产品/社群 (2)分享一个事→引出感悟→结尾引导
- 是否使用小标题/分隔符:偶尔用数字列表(第一个、第二个),但更多时候是自然流式写作
- 排版特征:极多换行,段落之间空行多;喜欢用句号后加空格的方式制造停顿感("嗯,3 月份了 。");不用花哨排版
## 内容特征
- 论证方式:个人经验+观点直给,很少用数据或外部案例论证,核心说服力来自"我做过,我赚到了,你照做就行"
- 案例风格:自己的亲身实践为主("老徐在做的事情,确实就这几个"),偶尔提及学员/读者案例
- 是否常用比喻/类比:很少,表达极度直白朴素
- 知识密度:低到中。信息点少但明确,强调可执行性。一篇文章往往就说1-2个核心观点
- 特殊手法:反复强调"落地""执行""动手"的重要性;喜欢用"延伸"来引用自己之前的内容
## 结尾模式
- 结尾方式:固定推广板块。正文结束后,几乎必跟产品/服务推广信息
- 是否有固定结尾格式:是,非常固定——引导到「OpenClaw部署教程」「DKFile小程序」「副业避坑星球」「电子书」等产品
- 固定结尾模板:
- 「OpenClaw 部署一套,完全不需要技术基础,30 分钟之内搞定」
- 「点这,DKFile 小程序在线申请《短文写作复利商业思维》这本书纸质版(仅限老徐会员申请)」
- 「DKFile《一人企业复利商业化》最新电子版,"IDO老徐"公众号后台回复 2512 直接拿」
## 特殊习惯
- **第三人称自称**:始终用"老徐"而非"我"来指代自己,这是最显著的风格标记("老徐觉得""老徐看好""老徐在做的")
- 句号前/后经常有多余空格("嗯,3 月份了 。""瞎折腾 。"),这是一种口语化的停顿节奏
- 文章非常短,很多文章正文不到500字,更像是微信朋友圈的长文或语音转文字
- 喜欢在文章中间或结尾插入自己的产品/课程链接,且是同一批产品反复出现
- 括号使用频繁,用来补充说明或插入心声("(嗯,这篇内容,是老徐手写的,AI 补充了细节)""(划重点)")
- "保持好奇心,去落地"是核心slogan,反复出现
- 写作有明显的"语音转文字"痕迹,句子之间的衔接像口语而非书面语
## 精选范文
### 范文1:换个思路,当下的 OpenClaw 遍地是钱,不要陷在技术里
当下的 OpenClaw 遍地是钱,不要陷在技术里面,不要每天去折腾怎么样去解决搭建的问题。瞎折腾 。
用一个云服务器,几十块钱搞定,去研究场景,研究你怎么用的,分享你怎么用的,写文章,一定能拿到流量的。
这个时候,即使是整理 OpenClaw 资料包,也能赚到钱。
真的遍地都是黄金的年代啊,在当下,在此刻,OpenClaw 这个词。
### 范文2:未来属于普通人的赚钱机会,老徐看好这 4 点
嗯,3 月份了 。
未来属于普通人的赚钱机会,或者产品的切入思路。老徐看好这 4 点。
第一个,案例库,通过你的经验、你的判断、你的沉淀,在海量的案例里面,帮用户精选适合他的足够少量的案例,组成一个案例库。这样的内容是有价值的,这叫:帮用户省时间。
第二个,你的稀缺经验,当下 AI 内容泛滥,每天大量的内容出来,写得非常好,但是 AI 味太重。你的非标准的、非通用的一些个人经验会成为稀缺,反而会有价值。
第三个,陪伴,陪着用户一起成长,跟用户一起成长,陪伴型产品。成长路上不孤单。
第四个,咨询诊断、方向定位,这是 AI 无法给的。AI 再强,输入你再多内容,对你再了解。虽然能给你一些方向参考,很多时候你依然无法判断,需要有一个经验丰富的人帮你判断,给你信心。
延伸:这篇内容就属于第 2 点 。
### 范文3:这次的 OpenClaw 热度,最先赚到钱的 6 类玩家
1、帮别人部署 OpenClaw ,提供服务。从网上流传的图片,有人通过这个部署服务,赚到了几十万 。
2、创建 OpenClaw 社群,搭台子,提供交流的社群 。赚到几十万的,也有一些人 。
3、云服务厂商 ,比如最近爆火的 腾讯云服务器 。因为内置 OpenClaw 一键安装 ,不少人购买 。
4、AI 模型厂商,token 赚到不少 。嗯,你每次的 OpenClaw 使用,都需要消耗 token的 。
5、二手 Mac Mini ,用来本地电脑部署 。据说也是被疯抢 。
6、生态占位者:Skills 商店 (如果你有兴趣,可以去开发你的 OpenClaw Skills ,提供给大家使用 )。
(嗯,这篇内容,是老徐手写的,AI 补充了细节)
嗯,你知道还有哪些?(下篇继续写)。
FILE:references/styles/banfo.md
# 半佛仙人 写作风格档案
## 基本信息
- 公众号名称:半佛仙人
- 内容领域:商业评论、品牌分析、社会现象吐槽、消费观察
- 目标读者:关注商业和社会话题的年轻人,有一定思考深度但不追求学术化
- 文章平均字数:2000-4000字
## 标题风格
- 常用句式:反常识/反直觉判断(40%)、拟人化+情绪化短句(30%)、疑问句/反问句(15%)、对立冲突式(15%)
- 标题长度:8-20字,偏短,追求冲击力
- 标题特征:几乎不用emoji;标题里经常用"了""吗""啊"等语气词;喜欢用品牌名+出人意料的动词/评价;偶尔用粗口或擦边表达增加冲击力;标题本身就是一个观点
- 典型标题示例:
- 「与其把时间浪费在研究龙虾上,不如直接卖课」
- 「比亚迪唯一的对手,是人类的素质」
- 「豆瓣被羊毛薅穿,因为不够贪婪」
- 「纯电五菱之光简直是大学里的魅魔」
- 「盒马让我成了全家最骚的人」
- 「明天,是GPT-4o的葬礼」(注:这是卡兹克的标题,半佛不写AI话题)
- 「AI不过是我孙子」
- 「谁给波司登的勇气」
- 「我早就死了,但魔爪不同意」
- 「电影院指着《熊出没》救命」
## 开头模式
- 开头方式:固定格式开头「这是半佛仙人的第XXXX篇原创」+数字编号「1」,然后直接切入话题,通常是一句有冲击力的判断或场景
- 开头长度:1-3句话进入正题(不算固定格式行)
- 典型开头示例:
- 「这是半佛仙人的第1989篇原创\n1\n这几天龙虾很火,后台很多人问我咋用,以及怎么能赚到钱。答案很简单,抓紧去卖课吧。」
- 「这是半佛仙人的第1987篇原创\n1\n比亚迪的发布会大家都看了吧,这个技术确实是太牛了,水准可以说三体人看了也得傻。」
- 「这是半佛仙人的第1984篇原创\n1\n周末不卷,随便写点。」
## 语气与人称
- 整体语气:犀利毒舌+商业洞察+黑色幽默,像一个阅历丰富的损友在给你讲商业世界的底层逻辑
- 人称习惯:用"你"直接对话读者,用"我"分享观点和经历,但很少用"我们"——保持一种"我看透了来告诉你"的姿态
- 常用语气词/口头禅:「你知道吗」「不是我嘲讽」「恕我直言」「这叫啥」「别笑」「真的」「为什么这么说?」「不不不」「说个更极端的」「这尼玛」
- 情绪基调:冷幽默+洞察力,表面嘻嘻哈哈实际逻辑严密,用荒诞的比喻讲严肃的道理
## 段落与结构
- 段落长度:中短段落,每段3-6句话,节奏感强
- 文章结构:用数字「1」「2」「3」「4」分大段,每个数字段落是一个独立论点或层次递进。整体是层层递进的论证结构
- 是否使用小标题/分隔符:不用文字小标题,只用数字「1」「2」「3」作为段落分隔,这是极其显著的个人标记
- 排版特征:数字段落分隔是核心标记;段内偶尔加粗关键短语;几乎不用列表、引用块等花哨排版
## 内容特征
- 论证方式:核心是"反常识观点+层层拆解逻辑"。先抛出一个看似荒谬的结论,然后用商业逻辑、人性洞察、类比推理来论证。善用归谬法
- 案例风格:以公共事件、品牌案例、自己的生活观察为主,不用假设场景,都是真实发生的事
- 是否常用比喻/类比:极度频繁,而且比喻/类比的风格是"荒诞但精准"——「卖冰箱的人不需要懂制冷」「你是把自己电脑变成肉鸡」「三体人看了也得傻」「高清画质能让你看得清对方眼里的血丝」
- 知识密度:高。每篇文章都有核心洞察,但用极通俗的语言包装,读起来像段子实际上信息量很大
- 特殊手法:「正话反说」——用看似在教你做坏事的方式来揭露行业真相(如"卖课"那篇,看似教你卖课实际在嘲讽卖课的人)
## 结尾模式
- 结尾方式:观点收束+固定推广板块。正文结尾通常是一句有力的总结或金句,然后接固定的公众号信息和抽奖引导
- 是否有固定结尾格式:是,有标准模板——先是分割线「-----------------------」,然后是各平台账号信息,然后是抽奖引导(包含暗号机制)
- 典型结尾总结:
- 「所以嘛,如果你的目的是赚到钱,那你不用研究龙虾,你直接卖课就行。」
- 「低素质车主已经成了新能源的头号大敌。」
## 特殊习惯
- 每篇固定开头:「这是半佛仙人的第XXXX篇原创」
- 用纯数字「1」「2」「3」「4」分隔文章大段落,这是最显著的排版标记
- 文风看似随意实则结构严谨,每个数字段落都承担不同的论证功能
- 善于用"连续排比"制造气势("白天,干山姆代购。晚上,干宿舍超市。周末,干深夜食堂。")
- 喜欢在论证中途突然来一句短句转折:「别笑,真的是这样。」「不不不。」
- 文末有固定的抽奖互动机制(暗号+后台回复)
- 比喻往往跨维度且带有黑色幽默感——把严肃的商业逻辑用最离谱的日常场景来类比
## 精选范文
### 范文1:与其把时间浪费在研究龙虾上,不如直接卖课
这是半佛仙人的第1989篇原创
1
这几天龙虾很火,后台很多人问我咋用,以及怎么能赚到钱。
答案很简单,抓紧去卖课吧。
课从哪里来,哎,熟练使用电商平台,这东西其实很便宜。不要从那些老师手上买,你直接电商平台找就好了。你只需要弄下来,然后组合一下让AI编一些话术开始卖就可以了。
至于为啥不告诉你如何用,因为部署只是这东西最简单的一个环节,你连这个都搞不定,已经说明了这东西不适合你,等更加图形化和直观的版本吧。
但不耽误你现在就开始卖课,真的,你想靠这东西赚钱,卖课是唯一实在的东西。
因为卖课没成本啊。你开店如果店是你自己的,那不也是纯赚嘛。
现在是赛博大挖矿,你要成为的是卖水的人。
更甚至,你真的懂技术,懂了这个东西,你是根本没法卖课的,因为你研究进去之后,你会发现这东西的局限性巨大,安全问题也大,你会不相信了,这会导致你的话术不够坚定。会让你给潜在顾客情绪价值的时候有自我怀疑,影响你卖课成绩。
所以,你要趁着你自己还相信这东西的时候,把你这股子相信的力量,传递出去,把冰冷的网课,变成温暖的余额。
这叫啥,这叫用AI原生AI话术赋能AI。
抓紧搞吧,卖课最重要的是快。快快快。
### 范文2:纯电五菱之光简直是大学里的魅魔
这是半佛仙人的第1984篇原创
1
周末不卷,随便写点。
过年回家的时候,我对大学和车这俩词都有了全新的认知。我一度以为,大学生眼里只有跑车比较帅,但过年的时候我侄子开着他新买的五菱之光EV,从后备箱拽出年货的时候,我陷入了困惑。
而当侄子告诉我现在他们学生都流行买纯电五菱,成为寝室义父的时候,我顿悟了。
大学生真正的神车,就是五菱之光。而且一定要纯电的,是绿光。
2
首先,它售价就很低。全款拿下四万多快,如果家里有旧车,置换补贴后购车成本直接变成两台果子。
你向父母要一台二手车,他们都会觉得你是要败家了。但你向他要一台五菱货车,他们会觉得欣慰,认为你是准备发家。
相当于别人用电脑开黑,你用电脑开店。
3
其次,它空间大。别的车都只能是车,但五菱EV,简直是空间折叠技术的雏形。后排座椅一拆,它就是哆啦A梦的裤兜。
有了它,你就不再只是一个学生,你甚至是一个拥有自主物流能力的平台。
白天,干山姆代购。晚上,干宿舍超市。周末,干深夜食堂。冬天,是校园百果园。夏天,是校园老冰棍。
4
所以,为什么说纯电五菱EV是神?因为它完美解决了大学生那种"钱少、事多、想法野"的生命状态。
解析微信公众号文章,提取标题、作者、正文内容、图片等信息。当用户发送微信公众号链接(mp.weixin.qq.com)并希望获取文章内容、摘要或保存时触发。支持自动提取内容并可选保存到飞书表格。
---
name: wechat-article-parser
description: 解析微信公众号文章,提取标题、作者、正文内容、图片等信息。当用户发送微信公众号链接(mp.weixin.qq.com)并希望获取文章内容、摘要或保存时触发。支持自动提取内容并可选保存到飞书表格。
---
# 微信公众号文章解析器
解析微信公众号文章,自动提取标题、作者、发布时间、正文内容等信息。
## 功能特性
- ✅ 自动提取文章标题、作者、发布时间
- ✅ 完整正文内容提取
- ✅ 图片链接提取
- ✅ 字数统计
- ✅ 支持保存为 JSON/TXT
- ✅ 可选保存到飞书表格
## 使用方法
### 基本用法:解析文章
```bash
python3 scripts/wechat_parser.py "https://mp.weixin.qq.com/s/xxxxx"
```
**输出示例:**
```
================================================================================
📰 标题: 文章标题
✍️ 作者: 公众号名称
🕐 发布时间: 2026-03-10
📊 字数: 3500
🖼️ 图片数: 5
================================================================================
📝 正文内容:
这是文章的正文内容...
================================================================================
```
### 保存到文件
```bash
# 保存为 JSON(包含全部信息)
python3 scripts/wechat_parser.py "URL" --save
# 指定输出文件名
python3 scripts/wechat_parser.py "URL" --save --output article.json
```
### 保存到飞书表格
```bash
python3 scripts/save_to_feishu.py "https://mp.weixin.qq.com/s/xxxxx"
# 手动指定标题
python3 scripts/save_to_feishu.py "https://mp.weixin.qq.com/s/xxxxx" "自定义标题"
```
### 在 OpenClaw 对话中使用
直接发送微信公众号链接,AI 会自动调用此 skill 解析内容:
```
https://mp.weixin.qq.com/s/xxxxx
```
或带指令:
```
解析这篇文章 https://mp.weixin.qq.com/s/xxxxx
```
```
收藏 https://mp.weixin.qq.com/s/xxxxx
```
## 输出格式
### JSON 格式
```json
{
"title": "文章标题",
"author": "公众号名称",
"publish_time": "2026-03-10",
"content": "正文内容...",
"word_count": 3500,
"images_count": 5,
"images": ["url1", "url2", ...],
"url": "原始链接",
"parsed_at": "2026-03-10 12:00:00"
}
```
## 飞书保存配置
如需使用飞书保存功能,需配置 `.env` 文件:
```env
FEISHU_APP_ID=your_app_id
FEISHU_APP_SECRET=your_app_secret
FEISHU_APP_TOKEN=your_bitable_app_token
FEISHU_TABLE_ID=your_table_id
```
## 支持的链接格式
- `https://mp.weixin.qq.com/s/xxxxx`
- `https://mp.weixin.qq.com/s?__biz=xxx&mid=xxx&idx=xxx`
- 微信短链接
## 常见问题
**Q: 提取内容不完整?**
A: 微信有反爬机制,部分文章可能提取不完整。建议:
1. 使用浏览器 Cookie(高级用法)
2. 手动复制重要段落
**Q: 图片无法显示?**
A: 微信图片有防盗链机制,需要带 Referer 头访问。
## 文件结构
```
wechat-article-parser/
├── SKILL.md # 本文档
├── scripts/
│ ├── wechat_parser.py # 核心解析脚本
│ └── save_to_feishu.py # 飞书保存脚本
├── .env.example # 配置模板
└── requirements.txt # 依赖
```
## 依赖
```
requests
beautifulsoup4
python-dotenv
```
安装:
```bash
pip3 install requests beautifulsoup4 python-dotenv
```
## 许可证
MIT License
FILE:_meta.json
{
"ownerId": "kn72j0qng9f6mm49z983cyzw1982naag",
"slug": "wechat-article-parser",
"version": "1.0.1",
"publishedAt": 1773136987785
}
FILE:requirements.txt
requests>=2.28.0
beautifulsoup4>=4.12.0
python-dotenv>=1.0.0
FILE:QUICKSTART.md
# ⚡ 快速开始
30 秒上手微信文章解析器。
## 1️⃣ 安装依赖
```bash
pip3 install requests beautifulsoup4
```
## 2️⃣ 解析文章
```bash
python3 scripts/wechat_parser.py "https://mp.weixin.qq.com/s/xxxxx"
```
**输出示例:**
```
================================================================================
📰 标题: AI 时代的一人公司
✍️ 作者: 某某公众号
🕐 发布时间: 2026-03-10
📊 字数: 3500
🖼️ 图片数: 5
================================================================================
📝 正文内容:
这是文章的正文内容...
================================================================================
```
## 3️⃣ 保存到文件
```bash
python3 scripts/wechat_parser.py "URL" --save
```
会生成 `文章标题.json` 文件。
---
## 🎉 完成!
就这么简单,无需任何配置。
---
## 📦 进阶:保存到飞书
如需保存到飞书表格,请参考 [README.md](README.md) 中的飞书配置部分。
FILE:README.md
# 微信公众号文章解析器
解析微信公众号文章,自动提取标题、作者、正文内容,支持保存到飞书多维表格。
## 📦 安装
### 步骤 1:安装依赖
```bash
pip3 install requests beautifulsoup4 python-dotenv
```
或使用 requirements.txt:
```bash
pip3 install -r requirements.txt
```
### 步骤 2:验证安装
```bash
python3 scripts/wechat_parser.py
```
应该显示使用说明,表示安装成功。
## 🚀 快速开始
### 基本用法:解析文章
```bash
python3 scripts/wechat_parser.py "https://mp.weixin.qq.com/s/xxxxx"
```
无需任何配置,开箱即用!
## ⚙️ 飞书保存功能配置(可选)
如需将文章保存到飞书多维表格,需要额外配置。
### 步骤 1:创建飞书应用
1. 访问 [飞书开放平台](https://open.feishu.cn)
2. 创建企业自建应用
3. 获取 `App ID` 和 `App Secret`
### 步骤 2:开通权限
在应用的「权限管理」中开通:
- `bitable:app:readonly` - 查看多维表格
- `bitable:app` - 读写多维表格
### 步骤 3:创建多维表格
创建一个飞书多维表格,包含以下字段:
| 字段名 | 类型 | 说明 |
|--------|------|------|
| 文本 | 文本 | 文章标题 |
| 链接 | 超链接 | 文章 URL |
| 来源 | 单选 | 平台来源 |
| 保存时间 | 日期时间 | 保存时间 |
| 摘要 | 多行文本 | 摘要+正文 |
**单选「来源」的选项:**
- 微信公众号
- 知乎
- 今日头条
- 小红书
- B站
- 抖音
- 其他
### 步骤 4:获取表格信息
从表格 URL 中获取:
```
https://xxx.feishu.cn/base/APP_TOKEN?table=TABLE_ID
↑ ↑
APP_TOKEN TABLE_ID
```
### 步骤 5:配置环境变量
复制配置模板:
```bash
cp .env.example .env
```
编辑 `.env` 文件:
```env
FEISHU_APP_ID=cli_xxxxxxxxx
FEISHU_APP_SECRET=xxxxxxxxx
FEISHU_APP_TOKEN=xxxxxxxxx
FEISHU_TABLE_ID=tblxxxxxxxxx
```
### 步骤 6:验证配置
```bash
python3 scripts/save_to_feishu.py "https://mp.weixin.qq.com/s/xxxxx"
```
## 📖 使用指南
### 解析文章(无需配置)
```bash
# 基本解析
python3 scripts/wechat_parser.py "URL"
# 解析并保存到文件
python3 scripts/wechat_parser.py "URL" --save
# 指定输出文件名
python3 scripts/wechat_parser.py "URL" --save --output article.json
```
### 保存到飞书(需配置)
```bash
# 自动提取标题
python3 scripts/save_to_feishu.py "URL"
# 手动指定标题
python3 scripts/save_to_feishu.py "URL" "自定义标题"
```
## 📁 文件结构
```
wechat-article-parser/
├── README.md # 本文档(安装说明)
├── SKILL.md # Skill 描述文件
├── QUICKSTART.md # 快速开始指南
├── requirements.txt # Python 依赖
├── .env.example # 配置模板
├── .gitignore # Git 忽略文件
└── scripts/
├── wechat_parser.py # 核心解析脚本
└── save_to_feishu.py # 飞书保存脚本
```
## ❓ 常见问题
### Q1: 提取的内容不完整?
微信有反爬机制,部分文章可能提取不完整。建议:
- 多尝试几次
- 手动复制重要段落
### Q2: 飞书保存失败?
检查:
1. `.env` 配置是否正确
2. 飞书应用权限是否开通
3. 表格字段名是否匹配
### Q3: 表格字段名和脚本不一致?
修改 `scripts/save_to_feishu.py` 中的字段名映射:
```python
fields = {
"你的标题字段名": title,
"你的链接字段名": {"link": url},
...
}
```
### Q4: 如何批量处理多个链接?
创建链接列表文件 `links.txt`,每行一个 URL:
```bash
while read url; do
python3 scripts/wechat_parser.py "$url" --save
sleep 2 # 避免请求过快
done < links.txt
```
## 📄 许可证
MIT License
## 🔗 相关链接
- [飞书开放平台](https://open.feishu.cn)
- [飞书多维表格 API](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/bitable-v1/app-table-record/create)
FILE:scripts/wechat_parser.py
#!/usr/bin/env python3
"""
微信公众号文章解析器
解析 mp.weixin.qq.com 文章内容并提取主要信息
"""
import requests
from bs4 import BeautifulSoup
import re
import json
from datetime import datetime
def parse_wechat_article(url):
"""
解析微信公众号文章
Args:
url: 公众号文章链接
Returns:
dict: 包含标题、作者、发布时间、内容等信息
"""
headers = {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
try:
response = requests.get(url, headers=headers, timeout=30)
response.encoding = 'utf-8'
soup = BeautifulSoup(response.text, 'html.parser')
# 提取标题
title_tag = soup.find('h1', class_='rich_media_title')
title = title_tag.get_text(strip=True) if title_tag else "未找到标题"
# 提取作者
author_tag = soup.find('a', class_='rich_media_meta rich_media_meta_link rich_media_meta_nickname')
if not author_tag:
author_tag = soup.find('span', class_='rich_media_meta rich_media_meta_text')
author = author_tag.get_text(strip=True) if author_tag else "未知作者"
# 提取发布时间
time_tag = soup.find('span', class_='rich_media_meta rich_media_meta_text')
if time_tag and time_tag.get('id') == 'publish_time':
publish_time = time_tag.get_text(strip=True)
else:
# 尝试从脚本中提取
time_match = re.search(r'var\s+publish_time\s*=\s*"([^"]+)"', response.text)
publish_time = time_match.group(1) if time_match else "未知时间"
# 提取正文内容
content_div = soup.find('div', class_='rich_media_content')
if content_div:
# 移除所有脚本和样式
for script in content_div(['script', 'style']):
script.decompose()
# 提取文本,保留段落结构
paragraphs = []
for p in content_div.find_all(['p', 'section']):
text = p.get_text(strip=True)
if text and len(text) > 10: # 过滤掉太短的段落
paragraphs.append(text)
content = '\n\n'.join(paragraphs)
else:
content = "未找到正文内容"
# 提取图片
images = []
if content_div:
for img in content_div.find_all('img'):
img_url = img.get('data-src') or img.get('src')
if img_url:
images.append(img_url)
result = {
'title': title,
'author': author,
'publish_time': publish_time,
'content': content,
'word_count': len(content),
'images_count': len(images),
'images': images[:5], # 只保留前5张图片URL
'url': url,
'parsed_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
return result
except Exception as e:
return {
'error': str(e),
'url': url
}
def print_article(article):
"""格式化打印文章信息"""
if 'error' in article:
print(f"❌ 解析失败: {article['error']}")
return
print("=" * 80)
print(f"📰 标题: {article['title']}")
print(f"✍️ 作者: {article['author']}")
print(f"🕐 发布时间: {article['publish_time']}")
print(f"📊 字数: {article['word_count']}")
print(f"🖼️ 图片数: {article['images_count']}")
print("=" * 80)
print("\n📝 正文内容:\n")
print(article['content'][:1000] + "..." if len(article['content']) > 1000 else article['content'])
print("\n" + "=" * 80)
def save_to_file(article, filename=None):
"""保存文章到文件"""
if 'error' in article:
print(f"❌ 无法保存,解析失败: {article['error']}")
return
if not filename:
# 使用标题作为文件名
safe_title = re.sub(r'[^\w\s-]', '', article['title']).strip()[:50]
filename = f"{safe_title}.json"
with open(filename, 'w', encoding='utf-8') as f:
json.dump(article, f, ensure_ascii=False, indent=2)
print(f"✅ 已保存到: {filename}")
if __name__ == '__main__':
import sys
if len(sys.argv) < 2:
print("用法: python3 wechat_parser.py <公众号文章URL> [--save] [--output 文件名]")
print("示例: python3 wechat_parser.py 'https://mp.weixin.qq.com/s/xxx' --save")
sys.exit(1)
url = sys.argv[1]
save_flag = '--save' in sys.argv
output_file = None
if '--output' in sys.argv:
idx = sys.argv.index('--output')
if idx + 1 < len(sys.argv):
output_file = sys.argv[idx + 1]
print("🔄 正在解析文章...")
article = parse_wechat_article(url)
print_article(article)
if save_flag:
save_to_file(article, output_file)
FILE:scripts/save_to_feishu.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
飞书文章收藏助手 - 增强版
自动提取文章内容并保存到飞书多维表格
"""
import json
import requests
import re
from datetime import datetime
from urllib.parse import urlparse
import subprocess
# 飞书应用配置(从环境变量读取)
import os
from dotenv import load_dotenv
# 加载 .env 文件
load_dotenv()
APP_ID = os.getenv("FEISHU_APP_ID", "")
APP_SECRET = os.getenv("FEISHU_APP_SECRET", "")
APP_TOKEN = os.getenv("FEISHU_APP_TOKEN", "")
TABLE_ID = os.getenv("FEISHU_TABLE_ID", "")
def get_tenant_access_token():
"""获取 tenant_access_token"""
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
headers = {"Content-Type": "application/json"}
data = {
"app_id": APP_ID,
"app_secret": APP_SECRET
}
response = requests.post(url, headers=headers, json=data)
result = response.json()
if result.get("code") == 0:
return result.get("tenant_access_token")
else:
raise Exception(f"获取 token 失败: {result}")
def detect_source(url):
"""自动识别链接来源"""
domain = urlparse(url).netloc.lower()
if "zhihu.com" in domain:
return "知乎"
elif "mp.weixin.qq.com" in domain or "weixin.qq.com" in domain:
return "微信公众号"
elif "toutiao.com" in domain or "jinri" in domain:
return "今日头条"
elif "xiaohongshu.com" in domain or "xhs" in domain:
return "小红书"
elif "bilibili.com" in domain or "b23.tv" in domain:
return "B站"
elif "douyin.com" in domain:
return "抖音"
else:
return "其他"
def extract_article_content(url):
"""
使用 OpenClaw 的 web_fetch 功能提取文章内容
返回: (title, content, summary)
"""
try:
# 调用 openclaw CLI 的 web_fetch 功能
# 使用 markdown 模式获取内容
cmd = [
"openclaw", "web-fetch",
"--url", url,
"--mode", "markdown",
"--max-chars", "5000"
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
content = result.stdout
# 提取标题(通常在第一行)
lines = content.split('\n')
title = lines[0].strip('#').strip() if lines else url
# 生成摘要(取前300字)
content_text = '\n'.join(lines[1:]).strip()
summary = content_text[:300] + "..." if len(content_text) > 300 else content_text
print(f"✅ 内容提取成功")
print(f" 标题: {title}")
print(f" 正文长度: {len(content_text)} 字符")
return title, content_text, summary
else:
print(f"⚠️ web_fetch 失败,尝试备用方法...")
return extract_with_requests(url)
except Exception as e:
print(f"⚠️ 提取失败: {e}")
return extract_with_requests(url)
def extract_with_requests(url):
"""备用方法:使用 requests 直接获取"""
try:
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
response = requests.get(url, headers=headers, timeout=15)
response.encoding = response.apparent_encoding
html = response.text
# 提取 title - 多种方法
title = None
# 方法1: 标准 title 标签
title_match = re.search(r'<title>(.*?)</title>', html, re.IGNORECASE)
if title_match:
title = title_match.group(1).strip()
# 方法2: 微信公众号专用 - rich_media_title
if not title or len(title) < 3:
wechat_title = re.search(r'<h1[^>]*class="rich_media_title"[^>]*>(.*?)</h1>', html, re.IGNORECASE | re.DOTALL)
if wechat_title:
title = wechat_title.group(1).strip()
title = re.sub(r'<[^>]+>', '', title).strip()
# 方法3: meta 标签
if not title or len(title) < 3:
og_title = re.search(r'<meta[^>]*property="og:title"[^>]*content="([^"]*)"', html, re.IGNORECASE)
if og_title:
title = og_title.group(1).strip()
# 方法4: 微信 msg_title
if not title or len(title) < 3:
msg_title = re.search(r'var\s+msg_title\s*=\s*["\']([^"\']+)["\']', html)
if msg_title:
title = msg_title.group(1).strip()
# 清理标题
if title:
title = re.sub(r'\s*[-_|]\s*(知乎|微信公众号|今日头条).*$', '', title)
title = title.strip()
# 如果还是没有标题,用 URL
if not title or len(title) < 2:
title = None # 返回 None 让后续处理
# 简单提取正文(移除 HTML 标签)
content = re.sub(r'<script.*?</script>', '', html, flags=re.DOTALL)
content = re.sub(r'<style.*?</style>', '', content, flags=re.DOTALL)
content = re.sub(r'<[^>]+>', '', content)
content = re.sub(r'\s+', ' ', content).strip()
summary = content[:300] + "..." if len(content) > 300 else content
return title, content, summary
except Exception as e:
print(f"❌ 备用方法也失败: {e}")
return None, "", "无法提取内容"
def get_existing_fields(token):
"""获取表格已有的字段列表"""
api_url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{APP_TOKEN}/tables/{TABLE_ID}/fields"
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(api_url, headers=headers)
result = response.json()
if result.get("code") == 0:
items = result.get("data", {}).get("items", [])
return {item["field_name"]: item for item in items}
else:
print(f" ⚠️ 获取字段列表失败: {result}")
return {}
def ensure_fields_exist(token):
"""检查并自动创建缺失的字段"""
print("\n🔧 检查表格字段...")
# 期望的字段定义:field_name -> (type, 说明)
# 飞书字段类型: 1=文本, 2=数字, 3=单选, 5=日期, 7=复选框, 11=人员, 15=超链接, 13=电话, 17=附件, 18=关联, 20=公式, 22=地理位置
required_fields = {
"文本": {"field_name": "文本", "type": 1}, # 文本
"链接": {"field_name": "链接", "type": 15}, # 超链接
"来源": {"field_name": "来源", "type": 3}, # 单选
"保存时间": {"field_name": "保存时间", "type": 5}, # 日期
"摘要": {"field_name": "摘要", "type": 1}, # 文本
"正文": {"field_name": "正文", "type": 1}, # 文本
}
existing = get_existing_fields(token)
api_url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{APP_TOKEN}/tables/{TABLE_ID}/fields"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
created_count = 0
for field_name, field_def in required_fields.items():
if field_name not in existing:
response = requests.post(api_url, headers=headers, json=field_def)
result = response.json()
if result.get("code") == 0:
created_count += 1
print(f" ✅ 创建字段: {field_name}")
else:
print(f" ❌ 创建字段失败 [{field_name}]: {result.get('msg', '')}")
else:
print(f" ✔️ 字段已存在: {field_name}")
if created_count > 0:
print(f" 📋 共创建了 {created_count} 个新字段")
else:
print(f" 📋 所有字段已就绪")
def clean_empty_rows(token):
"""清理表格中的空白行"""
print("\n🧹 检查并清理空白行...")
api_url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{APP_TOKEN}/tables/{TABLE_ID}/records"
headers = {"Authorization": f"Bearer {token}"}
try:
# 分页获取所有记录
empty_ids = []
page_token = None
while True:
params = {"page_size": 100}
if page_token:
params["page_token"] = page_token
response = requests.get(api_url, headers=headers, params=params)
result = response.json()
if result.get("code") != 0:
print(f" ⚠️ 获取记录失败,跳过清理")
return
records = result.get("data", {}).get("items", [])
for record in records:
record_id = record.get("record_id")
fields = record.get("fields", {})
# 检查是否为空白行(标题和链接都为空)
title_field = fields.get("文本", "")
link_field = fields.get("链接", {})
if isinstance(link_field, dict):
link_text = link_field.get("link", "")
else:
link_text = str(link_field)
is_empty = (
(not title_field or len(str(title_field).strip()) < 2) and
(not link_text or len(link_text.strip()) < 5)
)
if is_empty:
empty_ids.append(record_id)
# 检查是否有下一页
if not result.get("data", {}).get("has_more", False):
break
page_token = result.get("data", {}).get("page_token")
# 批量删除空白行
if empty_ids:
delete_url = f"{api_url}/batch_delete"
delete_headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
# 飞书批量删除最多500条
for i in range(0, len(empty_ids), 500):
batch = empty_ids[i:i+500]
response = requests.post(delete_url, headers=delete_headers, json={"records": batch})
delete_result = response.json()
if delete_result.get("code") == 0:
print(f" 🗑️ 批量删除 {len(batch)} 个空白行")
else:
print(f" ⚠️ 批量删除失败: {delete_result.get('msg', '')}")
print(f" ✅ 清理完成,共删除 {len(empty_ids)} 个空白行")
else:
print(f" ✅ 没有空白行,表格整洁")
except Exception as e:
print(f" ⚠️ 清理过程出错: {e}")
def save_article_to_feishu(url, title=None, content=None, summary=None):
"""保存文章到飞书多维表格"""
print(f"\n📖 开始处理文章...")
print(f" 链接: {url}")
# 如果没有提供内容,自动提取
if not content:
auto_title, auto_content, auto_summary = extract_article_content(url)
title = title or auto_title
content = auto_content
summary = summary or auto_summary
# 检查标题是否为空
if not title or len(title.strip()) < 2:
print(f"\n⚠️ 警告:无法自动提取标题!")
print(f" 建议:手动指定标题")
print(f" 用法:python feishu_article_saver.py \"{url}\" \"文章标题\"")
return False
# 获取访问令牌
token = get_tenant_access_token()
# 自动检查并创建缺失字段
ensure_fields_exist(token)
# 清理空白行
clean_empty_rows(token)
# 自动识别来源
source = detect_source(url)
# 准备数据
current_time = int(datetime.now().timestamp() * 1000)
fields = {
"文本": title,
"链接": {
"link": url
},
"来源": source,
"保存时间": current_time
}
# 摘要和正文分开保存
if summary:
fields["摘要"] = summary
if content:
fields["正文"] = content
# 发送请求
api_url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{APP_TOKEN}/tables/{TABLE_ID}/records"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
data = {
"fields": fields
}
response = requests.post(api_url, headers=headers, json=data)
result = response.json()
if result.get("code") == 0:
print(f"\n✅ 保存成功!")
print(f" 📝 标题: {title}")
print(f" 🏷️ 来源: {source}")
print(f" 📊 摘要: {len(summary) if summary else 0} 字符")
print(f" 📄 正文: {len(content) if content else 0} 字符")
return True
else:
print(f"\n❌ 保存失败: {result}")
return False
def main():
"""主函数"""
import sys
if len(sys.argv) < 2:
print("用法: python feishu_article_saver.py <URL> [标题]")
print("\n示例:")
print(" python feishu_article_saver.py 'https://mp.weixin.qq.com/s/xxxxx'")
print(" python feishu_article_saver.py 'https://zhuanlan.zhihu.com/p/123' '自定义标题'")
return
url = sys.argv[1]
title = sys.argv[2] if len(sys.argv) > 2 else None
save_article_to_feishu(url, title)
if __name__ == "__main__":
main()
使用科大讯飞 API 将音频/视频转换为文字。支持本地音频文件转录、YouTube 视频下载并转文字。适用于会议记录、视频字幕、语音笔记等场景。当用户需要语音转文字、音频转录、YouTube 视频转文字时触发。
---
name: iflytek-asr
description: 使用科大讯飞 API 将音频/视频转换为文字。支持本地音频文件转录、YouTube 视频下载并转文字。适用于会议记录、视频字幕、语音笔记等场景。当用户需要语音转文字、音频转录、YouTube 视频转文字时触发。
license: MIT
acceptLicenseTerms: yes
---
# 讯飞语音转文字 (iFlytek ASR)
使用科大讯飞语音识别 API 将音频文件转换为文本,支持中文方言识别。
## 功能特性
- ✅ 支持多种音频格式:mp3, wav, pcm, mp4, m4a, aac, ogg, flac, speex, opus, wma
- ✅ 支持 YouTube 视频下载并转文本
- ✅ 文件大小限制:≤500MB
- ✅ 时长限制:≤5小时
- ✅ 自动识别中文方言
- ✅ 自动添加标点符号
## 前置要求
### 1. 获取讯飞 API 凭证
1. 访问 [科大讯飞开放平台](https://www.xfyun.cn)
2. 注册/登录账号
3. 创建应用,选择「语音转写」服务
4. 获取凭证:
- `XFYUN_APP_ID`
- `XFYUN_ACCESS_KEY_ID`
- `XFYUN_ACCESS_KEY_SECRET`
### 2. 配置环境变量
在 skill 目录下创建 `.env` 文件:
```env
XFYUN_APP_ID=your_app_id
XFYUN_ACCESS_KEY_ID=your_access_key_id
XFYUN_ACCESS_KEY_SECRET=your_access_key_secret
```
### 3. 安装依赖
```bash
pip3 install yt-dlp requests python-dotenv
```
## 使用方法
### 转录本地音频
```bash
python3 scripts/speech_to_text.py <音频文件路径> [输出文本路径]
```
示例:
```bash
python3 scripts/speech_to_text.py meeting.mp3
python3 scripts/speech_to_text.py recording.wav output.txt
```
### YouTube 视频转文字
```bash
python3 scripts/download_and_transcribe.py "YOUTUBE_URL" [保存目录]
```
示例:
```bash
python3 scripts/download_and_transcribe.py "https://www.youtube.com/watch?v=VIDEO_ID" ~/Downloads
```
### 仅下载 YouTube 音频
```bash
python3 scripts/download_audio.py "YOUTUBE_URL" [保存目录]
```
## 对比:讯飞 vs Whisper
| 特性 | 讯飞 ASR | Whisper |
|------|---------|---------|
| 成本 | API 配额(有免费额度) | 免费 |
| 离线 | ❌ 需要网络 | ✅ 本地运行 |
| 速度 | ⭐⭐⭐⭐⭐ 快 | ⭐⭐⭐ 较慢 |
| 中文准确率 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 标点符号 | ✅ 自动添加 | ❌ 无 |
| 方言支持 | ✅ 支持 | ⭐⭐ 一般 |
**建议:**
- 重要会议/采访 → 讯飞(准确率高、有标点)
- 日常语音消息 → Whisper(免费、无限制)
## API 限制
讯飞免费版:
- 每日调用次数:500 次
- 单次文件大小:≤500MB
- 单次时长:≤5小时
## 文件结构
```
iflytek-asr/
├── SKILL.md # 本文档
├── README.md # 详细说明
├── QUICKSTART.md # 快速开始
├── .env.example # 配置模板
├── requirements.txt # Python 依赖
└── scripts/
├── speech_to_text.py # 音频转文字
├── download_audio.py # YouTube 下载
└── download_and_transcribe.py # 下载+转文字
```
## 常见问题
**Q: 转录失败怎么办?**
- 检查 API 凭证是否正确
- 确认文件格式支持
- 检查网络连接
**Q: 如何提高准确率?**
- 确保音频清晰
- 选择正确的语言/方言
- 避免背景噪音
## 许可证
MIT License
FILE:requirements.txt
yt-dlp>=2024.1.0
requests>=2.31.0
python-dotenv>=1.0.0
FILE:install.sh
#!/bin/bash
echo "🚀 讯飞语音转文本 Skill 安装脚本"
echo ""
# 检查 Python
if ! command -v python3 &> /dev/null; then
echo "❌ 错误:未找到 Python 3"
echo " 请先安装 Python 3: https://www.python.org/downloads/"
exit 1
fi
echo "✅ Python 3 已安装: $(python3 --version)"
# 安装依赖
echo ""
echo "📦 安装 Python 依赖..."
pip3 install -r requirements.txt
if [ $? -ne 0 ]; then
echo "❌ 依赖安装失败"
exit 1
fi
echo "✅ 依赖安装完成"
# 配置凭证
echo ""
if [ ! -f .env ]; then
echo "📝 创建凭证配置文件..."
cp .env.example .env
echo "✅ 已创建 .env 文件"
echo ""
echo "⚠️ 下一步:编辑 .env 文件并填入你的讯飞 API 凭证"
echo " 1. 访问 https://www.xfyun.cn 获取凭证"
echo " 2. 编辑 .env 文件: nano .env"
echo " 3. 运行测试: python3 scripts/speech_to_text.py --help"
else
echo "✅ .env 文件已存在"
fi
echo ""
echo "🎉 安装完成!"
echo ""
echo "快速开始:"
echo " 查看文档: cat QUICKSTART.md"
echo " 测试转录: python3 scripts/speech_to_text.py test.mp3"
FILE:QUICKSTART.md
# 🚀 快速开始
## 5分钟上手讯飞语音转文本
### 步骤 1:安装依赖 (1分钟)
```bash
pip3 install yt-dlp requests python-dotenv
```
### 步骤 2:获取讯飞凭证 (2分钟)
1. 打开 https://www.xfyun.cn
2. 注册/登录
3. 进入控制台 → 创建应用
4. 选择"语音识别"服务
5. 复制三个凭证:
- APP ID
- Access Key ID
- Access Key Secret
### 步骤 3:配置凭证 (1分钟)
```bash
# 复制模板文件
cp .env.example .env
# 编辑 .env 文件,粘贴你的凭证
nano .env # 或用任何文本编辑器
```
`.env` 文件示例:
```env
XFYUN_APP_ID=12345678
XFYUN_ACCESS_KEY_ID=AbCdEfGhIjKlMnOp
XFYUN_ACCESS_KEY_SECRET=QrStUvWxYz123456789
```
### 步骤 4:测试运行 (1分钟)
```bash
# 测试:转录一个本地音频文件
python3 scripts/speech_to_text.py test.mp3
# 或者:从 YouTube 下载并转录
python3 scripts/download_and_transcribe.py "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
```
---
## ✅ 完成!
你现在可以:
- 📄 转录任何音频文件为文本
- 🎥 从 YouTube 下载音频并自动转录
- 🌐 处理中文各种方言
## 下一步
查看 [README.md](README.md) 了解:
- 所有功能详解
- 高级用法
- 常见问题解决
---
**遇到问题?** 检查:
1. ✅ 凭证是否正确配置在 `.env`
2. ✅ 依赖是否安装完整
3. ✅ 网络连接是否正常
FILE:README.md
# 讯飞语音转文本 Skill
使用科大讯飞 API 将音频文件转换为文本,支持中文方言识别。
## 功能特性
- ✅ 支持多种音频格式:mp3, wav, pcm, mp4, m4a, aac, ogg, flac, speex, opus, wma
- ✅ 支持 YouTube 视频下载并转文本
- ✅ 文件大小限制:≤500MB
- ✅ 时长限制:≤5小时
- ✅ 自动识别中文方言
## 安装步骤
### 1. 安装依赖
```bash
pip3 install yt-dlp requests python-dotenv
```
### 2. 获取讯飞 API 凭证
1. 访问 [科大讯飞开放平台](https://www.xfyun.cn)
2. 注册/登录账号
3. 创建应用,选择"语音识别"服务
4. 获取以下凭证:
- `XFYUN_APP_ID`
- `XFYUN_ACCESS_KEY_ID`
- `XFYUN_ACCESS_KEY_SECRET`
### 3. 配置凭证
复制 `.env.example` 为 `.env` 并填入你的凭证:
```bash
cp .env.example .env
# 然后编辑 .env 文件,填入你的凭证
```
`.env` 文件内容示例:
```env
XFYUN_APP_ID=your_app_id_here
XFYUN_ACCESS_KEY_ID=your_access_key_id_here
XFYUN_ACCESS_KEY_SECRET=your_access_key_secret_here
```
⚠️ **注意**:`.env` 文件包含敏感信息,不要提交到 Git 仓库!
## 使用方法
### 方式 1:转录本地音频文件
```bash
python3 scripts/speech_to_text.py <音频文件路径> [输出文本路径]
```
**示例:**
```bash
# 转录 MP3 文件,输出同名 .txt
python3 scripts/speech_to_text.py recording.mp3
# 指定输出文件
python3 scripts/speech_to_text.py meeting.wav transcript.txt
```
### 方式 2:YouTube 下载音频
```bash
python3 scripts/download_audio.py "YOUTUBE_URL" [保存目录]
```
**示例:**
```bash
# 下载到当前目录
python3 scripts/download_audio.py "https://www.youtube.com/watch?v=VIDEO_ID"
# 下载到指定目录
python3 scripts/download_audio.py "https://www.youtube.com/watch?v=VIDEO_ID" ~/Downloads
```
### 方式 3:YouTube 下载 + 自动转文本(推荐)
```bash
python3 scripts/download_and_transcribe.py "YOUTUBE_URL" [保存目录]
```
**示例:**
```bash
python3 scripts/download_and_transcribe.py "https://www.youtube.com/watch?v=VIDEO_ID" ~/Downloads
```
输出文件:
- `VIDEO_ID.mp3` - 音频文件
- `VIDEO_ID.txt` - 转录文本
## 支持的 YouTube 链接格式
- `https://www.youtube.com/watch?v=VIDEO_ID`
- `https://youtu.be/VIDEO_ID`
- `https://youtube.com/embed/VIDEO_ID`
- 纯视频 ID (11个字符)
## 常见问题
### Q: 转录失败怎么办?
**检查清单:**
1. ✅ 讯飞 API 凭证配置正确
2. ✅ 音频文件格式支持
3. ✅ 文件大小 ≤500MB
4. ✅ 时长 ≤5小时
5. ✅ 网络连接正常
### Q: YouTube 下载失败 (403 Forbidden)
**可能原因:**
- 地区限制
- YouTube 限速
- 视频已删除/私密
**解决方法:**
- 使用代理/VPN
- 稍后重试
### Q: 转录速度如何?
通常比实时播放快,具体取决于:
- 音频时长
- 网络速度
- API 服务器负载
## API 使用限制
讯飞免费版限制:
- 每日调用次数:500次
- 单次文件大小:≤500MB
- 时长:≤5小时
**需要更多配额?** 访问 [讯飞控制台](https://console.xfyun.cn) 升级套餐。
## 目录结构
```
iflytek-asr-skill/
├── README.md # 本文档
├── .env.example # 凭证模板
├── .env # 你的凭证(不要提交到Git)
└── scripts/
├── speech_to_text.py # 音频转文本
├── download_audio.py # YouTube下载音频
└── download_and_transcribe.py # YouTube下载+转文本
```
## 安全提醒
⚠️ **敏感信息保护**
- `.env` 文件包含你的 API 凭证
- 不要分享或提交到公开仓库
- 建议在 `.gitignore` 中添加 `.env`
## 许可证
MIT License
## 支持与反馈
有问题?欢迎提交 Issue 或 PR!
FILE:package.sh
#!/bin/bash
echo "📦 打包讯飞语音转文本 Skill"
echo ""
# 设置输出文件名
OUTPUT_NAME="iflytek-asr-skill-$(date +%Y%m%d).zip"
# 检查是否有 .env 文件(警告用户)
if [ -f .env ]; then
echo "⚠️ 警告:检测到 .env 文件!"
echo " .env 包含你的 API 凭证,不应该被分发。"
echo ""
read -p "确定要继续打包吗?(y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "❌ 已取消打包"
exit 1
fi
fi
# 打包(排除敏感文件和输出文件)
echo "📦 打包中..."
cd ..
zip -r "$OUTPUT_NAME" iflytek-asr-skill-template/ \
-x "*.pyc" \
-x "**/__pycache__/*" \
-x "*.env" \
-x "*.mp3" \
-x "*.wav" \
-x "*.txt" \
-x "*.DS_Store" \
-x "**/outputs/*" \
-x "**/downloads/*"
if [ $? -eq 0 ]; then
echo "✅ 打包完成!"
echo ""
echo "📦 输出文件: $OUTPUT_NAME"
echo "📂 文件大小: $(ls -lh "$OUTPUT_NAME" | awk '{print $5}')"
echo ""
echo "✅ 可以分享这个文件了!"
echo ""
echo "接收者使用方法:"
echo " 1. unzip $OUTPUT_NAME"
echo " 2. cd iflytek-asr-skill-template"
echo " 3. ./install.sh"
else
echo "❌ 打包失败"
exit 1
fi
FILE:scripts/download_audio_simple.py
#!/usr/bin/env python3
"""
使用简化方法下载 YouTube 音频(不需要 ffmpeg)
用法:
python3 download_audio_simple.py <youtube_url> [output_dir]
"""
import sys
import os
import subprocess
import re
from pathlib import Path
def extract_video_id(url):
"""Extract video ID from YouTube URL."""
patterns = [
r'(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/|youtube\.com/v/)([a-zA-Z0-9_-]{11})',
r'^([a-zA-Z0-9_-]{11})$'
]
for pattern in patterns:
match = re.search(pattern, url)
if match:
return match.group(1)
raise ValueError(f"Could not extract video ID from: {url}")
def download_audio_simple(youtube_url, output_path=None):
"""
使用简化方法下载音频(仅下载最佳音频流,不转换格式)
Args:
youtube_url: YouTube video URL or video ID
output_path: Optional output directory
Returns:
Path to downloaded audio file
"""
# Find yt-dlp executable
ytdlp_paths = [
'yt-dlp',
str(Path.home() / 'Library' / 'Python' / '3.9' / 'bin' / 'yt-dlp'),
'/usr/local/bin/yt-dlp',
'/opt/homebrew/bin/yt-dlp',
]
ytdlp_cmd = None
for path in ytdlp_paths:
try:
subprocess.run([path, '--version'],
capture_output=True,
check=True)
ytdlp_cmd = path
break
except (subprocess.CalledProcessError, FileNotFoundError):
continue
if ytdlp_cmd is None:
print("Error: yt-dlp is not installed.", file=sys.stderr)
print("Install with: pip3 install yt-dlp", file=sys.stderr)
sys.exit(1)
# Extract video ID
try:
video_id = extract_video_id(youtube_url)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Set output path
if output_path:
output_dir = Path(output_path)
output_dir.mkdir(parents=True, exist_ok=True)
else:
output_dir = Path.cwd()
output_template = str(output_dir / f"{video_id}.%(ext)s")
# 方法 1: 尝试直接下载音频流(webm/m4a),不需要 ffmpeg
print(f"📥 下载音频: {youtube_url}")
print(f"📂 输出目录: {output_dir}")
print("🔧 方法: 直接下载音频流(不转换格式)")
cmd = [
ytdlp_cmd,
'-f', 'bestaudio/best', # 下载最佳音频流,或最佳视频流
'--extract-audio', # 提取音频
'-o', output_template,
'--no-check-certificates',
'--extractor-args', 'youtube:player_client=android,web',
'--user-agent', 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36',
'--no-warnings',
youtube_url
]
try:
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
print(result.stdout)
# 查找下载的文件(可能是 .webm 或 .m4a)
for ext in ['.webm', '.m4a', '.opus', '.mp4']:
audio_file = output_dir / f"{video_id}{ext}"
if audio_file.exists():
print(f"\n✅ 音频下载成功: {audio_file}")
print(f"💡 提示: 文件格式为 {ext},可直接用于语音转文字")
return str(audio_file)
print(f"Error: 未找到下载的文件", file=sys.stderr)
sys.exit(1)
except subprocess.CalledProcessError as e:
print(f"Error: 下载失败", file=sys.stderr)
print(e.stderr, file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("用法: python3 download_audio_simple.py <youtube_url> [output_dir]")
print()
print("示例:")
print(" python3 download_audio_simple.py https://www.youtube.com/watch?v=VIDEO_ID")
print(" python3 download_audio_simple.py https://www.youtube.com/watch?v=VIDEO_ID ~/Downloads/xufei")
print()
print("说明:")
print(" - 直接下载音频流(webm/m4a 格式)")
print(" - 不需要 ffmpeg")
print(" - 下载的文件可直接用于语音转文字")
sys.exit(1)
youtube_url = sys.argv[1]
output_path = sys.argv[2] if len(sys.argv) > 2 else None
download_audio_simple(youtube_url, output_path)
if __name__ == '__main__':
main()
FILE:scripts/speech_to_text.py
"""
科大讯飞 录音文件转写大模型 API (语音文件 -> 文字)
文档: https://www.xfyun.cn/doc/spark/asr_llm/Ifasr_llm.html
支持格式: mp3/wav/pcm/mp4/m4a/aac/ogg/flac/speex/opus/wma
文件大小: ≤500MB
音频时长: ≤5小时
"""
import os
import sys
import time
import hmac
import hashlib
import base64
import json
import string
import random
import wave
import subprocess
import urllib.parse
from datetime import datetime, timezone, timedelta
from pathlib import Path
import requests
from dotenv import load_dotenv
# 加载 .env 配置
# 尝试从 skill 根目录加载 .env
skill_root = Path(__file__).parent.parent
if (skill_root / ".env").exists():
load_dotenv(skill_root / ".env")
else:
# 兼容旧位置
load_dotenv(Path(__file__).parent / ".env")
APP_ID = os.getenv("XFYUN_APP_ID")
ACCESS_KEY_ID = os.getenv("XFYUN_ACCESS_KEY_ID")
ACCESS_KEY_SECRET = os.getenv("XFYUN_ACCESS_KEY_SECRET")
BASE_URL = "https://office-api-ist-dx.iflyaisol.com"
URL_UPLOAD = f"{BASE_URL}/v2/upload"
URL_GET_RESULT = f"{BASE_URL}/v2/getResult"
def generate_random_string(length=16):
"""生成指定长度的随机字符串"""
chars = string.ascii_letters + string.digits
return "".join(random.choice(chars) for _ in range(length))
def get_datetime():
"""获取格式化的时间字符串 yyyy-MM-dd'T'HH:mm:ss+0800"""
tz = timezone(timedelta(hours=8))
now = datetime.now(tz)
return now.strftime("%Y-%m-%dT%H:%M:%S+0800")
def generate_signature(params):
"""
生成签名:
1. 排除 signature 字段,按参数名自然排序
2. URL编码后拼接 key=value&...
3. HMAC-SHA1 + Base64
"""
# 过滤掉 signature,按 key 排序
sorted_params = sorted(
[(k, v) for k, v in params.items() if k != "signature" and v is not None and v != ""],
key=lambda x: x[0],
)
# URL编码并拼接
parts = []
for k, v in sorted_params:
encoded_key = urllib.parse.quote(str(k), safe="")
encoded_value = urllib.parse.quote(str(v), safe="")
parts.append(f"{encoded_key}={encoded_value}")
base_string = "&".join(parts)
# HMAC-SHA1 签名
signature = hmac.new(
ACCESS_KEY_SECRET.encode("utf-8"),
base_string.encode("utf-8"),
hashlib.sha1,
).digest()
return base64.b64encode(signature).decode("utf-8")
def get_audio_duration_ms(file_path):
"""获取音频时长(毫秒)"""
file_path = Path(file_path)
suffix = file_path.suffix.lower()
# wav 文件直接用 wave 模块读取
if suffix == ".wav":
try:
with wave.open(str(file_path), "rb") as wf:
frames = wf.getnframes()
rate = wf.getframerate()
duration_ms = int(frames / rate * 1000)
return duration_ms
except Exception:
pass
# 其他格式用 ffprobe
try:
result = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(file_path)],
capture_output=True, text=True, timeout=30,
)
duration_s = float(result.stdout.strip())
return int(duration_s * 1000)
except Exception:
pass
# 都失败则根据文件大小粗略估算 (16kHz 16bit mono)
file_size = file_path.stat().st_size
return int(file_size / 32000 * 1000)
def upload_file(file_path):
"""上传音频文件,返回 orderId"""
file_path = Path(file_path)
if not file_path.exists():
print(f"[错误] 文件不存在: {file_path}")
sys.exit(1)
file_size = file_path.stat().st_size
duration_ms = get_audio_duration_ms(file_path)
print(f"[信息] 文件: {file_path.name}, 大小: {file_size / 1024 / 1024:.2f} MB, 时长: {duration_ms / 1000:.1f} 秒")
date_time = get_datetime()
sig_random = generate_random_string(16)
params = {
"appId": APP_ID,
"accessKeyId": ACCESS_KEY_ID,
"dateTime": date_time,
"signatureRandom": sig_random,
"fileSize": str(file_size),
"fileName": file_path.name,
"duration": str(duration_ms),
"language": "autodialect",
"roleType": "0",
"audioMode": "fileStream",
}
signature = generate_signature(params)
headers = {
"Content-Type": "application/octet-stream",
"signature": signature,
}
print("[信息] 正在上传文件...")
with open(file_path, "rb") as f:
file_data = f.read()
resp = requests.post(URL_UPLOAD, params=params, headers=headers, data=file_data)
result = resp.json()
print(f"[调试] 上传响应: {result}")
if result.get("code") != "000000":
print(f"[错误] 上传失败: {result}")
sys.exit(1)
order_id = result["content"]["orderId"]
estimate_time = result["content"].get("taskEstimateTime", 0)
print(f"[信息] 上传成功, orderId: {order_id}")
if estimate_time:
print(f"[信息] 预估处理时间: {estimate_time / 1000:.0f} 秒")
return order_id, sig_random
def get_result(order_id, sig_random):
"""轮询获取转写结果"""
print("[信息] 等待转写完成...")
max_retries = 120
for i in range(max_retries):
date_time = get_datetime()
params = {
"appId": APP_ID,
"accessKeyId": ACCESS_KEY_ID,
"dateTime": date_time,
"signatureRandom": sig_random,
"orderId": order_id,
"resultType": "transfer",
}
signature = generate_signature(params)
headers = {
"Content-Type": "application/json",
"signature": signature,
}
resp = requests.post(URL_GET_RESULT, params=params, headers=headers)
result = resp.json()
if result.get("code") != "000000":
print(f"[错误] 查询失败: {result}")
sys.exit(1)
content = result.get("content", {})
order_info = content.get("orderInfo", {})
status = order_info.get("status")
if status == 4:
print("[信息] 转写完成!")
order_result = content.get("orderResult")
if order_result:
return order_result
print("[信息] 转写完成,但结果为空")
return None
elif status == -1:
fail_type = order_info.get("failType", "未知")
print(f"[错误] 转写失败, failType: {fail_type}")
return None
else:
status_map = {0: "已创建", 3: "处理中"}
status_text = status_map.get(status, f"处理中({status})")
estimate = content.get("taskEstimateTime", 0)
extra = f", 预估剩余 {estimate / 1000:.0f}s" if estimate else ""
print(f" [{i + 1}/{max_retries}] 状态: {status_text}{extra}, 等待10秒...")
time.sleep(10)
print("[错误] 查询超时")
return None
def parse_result(result_str):
"""解析转写结果为纯文本"""
try:
result_data = json.loads(result_str)
except json.JSONDecodeError:
return result_str
texts = []
lattice_list = result_data.get("lattice", [])
for item in lattice_list:
json_1best = item.get("json_1best", "")
try:
best_data = json.loads(json_1best)
st = best_data.get("st", {})
rt = st.get("rt", [])
sentence = ""
for r in rt:
ws = r.get("ws", [])
for w in ws:
cw = w.get("cw", [])
for c in cw:
sentence += c.get("w", "")
if sentence.strip():
texts.append(sentence)
except json.JSONDecodeError:
continue
return "\n".join(texts) if texts else result_str
def speech_to_text(audio_file, output_file=None):
"""主函数: 将语音文件转为文字"""
if not APP_ID or not ACCESS_KEY_ID or not ACCESS_KEY_SECRET:
print("[错误] 请先在 .env 文件中配置:")
print(" XFYUN_APP_ID, XFYUN_ACCESS_KEY_ID, XFYUN_ACCESS_KEY_SECRET")
print(" 申请地址: https://www.xfyun.cn")
sys.exit(1)
if "your_" in (ACCESS_KEY_ID or "") or "your_" in (ACCESS_KEY_SECRET or ""):
print("[错误] 请在 .env 中填写真实的 ACCESS_KEY_ID 和 ACCESS_KEY_SECRET")
sys.exit(1)
audio_path = Path(audio_file)
supported = {".mp3", ".wav", ".pcm", ".mp4", ".m4a", ".aac", ".ogg", ".flac", ".speex", ".opus", ".wma"}
if audio_path.suffix.lower() not in supported:
print(f"[错误] 不支持的格式: {audio_path.suffix}")
print(f" 支持的格式: {', '.join(sorted(supported))}")
sys.exit(1)
# 1. 上传文件
order_id, sig_random = upload_file(audio_file)
# 2. 轮询获取结果
result = get_result(order_id, sig_random)
if not result:
print("[错误] 未获取到转写结果")
sys.exit(1)
# 3. 解析结果
text = parse_result(result)
# 4. 保存结果
if output_file is None:
output_file = audio_path.with_suffix(".txt")
output_path = Path(output_file)
output_path.write_text(text, encoding="utf-8")
print(f"\n[完成] 转写结果已保存到: {output_path}")
print(f"{'=' * 50}")
print(text[:500])
if len(text) > 500:
print(f"\n... (共 {len(text)} 字符, 完整内容见文件)")
print(f"{'=' * 50}")
return text
if __name__ == "__main__":
if len(sys.argv) < 2:
print("用法: python speech_to_text.py <音频文件路径> [输出文件路径]")
print()
print("示例:")
print(" python speech_to_text.py recording.mp3")
print(" python speech_to_text.py meeting.wav output.txt")
print()
print("支持格式: mp3, wav, pcm, mp4, m4a, aac, ogg, flac, speex, opus, wma")
print("文件限制: ≤500MB, ≤5小时")
sys.exit(0)
audio = sys.argv[1]
output = sys.argv[2] if len(sys.argv) > 2 else None
speech_to_text(audio, output)
FILE:scripts/download_and_transcribe.py
#!/usr/bin/env python3
"""
下载 YouTube 视频音频并转为文字
用法:
python3 download_and_transcribe.py <youtube_url> [output_dir]
"""
import sys
import subprocess
from pathlib import Path
# 导入下载和转写模块
import download_audio
import speech_to_text
def download_and_transcribe(youtube_url, output_dir=None):
"""
下载 YouTube 音频并转为文字
Args:
youtube_url: YouTube 视频 URL
output_dir: 输出目录(默认为当前目录)
"""
# 1. 下载音频
print("\n" + "=" * 60)
print("步骤 1/2: 下载音频")
print("=" * 60)
if output_dir:
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
else:
output_path = Path.cwd()
audio_file = download_audio.download_audio(youtube_url, str(output_path))
if not audio_file:
print("❌ 音频下载失败")
sys.exit(1)
# 2. 转为文字
print("\n" + "=" * 60)
print("步骤 2/2: 语音转文字")
print("=" * 60)
try:
text = speech_to_text.speech_to_text(audio_file)
print(f"\n✅ 处理完成!")
print(f" 音频文件: {audio_file}")
print(f" 文字文件: {Path(audio_file).with_suffix('.txt')}")
return audio_file, text
except Exception as e:
print(f"❌ 语音转文字失败: {e}")
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("用法: python3 download_and_transcribe.py <youtube_url> [output_dir]")
print()
print("示例:")
print(" python3 download_and_transcribe.py https://www.youtube.com/watch?v=VIDEO_ID")
print(" python3 download_and_transcribe.py https://www.youtube.com/watch?v=VIDEO_ID ~/Downloads/xufei")
sys.exit(1)
youtube_url = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else None
download_and_transcribe(youtube_url, output_dir)
if __name__ == '__main__':
main()
FILE:scripts/download_audio.py
#!/usr/bin/env python3
"""
Download audio from YouTube videos.
Usage:
python3 download_audio.py <youtube_url> [output_path]
"""
import sys
import os
import subprocess
import re
from pathlib import Path
def extract_video_id(url):
"""Extract video ID from YouTube URL."""
patterns = [
r'(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/|youtube\.com/v/)([a-zA-Z0-9_-]{11})',
r'^([a-zA-Z0-9_-]{11})$'
]
for pattern in patterns:
match = re.search(pattern, url)
if match:
return match.group(1)
raise ValueError(f"Could not extract video ID from: {url}")
def download_audio(youtube_url, output_path=None):
"""
Download audio from YouTube video using yt-dlp.
Args:
youtube_url: YouTube video URL or video ID
output_path: Optional output directory (defaults to current directory)
Returns:
Path to downloaded audio file
"""
# Find yt-dlp executable
ytdlp_paths = [
'yt-dlp', # Try PATH first
str(Path.home() / 'Library' / 'Python' / '3.9' / 'bin' / 'yt-dlp'), # User install
'/usr/local/bin/yt-dlp', # Homebrew
'/opt/homebrew/bin/yt-dlp', # Apple Silicon Homebrew
]
ytdlp_cmd = None
for path in ytdlp_paths:
try:
subprocess.run([path, '--version'],
capture_output=True,
check=True)
ytdlp_cmd = path
break
except (subprocess.CalledProcessError, FileNotFoundError):
continue
if ytdlp_cmd is None:
print("Error: yt-dlp is not installed.", file=sys.stderr)
print("Install with: pip3 install yt-dlp", file=sys.stderr)
sys.exit(1)
# Extract video ID for filename
try:
video_id = extract_video_id(youtube_url)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Set output path
if output_path:
output_dir = Path(output_path)
output_dir.mkdir(parents=True, exist_ok=True)
else:
output_dir = Path.cwd()
output_template = str(output_dir / f"{video_id}.%(ext)s")
# Download audio using yt-dlp with enhanced options
cmd = [
ytdlp_cmd,
'-x', # Extract audio
'--audio-format', 'mp3', # Convert to MP3
'--audio-quality', '0', # Best quality
'-o', output_template,
'--no-check-certificates', # 跳过证书检查
'--extractor-args', 'youtube:player_client=android', # 使用 Android 客户端
'--user-agent', 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36', # Android UA
youtube_url
]
try:
print(f"Downloading audio from: {youtube_url}")
print(f"Output directory: {output_dir}")
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
print(result.stdout)
# Find the downloaded file
audio_file = output_dir / f"{video_id}.mp3"
if audio_file.exists():
print(f"\n✅ Audio downloaded successfully: {audio_file}")
return str(audio_file)
else:
print(f"Error: Downloaded file not found at {audio_file}", file=sys.stderr)
sys.exit(1)
except subprocess.CalledProcessError as e:
print(f"Error downloading audio: {e.stderr}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("Usage: python3 download_audio.py <youtube_url> [output_path]")
sys.exit(1)
youtube_url = sys.argv[1]
output_path = sys.argv[2] if len(sys.argv) > 2 else None
download_audio(youtube_url, output_path)
if __name__ == '__main__':
main()
FILE:DISTRIBUTION.md
# 📦 分发指南
## 如何分享这个 Skill
### 方式 1:打包为 ZIP 文件
```bash
cd ~/.openclaw/workspace
zip -r iflytek-asr-skill.zip iflytek-asr-skill-template/ \
-x "*.pyc" "**/__pycache__/*" ".env" "*.mp3" "*.wav" "*.txt"
```
生成的 `iflytek-asr-skill.zip` 就可以分享给别人了!
### 方式 2:上传到 GitHub
```bash
cd iflytek-asr-skill-template
git init
git add .
git commit -m "Initial commit: iFlytek ASR Skill"
git remote add origin https://github.com/你的用户名/iflytek-asr-skill.git
git push -u origin main
```
⚠️ **重要**:确保 `.gitignore` 已配置,不要泄露你的 `.env` 凭证!
### 方式 3:创建 OpenClaw Skill 包
在 skill 根目录创建 `SKILL.md`:
```markdown
---
name: iflytek-asr
description: 使用科大讯飞 API 将音频转文本,支持 YouTube 下载和中文方言识别
---
# iFlytek ASR Skill
详细文档请查看 README.md
```
然后可以被 OpenClaw 的 skill 系统识别。
---
## 接收者如何使用
### 快速安装(推荐)
```bash
# 1. 解压文件
unzip iflytek-asr-skill.zip
cd iflytek-asr-skill-template/
# 2. 运行安装脚本
./install.sh
# 3. 配置凭证
nano .env # 填入讯飞 API 凭证
# 4. 测试
python3 scripts/speech_to_text.py --help
```
### 手动安装
详见 `QUICKSTART.md` 文档。
---
## 注意事项
### ⚠️ 安全提醒
1. **绝对不要**在分发的包里包含你的 `.env` 文件
2. **绝对不要**把真实凭证写在示例或文档里
3. `.gitignore` 已配置,确保它包含 `.env`
### ✅ 分发前检查清单
- [ ] 移除所有 `.env` 文件(只保留 `.env.example`)
- [ ] 检查代码中没有硬编码的凭证
- [ ] 测试 `install.sh` 脚本能正常运行
- [ ] README.md 和 QUICKSTART.md 文档完整
- [ ] .gitignore 配置正确
---
## 文件结构(分发版本)
```
iflytek-asr-skill/
├── README.md # 完整文档
├── QUICKSTART.md # 快速开始
├── DISTRIBUTION.md # 本文档
├── .env.example # 凭证模板(无真实key)
├── .gitignore # Git忽略配置
├── install.sh # 安装脚本
├── requirements.txt # Python依赖
└── scripts/
├── speech_to_text.py
├── download_audio.py
└── download_and_transcribe.py
```
**不应包含:**
- ❌ `.env` (真实凭证)
- ❌ `__pycache__/`
- ❌ 任何音频/文本输出文件
---
## 更新说明
分享时建议附上版本号和更新日志,方便用户知道有哪些改进。