├── .gitignore ├── .npmignore ├── .github └── workflows │ └── publish-npm.yml ├── package.json ├── README.md ├── tsconfig.json └── src └── index.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig.json -------------------------------------------------------------------------------- /.github/workflows/publish-npm.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | workflow_dispatch: 8 | push: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | publish-npm: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: 16 20 | registry-url: https://registry.npmjs.org/ 21 | - run: npm ci 22 | - run: npm publish 23 | env: 24 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hexo-twitter-auto-publish", 3 | "version": "0.0.11", 4 | "description": "Hexo plugin to auto publish posts and pages on Twitter.", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prepublish": "rimraf dist && npm run build", 8 | "build": "tsc", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "keywords": [ 12 | "hexo", 13 | "twitter" 14 | ], 15 | "author": "Studio La Cosa Nostra ", 16 | "homepage": "https://github.com/studioLaCosaNostra/hexo-twitter-auto-publish#readme", 17 | "license": "ISC", 18 | "dependencies": { 19 | "camelcase": "^5.3.1", 20 | "lowdb": "^1.0.0", 21 | "twitter-api-v2": "^1.15.2" 22 | }, 23 | "devDependencies": { 24 | "@types/lowdb": "^1.0.9", 25 | "@types/node": "^13.1.4", 26 | "rimraf": "^2.7.1", 27 | "typescript": "^5.3.3" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hexo-twitter-auto-publish 2 | 3 | ## Install 4 | 5 | `npm i hexo-twitter-auto-publish` 6 | 7 | ## Configuration 8 | 9 | Twitter account config via shell variables 10 | 11 | ```bash 12 | export TWITTER_CONSUMER_KEY=Xegp8XDTMqVxcI2tId1juT70X 13 | export TWITTER_CONSUMER_SECRET=oaGaU06IGqaTfObZnYdrYmDvxiHcHck8TQ9Xk61Ze1ghjHQYkP 14 | export TWITTER_ACCESS_TOKEN_KEY=929842798974656517-VuQxIuoLhtoeqW71LofX6M5fIw8Pf3c 15 | export TWITTER_ACCESS_TOKEN_SECRET=R5RZtQj5tLWbSgFx39lq6cd2AcIQRjQk5kbepOobxCplA 16 | ``` 17 | 18 | or using `_config.yml` 19 | 20 | ```bash 21 | twitterAutoPublish: 22 | consumerKey: Xegp8XDTMqVxcI2tId1juT70X 23 | consumerSecret: fq4eY5NmK2X9ZxSDSUaFqMBPWWMUCCYu35PMvzoqB0YzqLOTEs 24 | accessTokenKey: 929842798974656517-VuQxIuoLhtoeqW71LofX6M5fIw8Pf3c 25 | accessTokenSecret: R5RZtQj5tLWbSgFx39lq6cd2AcIQRjQk5kbepOobxCplA 26 | ``` 27 | 28 | ## About twitter-db.json 29 | 30 | There are three fields in the database: `published`, `to-publish`, `to-destroy`. 31 | 32 | - `published` - contains posts that are already on twitter and each post has a tweetId. 33 | 34 | - `to-publish` - contains all new posts that have not yet appeared on Twitter. 35 | 36 | - `to-destroy` - contains posts that for some reason have been moved to a working version, or we changed the `twitterAutoPublish` in the page from true to false. 37 | 38 | **If you do not want a post to be sent to twitter, all you have to do is move it from `to-publish` to `published`.** 39 | 40 | **New statuses are sent to the twitter only after calling the command: `hexo deploy`, or after calling a custom command: `hexo twitter-publish`.** -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "./dist", /* Redirect output structure to the directory. */ 15 | // "rootDir": "./src/", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": true, /* Enable all strict type-checking options. */ 25 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 26 | // "strictNullChecks": true, /* Enable strict null checks. */ 27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 28 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 29 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 30 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 31 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 32 | 33 | /* Additional Checks */ 34 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 37 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 38 | 39 | /* Module Resolution Options */ 40 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 41 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 44 | // "typeRoots": [], /* List of folders to include type definitions from. */ 45 | // "types": [], /* Type declaration files to be included in compilation. */ 46 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 47 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 48 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 49 | 50 | /* Source Map Options */ 51 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 52 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 53 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 54 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 55 | 56 | /* Experimental Options */ 57 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 58 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 59 | } 60 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import FileAsync from 'lowdb/adapters/FileAsync'; 2 | import TwitterApi from 'twitter-api-v2'; 3 | import camelcase from 'camelcase'; 4 | import low from 'lowdb'; 5 | 6 | declare var hexo: any; 7 | const adapter = new FileAsync('twitter-db.json'); 8 | 9 | interface Document { 10 | layout: string; 11 | permalink: string; 12 | title: string; 13 | published: boolean; 14 | twitterAutoPublish: boolean; 15 | tweetMessage?: string; 16 | tags: (string | { name: string })[]; 17 | } 18 | 19 | type TwitterActions = { 20 | updateDB(document: Document, hexoPublished: boolean): Promise; 21 | publish(): Promise; 22 | cleanToPublish(): Promise; 23 | } 24 | 25 | interface DocumentInfo { 26 | title: string; 27 | permalink: string; 28 | tags: string[]; 29 | tweetMessage?: string; 30 | hexoPublished: boolean; 31 | tweetId?: string; 32 | } 33 | 34 | interface DbSchema { 35 | 'published': DocumentInfo[]; 36 | 'to-destroy': DocumentInfo[]; 37 | 'to-publish': DocumentInfo[]; 38 | } 39 | 40 | interface Config { 41 | appKey: string; 42 | appSecret: string; 43 | accessToken: string; 44 | accessSecret: string; 45 | } 46 | 47 | function validateConfig() { 48 | return !(( 49 | process.env.TWITTER_CONSUMER_KEY 50 | && process.env.TWITTER_CONSUMER_SECRET 51 | && process.env.TWITTER_ACCESS_TOKEN_KEY 52 | && process.env.TWITTER_ACCESS_TOKEN_SECRET 53 | ) 54 | || 55 | ( 56 | hexo.config.twitterAutoPublish 57 | && hexo.config.twitterAutoPublish.consumerKey 58 | && hexo.config.twitterAutoPublish.consumerSecret 59 | && hexo.config.twitterAutoPublish.accessTokenKey 60 | && hexo.config.twitterAutoPublish.accessTokenSecret 61 | )); 62 | } 63 | 64 | function twitterConfig(): Config { 65 | if (validateConfig()) { 66 | throw new Error('Missing hexo-twitter-auto-publish configuration'); 67 | } 68 | return { 69 | appKey: process.env.TWITTER_CONSUMER_KEY || hexo.config.twitterAutoPublish.consumerKey, 70 | appSecret: process.env.TWITTER_CONSUMER_SECRET || hexo.config.twitterAutoPublish.consumerSecret, 71 | accessToken: process.env.TWITTER_ACCESS_TOKEN_KEY || hexo.config.twitterAutoPublish.accessTokenKey, 72 | accessSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET || hexo.config.twitterAutoPublish.accessTokenSecret 73 | } 74 | } 75 | 76 | async function setupTwitter(db: low.LowdbAsync): Promise { 77 | await db.defaults({ 'published': [], 'to-destroy': [], 'to-publish': [] }).write(); 78 | return { 79 | async updateDB({ title, permalink, tags, tweetMessage }: Document, hexoPublished: boolean) { 80 | await db.read(); 81 | const published = db.get('published').find({ permalink }).value(); 82 | if (published) { 83 | if (!hexoPublished) { 84 | await db.get('to-destroy').push(published).write(); 85 | await db.get('to-publish').remove({ permalink }).write(); 86 | } 87 | } else { 88 | if (hexoPublished) { 89 | const tagNames: string[] = tags ? tags.map((tag: any) => tag.name || tag) : []; 90 | const data = { 91 | title, 92 | permalink, 93 | hexoPublished, 94 | tweetMessage, 95 | tags: tagNames 96 | }; 97 | const document = db.get('to-publish').find({ permalink }); 98 | if (document.value()) { 99 | await document.assign(data).write(); 100 | } else { 101 | await db.get('to-publish').push(data).write(); 102 | } 103 | } else { 104 | await db.get('to-publish').remove({ permalink }).write(); 105 | } 106 | } 107 | }, 108 | async publish() { 109 | await db.read(); 110 | const toDestroy = db.get('to-destroy').value(); 111 | const toPublish = db.get('to-publish').value(); 112 | try { 113 | const client = new TwitterApi(twitterConfig()); 114 | await Promise.all(toDestroy.map(async (documentInfo: DocumentInfo) => { 115 | const { tweetId } = documentInfo; 116 | try { 117 | await client.v2.deleteTweet(String(tweetId));; 118 | await db.get('published').remove({ tweetId }).write(); 119 | await db.get('to-destroy').remove({ tweetId }).write(); 120 | } catch (error) { 121 | throw new Error(`id: ${tweetId}\n${JSON.stringify(error)}`); 122 | } 123 | })); 124 | await Promise.all(toPublish.map(async (documentInfo: DocumentInfo) => { 125 | const { title, tags, permalink, tweetMessage } = documentInfo; 126 | const hashedTags = tags.map(tag => `#${camelcase(tag)}`).join(' '); 127 | const status = tweetMessage ? `${tweetMessage} ${hashedTags} ${permalink}` : `${title} ${hashedTags} ${permalink}`; 128 | try { 129 | const tweet = await client.v2.tweet(status); 130 | await db.get('published').push({ 131 | ...documentInfo, 132 | tweetId: tweet.data.id 133 | }).write(); 134 | await db.get('to-publish').remove({ permalink }).write(); 135 | } catch (error) { 136 | throw new Error(`${status}\n${JSON.stringify(error)}`); 137 | } 138 | })); 139 | } catch (error) { 140 | hexo.log.error(error); 141 | } 142 | }, 143 | async cleanToPublish() { 144 | await db.get('to-publish').remove().write(); 145 | } 146 | } 147 | } 148 | 149 | function processDocument(updateDB: (document: Document, hexoPublished: boolean) => Promise) { 150 | return async (document: Document) => { 151 | const publishedPost: boolean = document.layout === 'post' && document.published; 152 | const publishedPage: boolean = document.layout !== 'post' && document.twitterAutoPublish !== false; 153 | const hexoPublished: boolean = publishedPost || publishedPage; 154 | await updateDB(document, hexoPublished); 155 | return document; 156 | } 157 | } 158 | 159 | async function registerFilters(cleanToPublish: () => Promise, updateDB: (document: Document, hexoPublished: boolean) => Promise) { 160 | const updateDocumentDB = processDocument(updateDB); 161 | hexo.extend.filter.register('after_post_render', updateDocumentDB, { async: true }); 162 | hexo.extend.filter.register('after_generate', async () => { 163 | await cleanToPublish(); 164 | const posts = hexo.locals.get('posts'); 165 | for (var index = 0; index < posts.length; index++) { 166 | const post = posts.data[index]; 167 | await updateDocumentDB(post); 168 | } 169 | const pages = hexo.locals.get('pages'); 170 | for (var index = 0; index < pages.length; index++) { 171 | const page = pages.data[index]; 172 | await updateDocumentDB(page); 173 | } 174 | }, { async: true }); 175 | } 176 | 177 | function watchHexoDeployAfter(twitterPublish: () => Promise) { 178 | hexo.on('deployAfter', function () { 179 | twitterPublish(); 180 | }); 181 | } 182 | 183 | function registerConsoleCommandPublish() { 184 | hexo.extend.console.register('twitter-publish', 'Twitter publish posts.', async () => { 185 | const db = await low(adapter); 186 | const twitter: TwitterActions = await setupTwitter(db); 187 | twitter.publish(); 188 | }); 189 | } 190 | registerConsoleCommandPublish(); 191 | 192 | async function start() { 193 | const db = await low(adapter); 194 | const twitter: TwitterActions = await setupTwitter(db); 195 | registerFilters(twitter.cleanToPublish, twitter.updateDB); 196 | watchHexoDeployAfter(twitter.publish); 197 | } 198 | start(); 199 | --------------------------------------------------------------------------------