├── .browserslistrc ├── scripts ├── browsers.js ├── zip.js ├── post-build.js └── dev-env │ ├── webextension-background.js │ └── webextension-page.js ├── babel.config.js ├── assets └── weitweet.psd ├── tests └── unit │ └── .eslintrc.js ├── postcss.config.js ├── public ├── img │ ├── icon128.png │ ├── icon16.png │ ├── icon24.png │ ├── icon32.png │ ├── icon48.png │ └── symbol-defs.svg └── index.html ├── src ├── shims-vue.d.ts ├── assets │ └── shadows-into-light.ttf ├── manifest │ ├── chrome.manifest.json │ ├── firefox.manifest.json │ └── common.manifest.json ├── helpers │ ├── error.ts │ └── sort-imgs.ts ├── services │ ├── fanfou │ │ ├── extractor.ts │ │ ├── logo.svg │ │ └── service.ts │ ├── twitter │ │ ├── extractor.ts │ │ ├── logo.svg │ │ └── service.ts │ ├── types.ts │ ├── service.ts │ ├── weibo │ │ ├── logo.svg │ │ ├── service.ts │ │ └── error.json │ ├── OAuth1a.ts │ └── helpers.ts ├── shims-tsx.d.ts ├── background │ ├── types.ts │ └── index.ts ├── main.ts ├── App.vue ├── content │ └── img-extractor.ts ├── components │ ├── Gallery.vue │ ├── EmojiPicker.vue │ └── InputBox.vue └── _locales │ └── messages.json ├── .editorconfig ├── .gitignore ├── .env ├── .eslintrc.js ├── .env.development ├── tsconfig.json ├── LICENSE ├── README.md ├── package.json └── vue.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | Chrome >= 56 2 | Firefox >= 56 3 | -------------------------------------------------------------------------------- /scripts/browsers.js: -------------------------------------------------------------------------------- 1 | module.exports = ['chrome', 'firefox'] 2 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /assets/weitweet.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crimx/ext-weitweet/HEAD/assets/weitweet.psd -------------------------------------------------------------------------------- /tests/unit/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | mocha: true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/img/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crimx/ext-weitweet/HEAD/public/img/icon128.png -------------------------------------------------------------------------------- /public/img/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crimx/ext-weitweet/HEAD/public/img/icon16.png -------------------------------------------------------------------------------- /public/img/icon24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crimx/ext-weitweet/HEAD/public/img/icon24.png -------------------------------------------------------------------------------- /public/img/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crimx/ext-weitweet/HEAD/public/img/icon32.png -------------------------------------------------------------------------------- /public/img/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crimx/ext-weitweet/HEAD/public/img/icon48.png -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/shadows-into-light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crimx/ext-weitweet/HEAD/src/assets/shadows-into-light.ttf -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /src/manifest/chrome.manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "background": { 3 | "scripts": [ 4 | "js/browser-polyfill.min.js", 5 | "js/background.js" 6 | ], 7 | "persistent": false 8 | }, 9 | "update_url": "https://clients2.google.com/service/update2/crx", 10 | "minimum_chrome_version": "56" 11 | } -------------------------------------------------------------------------------- /src/manifest/firefox.manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "background": { 3 | "scripts": [ 4 | "js/browser-polyfill.min.js", 5 | "js/background.js" 6 | ] 7 | }, 8 | "applications": { 9 | "gecko": { 10 | "id": "ext-weitweet@crimx.com", 11 | "strict_min_version": "56.0" 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /src/helpers/error.ts: -------------------------------------------------------------------------------- 1 | export function encodeError (msg: string) { 2 | return 'err_' + msg 3 | } 4 | 5 | export function decodeError (err: Error): string { 6 | if (err && err.message) { 7 | return err.message.startsWith('err_') 8 | ? browser.i18n.getMessage(err.message) 9 | : err.message 10 | } 11 | return '' 12 | } 13 | -------------------------------------------------------------------------------- /src/services/fanfou/extractor.ts: -------------------------------------------------------------------------------- 1 | import { setupExtractor } from '../helpers' 2 | 3 | setupExtractor(() => { 4 | const el = document.querySelector('.pin') 5 | if (el) { 6 | const code = el.innerText.trim() 7 | if (code) { 8 | return { 9 | service: 'fanfou', 10 | code 11 | } 12 | } 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /src/services/twitter/extractor.ts: -------------------------------------------------------------------------------- 1 | import { setupExtractor } from '../helpers' 2 | 3 | setupExtractor(() => { 4 | const code = document.querySelectorAll('code') 5 | for (const el of code) { 6 | if (Number(el.innerText)) { 7 | return { 8 | service: 'twitter', 9 | code: el.innerText.trim() 10 | } 11 | } 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /src/services/types.ts: -------------------------------------------------------------------------------- 1 | export type ServiceId = 'fanfou' | 'twitter' | 'weibo' 2 | 3 | /** 4 | * Saved in browser.local.storage. 5 | * Must be `type` due to a limitation of web-ext-types. 6 | * @see {@link https://github.com/kelseasy/web-ext-types/issues/51} 7 | */ 8 | export type User = { 9 | id: string 10 | name: string 11 | avatar: string 12 | } 13 | -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue' 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Do not modify this file. Use `.env.local`. 2 | 3 | VUE_APP_FANFOU_CONSUMER_KEY= 4 | VUE_APP_FANFOU_CONSUMER_SECRET= 5 | 6 | VUE_APP_TWITTER_CONSUMER_KEY= 7 | VUE_APP_TWITTER_CONSUMER_SECRET= 8 | 9 | VUE_APP_WEIBO_CONSUMER_KEY= 10 | VUE_APP_WEIBO_CONSUMER_SECRET= 11 | 12 | # Must be Chrome extension id. Firefox also works with Chrome id. 13 | # VUE_APP_REDIRECT_URI=https://.chromiumapp.org/provider_cb 14 | VUE_APP_REDIRECT_URI= 15 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | 'extends': [ 7 | 'plugin:vue/essential', 8 | '@vue/standard', 9 | '@vue/typescript' 10 | ], 11 | rules: { 12 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 13 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' 14 | }, 15 | parserOptions: { 16 | parser: 'typescript-eslint-parser' 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | # Do not modify this file. Use `.env.development.local`. 2 | 3 | # Images for testing 4 | # VUE_APP_IMGS=["", ""] 5 | VUE_APP_IMGS=[] 6 | 7 | # Service accounts info 8 | # ={"avatar":"","id":"","name":""} 9 | # ={"key":"","secret":""} 10 | # VUE_APP_ACCOUNTS={"fanfou":"{"token":,"user":}","weibo":"{"token":{"accessToken":"","uid":""},"user":}","twitter":"{"user":,"token":}"} 11 | VUE_APP_ACCOUNTS={} 12 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | WeiTweet Editor 8 | <%= process.env.NODE_ENV === 'development' ? '' : '' %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/background/types.ts: -------------------------------------------------------------------------------- 1 | import { ServiceId } from '@/services/types' 2 | 3 | export const enum MsgType { 4 | OpenUrl, 5 | PinCode, 6 | IMGs, 7 | ExtractorReady 8 | } 9 | 10 | export interface Message { 11 | type: MsgType 12 | } 13 | 14 | export interface MsgOpenUrl extends Message { 15 | type: MsgType.OpenUrl 16 | url: string 17 | /** extension inner url */ 18 | self?: boolean 19 | } 20 | 21 | export interface MsgPinCode extends Message { 22 | type: MsgType.PinCode 23 | service: ServiceId 24 | /** PIN code */ 25 | code: string 26 | } 27 | 28 | export interface MsgIMGs extends Message { 29 | type: MsgType.IMGs 30 | col: { [index: string]: number } 31 | } 32 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import iView from 'iview' 4 | import { MsgType, Message } from './background/types' 5 | import i18n from 'vue-plugin-webextension-i18n' 6 | // @ts-ignore 7 | import VueMasonry from 'vue-masonry-css' 8 | 9 | import 'iview/dist/styles/iview.css' 10 | 11 | Vue.config.productionTip = false 12 | 13 | Vue.use(i18n) 14 | Vue.use(iView) 15 | Vue.use(VueMasonry) 16 | 17 | Vue.directive('focus', { 18 | inserted: function (el) { 19 | el.focus() 20 | } 21 | }) 22 | 23 | new Vue({ 24 | render: h => h(App) 25 | }).$mount('#app') 26 | 27 | browser.runtime.onMessage.addListener((data: Partial) => { 28 | if (data.type === MsgType.ExtractorReady) { 29 | return Promise.resolve(true) 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /src/services/twitter/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "skipLibCheck": true, 15 | "typeRoots": [ 16 | "node_modules/@types", 17 | "node_modules/web-ext-types" 18 | ], 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "lib": [ 25 | "esnext", 26 | "dom", 27 | "dom.iterable", 28 | "scripthost" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.tsx", 34 | "src/**/*.vue", 35 | "tests/**/*.ts", 36 | "tests/**/*.tsx" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 32 | 33 | 48 | -------------------------------------------------------------------------------- /src/manifest/common.manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "homepage_url": "https://github.com/crimx/ext-weitweet", 3 | "manifest_version": 2, 4 | "name": "__MSG_extension_name__", 5 | "short_name": "__MSG_extension_short_name__", 6 | "description": "__MSG_extension_description__", 7 | "default_locale": "zh_CN", 8 | "version": "0.0.0", 9 | "icons": { 10 | "16": "img/icon16.png", 11 | "48": "img/icon48.png", 12 | "128": "img/icon128.png" 13 | }, 14 | "content_scripts": [ 15 | { 16 | "js": ["js/browser-polyfill.min.js", "js/extractor_twitter.js"], 17 | "matches": ["*://api.twitter.com/oauth/authorize*"] 18 | }, 19 | { 20 | "js": ["js/browser-polyfill.min.js", "js/extractor_fanfou.js"], 21 | "matches": ["*://fanfou.com/oauth/authorize*"] 22 | } 23 | ], 24 | "browser_action": { 25 | "default_icon": { 26 | "16": "img/icon16.png", 27 | "24": "img/icon24.png", 28 | "32": "img/icon32.png" 29 | } 30 | }, 31 | "permissions": ["", "storage", "tabs", "identity"], 32 | "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'" 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 CRIMX 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/content/img-extractor.ts: -------------------------------------------------------------------------------- 1 | import { MsgType, MsgIMGs } from '@/background/types' 2 | 3 | browser.runtime.sendMessage({ 4 | type: MsgType.IMGs, 5 | col: searchDom(document) 6 | }) 7 | 8 | function searchDom (doc: Document): { [index: string]: number } { 9 | const srcChecker = /url\(\s*?['"]?\s*?(\S+?)\s*?["']?\s*?\)/i 10 | const collection: { [index: string]: number } = {} // src, weight 11 | doc.querySelectorAll('*').forEach(node => { 12 | // bg src 13 | const prop = window 14 | .getComputedStyle(node, null) 15 | .getPropertyValue('background-image') 16 | // match `url(...)` 17 | const match = srcChecker.exec(prop) 18 | if (match) { 19 | collection[match[1].replace(/^\/(?!\/)/, location.origin)] = 0 20 | } 21 | 22 | if (/^img$/i.test(node.tagName) && (node as HTMLImageElement).src) { 23 | // src from img tag 24 | collection[ 25 | (node as HTMLImageElement).src.replace(/^\/(?!\/)/, location.origin) 26 | ] = 1 27 | } else if (/^frame$/i.test(node.tagName)) { 28 | // iframe 29 | const iframe = node as HTMLIFrameElement 30 | const doc = 31 | iframe.contentDocument || 32 | (iframe.contentWindow && iframe.contentWindow.document) 33 | if (doc) { 34 | try { 35 | Object.assign(collection, searchDom(doc)) 36 | } catch (e) {} 37 | } 38 | } 39 | }) 40 | return collection 41 | } 42 | -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | import { MsgType, Message, MsgOpenUrl } from '@/background/types' 2 | 3 | browser.runtime.onMessage.addListener( 4 | (data: Partial, sender: browser.runtime.MessageSender) => { 5 | switch (data.type) { 6 | case MsgType.OpenUrl: 7 | return openUrl(data as MsgOpenUrl) 8 | default: 9 | break 10 | } 11 | } 12 | ) 13 | 14 | browser.browserAction.onClicked.addListener(async sourceTab => { 15 | await openUrl({ 16 | url: browser.extension.getURL( 17 | `editor.html` + 18 | `?title=${encodeURIComponent(sourceTab.title || '')}` + 19 | `&url=${encodeURIComponent(sourceTab.url || '')}` + 20 | `&tabid=${encodeURIComponent( 21 | sourceTab.id != null ? String(sourceTab.id) : '' 22 | )}` 23 | ) 24 | }) 25 | }) 26 | 27 | /** 28 | * Open a url on new tab or highlight a existing tab if already opened 29 | */ 30 | async function openUrl ({ 31 | url, 32 | self 33 | }: { 34 | url: string 35 | self?: boolean 36 | }): Promise { 37 | if (self) { 38 | url = browser.runtime.getURL(url) 39 | } 40 | const tabs = await browser.tabs.query({ url }) 41 | if (tabs.length > 0) { 42 | const { index, windowId } = tabs[0] 43 | await browser.windows.update(windowId, { focused: true }) 44 | // Only Chrome supports tab.highlight for now 45 | const highlight = (browser.tabs as any)['highlight'] 46 | if (highlight) { 47 | await highlight({ tabs: index, windowId }) 48 | } 49 | return tabs[0] 50 | } else { 51 | return browser.tabs.create({ url }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 小推WeiTweet 2 | 3 | 一键编辑发送饭否、推特与微博。
4 | Post Fanfou, Weibo and Twitter in one click. 5 | 6 |

7 | 8 |

9 | 10 |

11 | 12 | - 一键编辑发送。 13 | - 网页图片自动抓取。 14 | - 支持本地图片、图片链接、图片直接粘贴。 15 | - 开源,不占后台。 16 | 17 |

18 | 19 | - One click to send them all. 20 | - Auto extracts images from page. 21 | - Image upload. Url pasting. Image pasting. 22 | - Open sourced. Low background memory and CPU footprint. 23 | 24 |

25 | 26 | ## Downloads 27 | 28 | - Chrome Web Store: 29 | - Firefox AMO: 30 | - crx: 31 | 32 | ## Privacy Policy & Terms of Service 33 | 34 | 小推Weitweet 通过 Oauth 授权方式为用户提供连接推特和微博功能,相关用户信息会通过浏览器存储在本地。除此以外本扩展不收集任何信息。扩展安全性依赖于饭否平台、微博平台、推特平台以及浏览器供应商,使用风险由用户承担。 35 | 36 | 小推Weitweet offers account connections with Twitter and Weibo through Oauth service. User related data is stored locally in the browser. Other than that the extension collects no further information. Extension security relies on Fanfou platform, Weibo platform, Twitter platform and Browser porviders. The use of this extension is at the user’s own risk. 37 | 38 | ## Project setup 39 | ``` 40 | yarn install 41 | ``` 42 | 43 | ### Compiles and hot-reloads for development 44 | ``` 45 | yarn run serve 46 | ``` 47 | 48 | ### Compiles and minifies for production 49 | ``` 50 | yarn run build 51 | ``` 52 | 53 | ### Run your tests 54 | ``` 55 | yarn run test 56 | ``` 57 | 58 | ### Lints and fixes files 59 | ``` 60 | yarn run lint 61 | ``` 62 | 63 | ### Run your unit tests 64 | ``` 65 | yarn run test:unit 66 | ``` 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ext-weitweet", 3 | "version": "1.3.2", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build && node scripts/post-build.js", 8 | "fastbuild": "vue-cli-service build --fastbuild && node scripts/post-build.js", 9 | "lint": "vue-cli-service lint", 10 | "zip": "node scripts/zip.js", 11 | "test:unit": "vue-cli-service test:unit" 12 | }, 13 | "dependencies": { 14 | "@types/crypto-js": "^3.1.43", 15 | "@types/twitter-text": "^2.0.0", 16 | "crypto-js": "^3.1.9-1", 17 | "iview": "^3.2.1", 18 | "oauth-1.0a": "^2.2.5", 19 | "twitter-text": "1.x", 20 | "vue": "^2.5.17", 21 | "vue-class-component": "^6.0.0", 22 | "vue-emoji-picker": "^1.0.1", 23 | "vue-masonry-css": "^1.0.3", 24 | "vue-plugin-webextension-i18n": "^0.1.2", 25 | "vue-property-decorator": "^7.0.0", 26 | "web-ext-types": "^3.0.0", 27 | "webextension-polyfill": "^0.3.1" 28 | }, 29 | "devDependencies": { 30 | "@types/chai": "^4.1.0", 31 | "@types/mocha": "^5.2.4", 32 | "@vue/cli-plugin-babel": "^3.2.0", 33 | "@vue/cli-plugin-eslint": "^3.2.0", 34 | "@vue/cli-plugin-typescript": "^3.2.0", 35 | "@vue/cli-plugin-unit-mocha": "^3.2.0", 36 | "@vue/cli-service": "^3.2.0", 37 | "@vue/eslint-config-standard": "^4.0.0", 38 | "@vue/eslint-config-typescript": "^3.2.0", 39 | "@vue/test-utils": "^1.0.0-beta.20", 40 | "archiver": "^3.0.0", 41 | "babel-eslint": "^10.0.1", 42 | "chai": "^4.1.2", 43 | "eslint": "^5.8.0", 44 | "eslint-plugin-vue": "^5.0.0-0", 45 | "fs-extra": "^7.0.1", 46 | "node-sass": "^4.12.0", 47 | "sass-loader": "^7.0.1", 48 | "typescript": "^3.0.0", 49 | "vue-template-compiler": "^2.5.17", 50 | "wrapper-webpack-plugin": "^2.1.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/services/service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | setServiceStorage, 3 | getServiceStorage, 4 | clearServiceStorage 5 | } from './helpers' 6 | import { User, ServiceId } from './types' 7 | 8 | export interface ServiceStorage { 9 | enable: boolean 10 | user: User | null 11 | token: any 12 | } 13 | 14 | /** 15 | * OAuth based service interface 16 | */ 17 | export abstract class Service { 18 | constructor (id: ServiceId) { 19 | this.id = id 20 | this.getStorage() 21 | } 22 | 23 | readonly id: ServiceId 24 | /** max content length */ 25 | abstract readonly maxWordCount: number 26 | 27 | /** 28 | * Direct user to authorize page. 29 | * @returns {Promise} is OAuth 1a? 30 | */ 31 | abstract authorize(): Promise 32 | abstract obtainAccessToken(code: string): Promise 33 | abstract checkAccessToken(): Promise 34 | 35 | /** 36 | * @returns {Promise} url 37 | */ 38 | abstract postContent(text: string, img?: string | Blob): Promise 39 | 40 | enable = true 41 | 42 | user: User | null = null 43 | protected token: any = null 44 | 45 | countWords (text: string) { 46 | return text.length 47 | } 48 | 49 | async setStorage () { 50 | await setServiceStorage(this.id, { 51 | enable: this.enable, 52 | user: this.user, 53 | token: this.token 54 | }) 55 | } 56 | 57 | async getStorage () { 58 | const storage: ServiceStorage = (await getServiceStorage( 59 | this.id 60 | )) || { 61 | enable: true, 62 | user: null, 63 | token: null 64 | } 65 | this.enable = !!storage.enable 66 | this.user = storage.user != null ? storage.user : null 67 | this.token = storage.token != null ? storage.token : null 68 | } 69 | 70 | async clearStorage () { 71 | await clearServiceStorage(this.id) 72 | this.enable = true 73 | this.user = null 74 | this.token = null 75 | } 76 | } 77 | 78 | export default Service 79 | -------------------------------------------------------------------------------- /src/helpers/sort-imgs.ts: -------------------------------------------------------------------------------- 1 | export interface ImgMeta { 2 | src: string 3 | weight: number 4 | width: number 5 | height: number 6 | } 7 | 8 | interface Col { 9 | [index: string]: number 10 | } 11 | 12 | export async function sortImgs (col: Col = {}): Promise { 13 | const list: ImgMeta[] = [] 14 | await Promise.all( 15 | Object.keys(col).map(async src => { 16 | const dimension = await getImgDimension(src) 17 | if (dimension && dimension.width >= 100 && dimension.height >= 100) { 18 | list.push({ 19 | src: /(jpg|jpeg|png|gif)$/i.test(src) ? src : await imgToPng(src), 20 | weight: col[src], 21 | width: dimension.width, 22 | height: dimension.height 23 | }) 24 | } 25 | }) 26 | ) 27 | return list 28 | .sort((a, b) => { 29 | if (b.weight - a.weight !== 0) { 30 | return b.weight - a.weight 31 | } 32 | return b.width * b.height - a.width * a.height 33 | }) 34 | .map(meta => meta.src) 35 | } 36 | 37 | /** 38 | * cover a image to png 39 | */ 40 | export const imgToPng = function imgToPng (src: string): Promise { 41 | return new Promise((resolve, reject) => { 42 | var canvas = document.createElement('canvas') 43 | var ctx = canvas.getContext('2d') 44 | var img = new Image() 45 | img.onload = function () { 46 | canvas.width = img.naturalWidth 47 | canvas.height = img.naturalHeight 48 | if (!ctx) { 49 | return reject(new Error()) 50 | } 51 | ctx.drawImage(img, 0, 0) 52 | canvas.toBlob(blob => { 53 | resolve(URL.createObjectURL(blob)) 54 | }) 55 | } 56 | img.onerror = reject 57 | img.src = src 58 | }) 59 | } 60 | 61 | export function getImgDimension ( 62 | src: string 63 | ): Promise<{ width: number; height: number } | undefined> { 64 | return new Promise(resolve => { 65 | const img = document.createElement('img') 66 | img.onload = () => 67 | resolve({ width: img.naturalWidth, height: img.naturalHeight }) 68 | img.src = src 69 | setTimeout(resolve, 5000) 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /src/services/weibo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 9 | 11 | 12 | 14 | 15 | -------------------------------------------------------------------------------- /scripts/zip.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const archiver = require('archiver') 4 | const browsers = require('./browsers') 5 | 6 | console.log('\n\nZipping files...') 7 | 8 | Promise.all([...browsers.map(pack), packSource()]) 9 | .then(() => { 10 | console.log(`Done. See ${path.join(__dirname, '../dist')} .\n\n`) 11 | }) 12 | .catch(e => { 13 | throw e 14 | }) 15 | 16 | function pack (browser) { 17 | return new Promise((resolve, reject) => { 18 | const output = fs.createWriteStream( 19 | path.join(__dirname, `../dist/${browser}.zip`) 20 | ) 21 | const archive = archiver('zip', {}) 22 | 23 | output.on('close', resolve) 24 | 25 | archive.on('warning', function (err) { 26 | if (err.code === 'ENOENT') { 27 | console.warn(err) 28 | } else { 29 | reject(err) 30 | } 31 | }) 32 | 33 | archive.on('error', reject) 34 | 35 | archive.pipe(output) 36 | 37 | archive.glob(`**/*`, { 38 | cwd: path.join(__dirname, '../dist', browser), 39 | ignore: `**/*.map` 40 | }) 41 | 42 | archive.finalize() 43 | }) 44 | } 45 | 46 | function packSource () { 47 | return new Promise((resolve, reject) => { 48 | const output = fs.createWriteStream( 49 | path.join(__dirname, `../dist/source.zip`) 50 | ) 51 | const archive = archiver('zip', {}) 52 | 53 | output.on('close', resolve) 54 | 55 | archive.on('warning', function (err) { 56 | if (err.code === 'ENOENT') { 57 | console.warn(err) 58 | } else { 59 | reject(err) 60 | } 61 | }) 62 | 63 | archive.on('error', reject) 64 | 65 | archive.pipe(output) 66 | 67 | fs.readdirSync(path.join(__dirname, `..`)) 68 | .filter( 69 | name => 70 | !/^(\.github|dist|node_modules|\.git|\.env\.development(\.local)?)$/.test( 71 | name 72 | ) 73 | ) 74 | .forEach(name => { 75 | const filePath = path.join(__dirname, `../`, name) 76 | const stats = fs.lstatSync(filePath) 77 | if (stats.isDirectory()) { 78 | archive.directory(filePath, name) 79 | } else if (stats.isFile()) { 80 | archive.file(filePath, { name }) 81 | } 82 | }) 83 | 84 | archive.finalize() 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /src/services/fanfou/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 11 | -------------------------------------------------------------------------------- /scripts/post-build.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra') 2 | const path = require('path') 3 | const vueConfig = require('../vue.config') 4 | const browsers = require('./browsers') 5 | const messages = require('../src/_locales/messages.json') 6 | 7 | console.log('\n\nGenerating files for each browser target...') 8 | 9 | const dist = path.join(__dirname, '../', vueConfig.outputDir || 'dist') 10 | const originFiles = fs.readdirSync(dist) 11 | 12 | const locales = getLocales(messages) 13 | 14 | const pGenerating = browsers.map(async browser => { 15 | const dest = path.join(dist, browser) 16 | await fs.mkdir(dest) 17 | 18 | // Copy source files 19 | await Promise.all( 20 | originFiles.map(filename => 21 | fs.copy(path.join(dist, filename), path.join(dest, filename)) 22 | ) 23 | ) 24 | 25 | // Copy polyfill 26 | await Promise.all([ 27 | fs.copy( 28 | path.join( 29 | __dirname, 30 | '../node_modules/webextension-polyfill/dist/browser-polyfill.min.js' 31 | ), 32 | path.join(dest, 'js/browser-polyfill.min.js') 33 | ), 34 | fs.copy( 35 | path.join( 36 | __dirname, 37 | '../node_modules/webextension-polyfill/dist/browser-polyfill.min.js.map' 38 | ), 39 | path.join(dest, 'js/browser-polyfill.min.js.map') 40 | ) 41 | ]) 42 | 43 | // Copy manifest 44 | const commonManifest = require('../src/manifest/common.manifest') 45 | const browserManifest = require(`../src/manifest/${browser}.manifest`) 46 | const version = require('../package.json').version 47 | await fs.writeJson( 48 | path.join(dest, 'manifest.json'), 49 | Object.assign({}, commonManifest, browserManifest, { version }), 50 | { spaces: 2 } 51 | ) 52 | 53 | // Write locales 54 | await Promise.all( 55 | Object.keys(locales).map(async lang => { 56 | const localesDir = path.join(dest, '_locales', lang) 57 | await fs.ensureDir(localesDir) 58 | await fs.writeJSON( 59 | path.join(localesDir, 'messages.json'), 60 | locales[lang], 61 | { space: 2 } 62 | ) 63 | }) 64 | ) 65 | }) 66 | 67 | Promise.all(pGenerating) 68 | .then(async () => { 69 | // Remove files 70 | await Promise.all( 71 | originFiles.map(filename => fs.remove(path.join(dist, filename))) 72 | ) 73 | console.log('Done.\n\n') 74 | }) 75 | .catch(e => { 76 | throw e 77 | }) 78 | 79 | function getLocales (messages) { 80 | const keys = Object.keys(messages) 81 | const langs = Object.keys(messages[keys[0]]).filter(k => k !== 'description') 82 | const locales = {} 83 | for (const lang of langs) { 84 | locales[lang] = {} 85 | for (const key of keys) { 86 | locales[lang][key] = { 87 | description: messages[key].description, 88 | message: messages[key][lang] 89 | } 90 | } 91 | } 92 | return locales 93 | } 94 | -------------------------------------------------------------------------------- /src/services/OAuth1a.ts: -------------------------------------------------------------------------------- 1 | import OAuth from 'oauth-1.0a' 2 | import hmacSHA1 from 'crypto-js/hmac-sha1' 3 | import Base64 from 'crypto-js/enc-base64' 4 | import { encodeError } from '@/helpers/error' 5 | 6 | export interface OAuthConfig { 7 | consumer: Token 8 | accessToken: Token | null 9 | } 10 | 11 | export type Token = { 12 | key: string 13 | secret: string 14 | } 15 | 16 | /** 17 | * OAuth 1a helper 18 | */ 19 | export class OAuth1a { 20 | oauth: OAuth 21 | 22 | consumerToken: Token 23 | accessToken: Token | null = null 24 | requestToken: Token | null = null 25 | 26 | constructor (config: OAuthConfig) { 27 | this.oauth = new OAuth({ 28 | consumer: config.consumer, 29 | signature_method: 'HMAC-SHA1', 30 | hash_function (baseString, key) { 31 | return Base64.stringify(hmacSHA1(baseString, key)) 32 | } 33 | }) 34 | this.consumerToken = config.consumer 35 | this.accessToken = config.accessToken 36 | } 37 | 38 | async obtainToken ( 39 | requestData: OAuth.RequestOptions, 40 | token?: OAuth.Token 41 | ): Promise { 42 | const response = await fetch(requestData.url, { 43 | method: requestData.method, 44 | headers: { 45 | ...this.oauth.toHeader(this.oauth.authorize(requestData, token)) 46 | } 47 | }) 48 | const params = new URLSearchParams(await response.text()) 49 | const key = params.get('oauth_token') 50 | const secret = params.get('oauth_token_secret') 51 | if (key && secret) { 52 | this.accessToken = { key, secret } 53 | return this.accessToken 54 | } 55 | return Promise.reject(new Error()) 56 | } 57 | 58 | async obtainRequestToken (requestData: OAuth.RequestOptions): Promise { 59 | this.requestToken = await this.obtainToken(requestData) 60 | return this.requestToken 61 | } 62 | 63 | async obtainAccessToken (requestData: OAuth.RequestOptions): Promise { 64 | if (!this.requestToken) { 65 | throw new Error(encodeError('no_request_token')) 66 | } 67 | 68 | this.accessToken = await this.obtainToken(requestData, this.requestToken) 69 | return this.accessToken 70 | } 71 | 72 | async send ( 73 | url: string, 74 | accessToken: Token | null, 75 | requestInit: RequestInit = {} 76 | ): Promise { 77 | if (!accessToken) { 78 | throw new Error(encodeError('no_access_token')) 79 | } 80 | 81 | const requestData = { 82 | url, 83 | method: (requestInit.method || 'GET').toUpperCase(), 84 | data: requestInit.headers 85 | } 86 | 87 | const response = await fetch(url, { 88 | ...requestInit, 89 | // remove cookies 90 | credentials: 'omit', 91 | referrer: 'no-referrer', 92 | headers: { 93 | ...this.oauth.toHeader(this.oauth.authorize(requestData, accessToken)) 94 | } 95 | }) 96 | return response.json() 97 | } 98 | } 99 | 100 | export default OAuth1a 101 | -------------------------------------------------------------------------------- /src/components/Gallery.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 55 | 56 | 106 | -------------------------------------------------------------------------------- /src/services/fanfou/service.ts: -------------------------------------------------------------------------------- 1 | import { MsgType, MsgOpenUrl } from '@/background/types' 2 | import { Service } from '../service' 3 | import { OAuth1a } from '../OAuth1a' 4 | import tText from 'twitter-text' 5 | import { encodeError } from '@/helpers/error' 6 | import { replaceUrls } from '../helpers' 7 | 8 | export class Fanfou extends Service { 9 | constructor () { 10 | super('fanfou') 11 | } 12 | 13 | readonly maxWordCount = 140 14 | 15 | private oauth = new OAuth1a({ 16 | consumer: { 17 | key: process.env.VUE_APP_FANFOU_CONSUMER_KEY || '', 18 | secret: process.env.VUE_APP_FANFOU_CONSUMER_SECRET || '' 19 | }, 20 | accessToken: this.token 21 | }) 22 | 23 | countWords (text: string) { 24 | return tText.getTweetLength(text, { 25 | short_url_length: 23, 26 | short_url_length_https: 23 27 | }) 28 | } 29 | 30 | async authorize () { 31 | const requestToken = await this.oauth.obtainRequestToken({ 32 | url: 'http://fanfou.com/oauth/request_token', 33 | method: 'GET' 34 | }) 35 | if (!requestToken) { 36 | throw new Error(encodeError('request_token')) 37 | } 38 | 39 | await browser.runtime.sendMessage({ 40 | type: MsgType.OpenUrl, 41 | url: `https://fanfou.com/oauth/authorize?oauth_callback=oob&oauth_token=${ 42 | requestToken.key 43 | }` 44 | }) 45 | 46 | return true 47 | } 48 | 49 | async obtainAccessToken (code: string) { 50 | this.token = await this.oauth.obtainAccessToken({ 51 | url: 'http://fanfou.com/oauth/access_token', 52 | method: 'GET', 53 | data: { oauth_verifier: code } 54 | }) 55 | await this.checkAccessToken() 56 | } 57 | 58 | async checkAccessToken () { 59 | const json = await this.oauth.send( 60 | 'http://api.fanfou.com/account/verify_credentials.json', 61 | this.token 62 | ) 63 | 64 | if (json && json.profile_image_url_large) { 65 | this.user = { 66 | id: json.screen_name, 67 | name: json.name, 68 | avatar: json.profile_image_url_large 69 | } 70 | 71 | await this.setStorage() 72 | } 73 | } 74 | 75 | async postContent (text: string, img?: string | Blob) { 76 | text = await replaceUrls(text) 77 | 78 | const formData = new FormData() 79 | formData.append('status', text) 80 | if (img) { 81 | if (typeof img === 'string') { 82 | const response = await fetch(img) 83 | img = await response.blob() 84 | } 85 | formData.append('photo', img) 86 | } 87 | const json = await this.oauth.send( 88 | `http://api.fanfou.com/${img ? 'photos/upload' : 'statuses/update'}.json`, 89 | this.token, 90 | { 91 | method: 'POST', 92 | body: formData 93 | } 94 | ) 95 | if (!json || !json.created_at) { 96 | return Promise.reject(new Error(json)) 97 | } 98 | return `https://fanfou.com/statuses/${json.id}` 99 | } 100 | } 101 | 102 | export default Fanfou 103 | -------------------------------------------------------------------------------- /scripts/dev-env/webextension-background.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fake background script messaging. 3 | * This script is prepended to background script(if exist). 4 | * Only other pages should receive messages sent from background script 5 | */ 6 | 7 | // shadow the global 8 | let browser = (function () { 9 | const runtimeOnMessage = { 10 | addListener (listener) { 11 | if (typeof listener !== 'function') { 12 | throw new TypeError('Wrong argument type') 13 | } 14 | if (!window.msgBackgroundListeners.some(x => x === listener)) { 15 | window.msgBackgroundListeners.push(listener) 16 | } 17 | }, 18 | removeListener (listener) { 19 | if (typeof listener !== 'function') { 20 | throw new TypeError('Wrong argument type') 21 | } 22 | window.msgBackgroundListeners = window.msgBackgroundListeners.filter( 23 | x => x !== listener 24 | ) 25 | }, 26 | hasListener (listener) { 27 | if (typeof listener !== 'function') { 28 | throw new TypeError('Wrong argument type') 29 | } 30 | return window.msgBackgroundListeners.some(x => x === listener) 31 | } 32 | } 33 | 34 | function runtimeSendMessage (extensionId, message) { 35 | return new Promise((resolve, reject) => { 36 | if (typeof extensionId !== 'string') { 37 | message = extensionId 38 | } 39 | try { 40 | message = JSON.parse(JSON.stringify(message)) 41 | } catch (err) { 42 | return reject(new TypeError('Wrong argument type')) 43 | } 44 | 45 | let isClosed = false 46 | let isAsync = false 47 | function sendResponse (response) { 48 | if (isClosed) { 49 | return reject(new Error('Attempt to response a closed channel')) 50 | } 51 | try { 52 | // deep clone & check data 53 | response = JSON.parse(JSON.stringify(response)) 54 | } catch (err) { 55 | return reject(new TypeError('Response data not serializable')) 56 | } 57 | resolve(response) 58 | } 59 | 60 | window.msgPageListeners.forEach(listener => { 61 | const hint = listener(message, {}, sendResponse) 62 | // return true or Promise to send a response asynchronously 63 | if (hint === true) { 64 | isAsync = true 65 | } else if (hint && typeof hint.then === 'function') { 66 | isAsync = true 67 | hint.then(sendResponse) 68 | } 69 | }) 70 | 71 | // close synchronous response 72 | setTimeout(() => { 73 | if (!isAsync) { 74 | isClosed = true 75 | } 76 | }, 0) 77 | }) 78 | } 79 | 80 | // FRAGILE: Assuming all tab messages are sent to the tab that is under development 81 | // Filter out messages here if you need to narrow down 82 | function tabsSendMessage (tabId, message) { 83 | if (typeof tabId !== 'string') { 84 | return Promise.reject(new TypeError('Wrong argument type')) 85 | } 86 | return browser.runtime.sendMessage(tabId, message) 87 | } 88 | 89 | let runtime = Object.assign({}, window.browser.runtime, { 90 | sendMessage: runtimeSendMessage, 91 | onMessage: runtimeOnMessage 92 | }) 93 | let tabs = Object.assign({}, window.browser.tabs, { 94 | sendMessage: tabsSendMessage 95 | }) 96 | 97 | return Object.assign({}, window.browser, { runtime, tabs, _identity: 'cool' }) 98 | })() // eslint-disable-line 99 | -------------------------------------------------------------------------------- /src/services/twitter/service.ts: -------------------------------------------------------------------------------- 1 | import tText from 'twitter-text' 2 | import { MsgType, MsgOpenUrl } from '@/background/types' 3 | import { Service } from '../service' 4 | import { OAuth1a, Token } from '../OAuth1a' 5 | import { encodeError } from '@/helpers/error' 6 | 7 | export class Twitter extends Service { 8 | constructor () { 9 | super('twitter') 10 | } 11 | 12 | readonly maxWordCount = 280 13 | 14 | private readonly oauth = new OAuth1a({ 15 | consumer: { 16 | key: process.env.VUE_APP_TWITTER_CONSUMER_KEY || '', 17 | secret: process.env.VUE_APP_TWITTER_CONSUMER_SECRET || '' 18 | }, 19 | accessToken: this.token 20 | }) 21 | 22 | protected token: Token | null = null 23 | 24 | countWords (text: string) { 25 | return tText.getTweetLength(text, { 26 | short_url_length: 23, 27 | short_url_length_https: 23 28 | }) 29 | } 30 | 31 | async authorize () { 32 | const requestToken = await this.oauth.obtainRequestToken({ 33 | url: 'https://api.twitter.com/oauth/request_token', 34 | method: 'POST', 35 | data: { oauth_callback: 'oob' } 36 | }) 37 | if (!requestToken) { 38 | throw new Error(encodeError('request_token')) 39 | } 40 | 41 | await browser.runtime.sendMessage({ 42 | type: MsgType.OpenUrl, 43 | url: `https://api.twitter.com/oauth/authorize?oauth_token=${ 44 | requestToken.key 45 | }` 46 | }) 47 | 48 | return true 49 | } 50 | 51 | async obtainAccessToken (code: string) { 52 | this.token = await this.oauth.obtainAccessToken({ 53 | url: 'https://api.twitter.com/oauth/access_token', 54 | method: 'POST', 55 | data: { oauth_verifier: code } 56 | }) 57 | await this.checkAccessToken() 58 | } 59 | 60 | async checkAccessToken () { 61 | const json = await this.oauth.send( 62 | 'https://api.twitter.com/1.1/account/verify_credentials.json', 63 | this.token 64 | ) 65 | 66 | if (json && json.profile_image_url_https) { 67 | this.user = { 68 | id: json.screen_name, 69 | name: json.name, 70 | avatar: json.profile_image_url_https 71 | } 72 | 73 | await this.setStorage() 74 | } 75 | } 76 | 77 | async postContent (text: string, img?: string | Blob) { 78 | let mediaStr: string = '' 79 | if (img) { 80 | const formData = new FormData() 81 | if (typeof img === 'string') { 82 | const response = await fetch(img) 83 | img = await response.blob() 84 | } 85 | formData.append('media', img) 86 | const response = await this.oauth.send( 87 | 'https://upload.twitter.com/1.1/media/upload.json', 88 | this.token, 89 | { 90 | method: 'POST', 91 | body: formData 92 | } 93 | ) 94 | if (response) { 95 | mediaStr = response.media_id_string 96 | } 97 | } 98 | const json = await this.oauth.send( 99 | 'https://api.twitter.com/1.1/statuses/update.json' + 100 | `?status=${encodeURIComponent(text)}` + 101 | (mediaStr ? `&media_ids=${mediaStr}` : ''), 102 | this.token, 103 | { 104 | method: 'POST' 105 | } 106 | ) 107 | if (!json || !json.created_at) { 108 | return Promise.reject(new Error()) 109 | } 110 | return `https://twitter.com/${this.user!.id}/status/${json.id_str}` 111 | } 112 | } 113 | 114 | export default Twitter 115 | -------------------------------------------------------------------------------- /src/components/EmojiPicker.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 50 | 51 | 122 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra') 2 | const path = require('path') 3 | const argv = require('minimist')(process.argv.slice(2)) 4 | const DEV_BG_ENV = fs.readFileSync( 5 | path.join(__dirname, 'scripts/dev-env/webextension-background.js'), 6 | 'utf8' 7 | ) 8 | 9 | module.exports = { 10 | baseUrl: './', 11 | outputDir: 'dist', 12 | indexPath: 'editor.html', 13 | filenameHashing: false, 14 | devServer: { 15 | // bug @see https://github.com/vuejs/vue-cli/issues/3174 16 | disableHostCheck: true, 17 | contentBase: [path.join(__dirname, './public')] 18 | }, 19 | chainWebpack: config => { 20 | config.resolve.extensions.add('ts') 21 | 22 | // only hash assets to avoid collision 23 | config.module 24 | .rule('images') 25 | .use('url-loader') 26 | .tap(options => { 27 | options.fallback.options.name = 'img/[hash].[ext]' 28 | return options 29 | }) 30 | 31 | config.module 32 | .rule('svg') 33 | .use('file-loader') 34 | .tap(options => { 35 | options.name = 'img/[hash].[ext]' 36 | return options 37 | }) 38 | 39 | config.module 40 | .rule('media') 41 | .use('url-loader') 42 | .tap(options => { 43 | options.fallback.options.name = 'media/[hash].[ext]' 44 | return options 45 | }) 46 | 47 | config.module 48 | .rule('fonts') 49 | .use('url-loader') 50 | .tap(options => { 51 | options.fallback.options.name = 'fonts/[hash].[ext]' 52 | return options 53 | }) 54 | 55 | if (process.env.NODE_ENV === 'development') { 56 | chainWebpackDev(config) 57 | } else { 58 | chainWebpackProd(config) 59 | } 60 | } 61 | } 62 | 63 | function chainWebpackDev (config) { 64 | config 65 | .entry('app') 66 | .prepend(path.join(__dirname, 'src/background')) 67 | .prepend(path.join(__dirname, 'node_modules/webextension-polyfill')) 68 | .prepend(path.join(__dirname, 'scripts/dev-env/webextension-page')) 69 | 70 | // // example entry with html 71 | // config 72 | // .plugin('html') 73 | // .tap(args => { 74 | // args[0].filename = 'popup' 75 | // args[0].template = path.join(__dirname, './scripts/dev-env/index.html') 76 | // args[0].chunks = ['env', 'background', ...(args[0].chunks || [])] 77 | // args[0].chunksSortMode = 'manual' 78 | // return args 79 | // }) 80 | 81 | config.plugin('background-wrap').use(require('wrapper-webpack-plugin'), [ 82 | { 83 | test: /background\.js$/, 84 | header: ';(function () {\n' + DEV_BG_ENV + '\n', 85 | footer: '\n})();' 86 | } 87 | ]) 88 | } 89 | 90 | function chainWebpackProd (config) { 91 | // chunk files are loaded in manifest 92 | // disable chunk splitting 93 | config.optimization.delete('splitChunks') 94 | config.plugins.delete('preload') 95 | config.plugins.delete('prefetch') 96 | // exclude other chunks 97 | config.plugin('html').tap(args => { 98 | args[0].chunks = ['app'] 99 | return args 100 | }) 101 | 102 | config.entry('background').add(path.join(__dirname, 'src/background')) 103 | config 104 | .entry('img-extractor') 105 | .add(path.join(__dirname, 'src/content/img-extractor')) 106 | config 107 | .entry('extractor_twitter') 108 | .add(path.join(__dirname, 'src/services/twitter/extractor.ts')) 109 | config 110 | .entry('extractor_fanfou') 111 | .add(path.join(__dirname, 'src/services/fanfou/extractor.ts')) 112 | 113 | if (argv.fastbuild) { 114 | config.plugins.delete('fork-ts-checker') 115 | config.plugins.delete('optimize-css') 116 | config.delete('optimization') 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/services/helpers.ts: -------------------------------------------------------------------------------- 1 | import { MsgType, MsgPinCode, Message } from '@/background/types' 2 | import { ServiceId } from './types' 3 | import tText from 'twitter-text' 4 | 5 | export async function getServiceStorage ( 6 | serviceId: ServiceId 7 | ): Promise { 8 | const response = await browser.storage.local.get(serviceId) 9 | if (response && response[serviceId]) { 10 | try { 11 | return JSON.parse(decodeURIComponent(atob(response[serviceId]))) 12 | } catch (err) { 13 | console.warn(`get service ${serviceId} storage error`, err) 14 | } 15 | } 16 | } 17 | 18 | export function setServiceStorage ( 19 | serviceId: ServiceId, 20 | serviceStorage: T 21 | ): Promise { 22 | return browser.storage.local.set({ 23 | [serviceId]: btoa(encodeURIComponent(JSON.stringify(serviceStorage))) 24 | }) 25 | } 26 | 27 | export function clearServiceStorage (serviceId: ServiceId): Promise { 28 | return browser.storage.local.remove(serviceId) 29 | } 30 | 31 | /** 32 | * OAuth PIN code extractor 33 | * @param callback is PIN code extracted successfully 34 | */ 35 | export async function setupExtractor ( 36 | callback: () => { service: ServiceId; code: string } | void 37 | ) { 38 | const proceed = await browser.runtime.sendMessage({ 39 | type: MsgType.ExtractorReady 40 | }) 41 | if (proceed) { 42 | const interval = setInterval(() => { 43 | const result = callback() 44 | if (result) { 45 | clearInterval(interval) 46 | browser.runtime.sendMessage({ 47 | type: MsgType.PinCode, 48 | service: result.service, 49 | code: result.code 50 | }) 51 | } 52 | }, 100) 53 | } 54 | } 55 | 56 | declare global { 57 | interface Window { 58 | __shorturls__: Map 59 | } 60 | } 61 | 62 | /** 63 | * Replace all the urls in a text with shorter ones. 64 | */ 65 | export async function replaceUrls ( 66 | text: string, 67 | transformer = gitTo 68 | ): Promise { 69 | const urlsWithIndices = tText.extractUrlsWithIndices(text) 70 | if (!Array.isArray(urlsWithIndices) || urlsWithIndices.length <= 0) { 71 | return text 72 | } 73 | 74 | const entities = await Promise.all( 75 | urlsWithIndices.map(({ url }) => transformer(url)) 76 | ) 77 | .then(urls => 78 | urls.map((url, i) => ({ url, indices: urlsWithIndices[i].indices })) 79 | ) 80 | .catch(() => { 81 | console.error('Shorten urls failed') 82 | return urlsWithIndices 83 | }) 84 | 85 | let result = '' 86 | let beginIndex = 0 87 | entities.sort((a, b) => a.indices[0] - b.indices[0]) 88 | for (let i = 0; i < entities.length; i++) { 89 | const entity = entities[i] 90 | result += text.substring(beginIndex, entity.indices[0]) 91 | result += entity.url 92 | beginIndex = entity.indices[1] 93 | } 94 | result += text.substring(beginIndex, text.length) 95 | return result 96 | } 97 | 98 | export async function gitTo (url: string): Promise { 99 | let cache = window.__shorturls__ 100 | if (!cache) { 101 | cache = window.__shorturls__ = new Map() 102 | } 103 | if (cache.has(url)) { 104 | return cache.get(url)! 105 | } 106 | 107 | const result = await fetch( 108 | 'https://git.io/create', 109 | { 110 | method: 'POST', 111 | headers: { 112 | 'Content-Type': 'application/x-www-form-urlencoded' 113 | }, 114 | body: 'url=' + encodeURIComponent('https://auntlucy.github.io/jump?url=' + encodeURIComponent(url)) 115 | } 116 | ) 117 | .then(r => r.text()) 118 | .then(t => 'https://git.io/' + t) 119 | 120 | cache.set(url, result) 121 | 122 | return result 123 | } 124 | 125 | export function tinyUrl (url: string): Promise { 126 | return fetch( 127 | 'http://tinyurl.com/api-create.php?url=' + encodeURIComponent(url) 128 | ).then(r => r.text()) 129 | } 130 | -------------------------------------------------------------------------------- /src/_locales/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "err_access_token": { 3 | "description": "Unable to obtain OAuth access token", 4 | "en": "Unable to obtain OAuth access token.", 5 | "zh_CN": "无法获取访问令牌。", 6 | "zh_TW": "無法獲取訪問令牌。" 7 | }, 8 | "err_no_access_token": { 9 | "description": "Missing OAuth access token", 10 | "en": "Missing OAuth access token.", 11 | "zh_CN": "缺失访问令牌。", 12 | "zh_TW": "缺失訪問令牌。" 13 | }, 14 | "err_no_request_token": { 15 | "description": "Missing OAuth access token", 16 | "en": "Missing OAuth request token.", 17 | "zh_CN": "缺失请求令牌。", 18 | "zh_TW": "缺失請求令牌。" 19 | }, 20 | "err_post": { 21 | "description": "Post failed.", 22 | "en": "Post failed.", 23 | "zh_CN": "发送失败。", 24 | "zh_TW": "傳送失敗。" 25 | }, 26 | "err_request_token": { 27 | "description": "Unable to obtain OAuth request token", 28 | "en": "Unable to obtain OAuth request token.", 29 | "zh_CN": "无法获取请求令牌。", 30 | "zh_TW": "無法獲取請求令牌。" 31 | }, 32 | "err_shorten_urls": { 33 | "description": "Unable to shorten urls", 34 | "en": "Unable to shorten urls.", 35 | "zh_CN": "缩短链接失败。", 36 | "zh_TW": "縮短連結失敗。" 37 | }, 38 | "extension_description": { 39 | "description": "Description of extension", 40 | "en": "Post Fanfou, Weibo and Twitter in one click.", 41 | "zh_CN": "一键编辑发送饭否、推特与微博,并可抓取页面全部图片。", 42 | "zh_TW": "一鍵編輯傳送飯否、推特與微博,並可抓取頁面全部圖片。" 43 | }, 44 | "extension_name": { 45 | "description": "Extension name", 46 | "en": "Weitweet", 47 | "zh_CN": "小推Weitweet", 48 | "zh_TW": "小推Weitweet" 49 | }, 50 | "extension_short_name": { 51 | "description": "Extension short name", 52 | "en": "Weitweet", 53 | "zh_CN": "Weitweet", 54 | "zh_TW": "Weitweet" 55 | }, 56 | "fanfou": { 57 | "description": "饭否", 58 | "en": "Fanfou", 59 | "zh_CN": "饭否", 60 | "zh_TW": "飯否" 61 | }, 62 | "gallery_empty": { 63 | "description": "No image.", 64 | "en": "No image.", 65 | "zh_CN": "没有图片。", 66 | "zh_TW": "沒有圖片。" 67 | }, 68 | "loggingin": { 69 | "description": "Logging in...", 70 | "en": "Logging in...", 71 | "zh_CN": "登录中...", 72 | "zh_TW": "登入中..." 73 | }, 74 | "login": { 75 | "description": "Login", 76 | "en": "Login", 77 | "zh_CN": "登录", 78 | "zh_TW": "登入" 79 | }, 80 | "logout_confirm": { 81 | "description": "Logout account: ", 82 | "en": "Logout account: ", 83 | "zh_CN": "退出账户:", 84 | "zh_TW": "退出賬戶:" 85 | }, 86 | "not_login": { 87 | "description": "Not login", 88 | "en": "Not login", 89 | "zh_CN": "尚未登录", 90 | "zh_TW": "尚未登入" 91 | }, 92 | "post": { 93 | "description": "Send", 94 | "en": "Send", 95 | "zh_CN": "发送", 96 | "zh_TW": "傳送" 97 | }, 98 | "post_success": { 99 | "description": "Message successfully delivered", 100 | "en": ": successfully delivered", 101 | "zh_CN": ":发布成功", 102 | "zh_TW": ":傳送成功" 103 | }, 104 | "post_view_msg": { 105 | "description": "'Click here to view the message.'", 106 | "en": "Click here to view the message.", 107 | "zh_CN": "点击这里查看消息。", 108 | "zh_TW": "點選這裡檢視訊息。" 109 | }, 110 | "posting": { 111 | "description": "Posting...", 112 | "en": "Posting...", 113 | "zh_CN": "发送中...", 114 | "zh_TW": "傳送中..." 115 | }, 116 | "toggle_service": { 117 | "description": "Toggle service", 118 | "en": "Toggle service", 119 | "zh_CN": "是否用此账户发送", 120 | "zh_TW": "是否用此賬戶傳送" 121 | }, 122 | "twitter": { 123 | "description": "推特", 124 | "en": "Twitter", 125 | "zh_CN": "推特", 126 | "zh_TW": "推特" 127 | }, 128 | "unknown_error": { 129 | "description": "Unknown error occurred.", 130 | "en": "Unknown error occurred.", 131 | "zh_CN": "未知错误。", 132 | "zh_TW": "未知錯誤。" 133 | }, 134 | "upload_area": { 135 | "description": "Click or drag image files here to upload", 136 | "en": "Click or drag image files here to upload", 137 | "zh_CN": "点击或拖拽图片文件到此上传", 138 | "zh_TW": "點選或拖拽圖片檔案到此上傳" 139 | }, 140 | "upload_link": { 141 | "description": "Enter or paste image URL", 142 | "en": "Enter or paste image URL", 143 | "zh_CN": "输入或粘贴图片链接", 144 | "zh_TW": "輸入或貼上圖片連結" 145 | }, 146 | "weibo": { 147 | "description": "微博", 148 | "en": "Weibo", 149 | "zh_CN": "微博", 150 | "zh_TW": "微博" 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/services/weibo/service.ts: -------------------------------------------------------------------------------- 1 | import tText from 'twitter-text' 2 | import { Service } from '../service' 3 | import { replaceUrls, gitTo } from '../helpers' 4 | 5 | const errMsg = require('./error.json') 6 | 7 | export class Weibo extends Service { 8 | token: 9 | | undefined 10 | | { 11 | accessToken: string 12 | uid: string 13 | } 14 | 15 | constructor () { 16 | super('weibo') 17 | } 18 | 19 | readonly maxWordCount = 2000 20 | 21 | countWords (text: string) { 22 | return tText.getTweetLength(text, { 23 | short_url_length: 20, 24 | short_url_length_https: 20 25 | }) 26 | } 27 | 28 | async authorize () { 29 | const state = `weitweet-${Date.now()}` 30 | const responseUrl = await browser.identity.launchWebAuthFlow({ 31 | url: 32 | 'https://api.weibo.com/oauth2/authorize' + 33 | `?client_id=${process.env.VUE_APP_WEIBO_CONSUMER_KEY}` + 34 | `&redirect_uri=${process.env.VUE_APP_REDIRECT_URI}` + 35 | `&state=${state}` + 36 | '&response_type=code&forcelogin=true', 37 | interactive: true 38 | }) 39 | const url = new URL(responseUrl) 40 | const code = url.searchParams.get('code') 41 | if (code && state === url.searchParams.get('state')) { 42 | await this.obtainAccessToken(code) 43 | } 44 | 45 | return false 46 | } 47 | 48 | async obtainAccessToken (code: string) { 49 | const response = await fetch(`https://api.weibo.com/oauth2/access_token`, { 50 | method: 'POST', 51 | headers: { 52 | Accept: 'application/json', 53 | 'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8' 54 | }, 55 | body: 56 | `client_id=${process.env.VUE_APP_WEIBO_CONSUMER_KEY}` + 57 | `&client_secret=${process.env.VUE_APP_WEIBO_CONSUMER_SECRET}` + 58 | `&code=${code}` + 59 | `&redirect_uri=${encodeURIComponent( 60 | process.env.VUE_APP_REDIRECT_URI || '' 61 | )}` + 62 | `&grant_type=authorization_code` 63 | }) 64 | const json = await response.json() 65 | if (json.access_token && json.uid) { 66 | this.token = { 67 | accessToken: json.access_token, 68 | uid: json.uid 69 | } 70 | await this.checkAccessToken() 71 | } 72 | } 73 | 74 | async checkAccessToken () { 75 | const response = await fetch( 76 | `https://api.weibo.com/2/users/show.json?access_token=${ 77 | this.token!.accessToken 78 | }&uid=${this.token!.uid}` 79 | ) 80 | const json = await response.json() 81 | if (json && json.avatar_large) { 82 | this.user = { 83 | id: json.screen_name, 84 | name: json.name, 85 | avatar: json.avatar_large.replace(/^http:/, 'https:') 86 | } 87 | await this.setStorage() 88 | } 89 | } 90 | 91 | async postContent (text: string, img?: string | Blob) { 92 | text = await replaceUrls( 93 | text, 94 | // bypass weibo's freaking policy 95 | (url: string) => Promise.resolve('http://jump.crimx.com?url=' + encodeURIComponent(url)) 96 | ) 97 | 98 | const formattedText = toRfc3986(text) 99 | const { accessToken } = this.token! 100 | let formData: FormData | string 101 | const headers: Record = { 102 | Accept: 'application/json' 103 | } 104 | 105 | if (img) { 106 | formData = new FormData() 107 | formData.append('access_token', accessToken) 108 | formData.append('status', formattedText) 109 | if (typeof img === 'string') { 110 | const response = await fetch(img) 111 | img = await response.blob() 112 | } 113 | formData.append('pic', img) 114 | } else { 115 | formData = `access_token=${accessToken}&status=${formattedText}` 116 | headers['Content-type'] = 117 | 'application/x-www-form-urlencoded; charset=UTF-8' 118 | } 119 | 120 | const response = await fetch( 121 | `https://api.weibo.com/2/statuses/share.json`, 122 | { 123 | method: 'post', 124 | headers, 125 | body: formData 126 | } 127 | ) 128 | const json = await response.json() 129 | if (!json || !json.created_at) { 130 | let err = '' 131 | if (json && json.error_code) { 132 | err = errMsg[json.error_code] 133 | } 134 | return Promise.reject(new Error(err)) 135 | } 136 | 137 | let mid = '' 138 | try { 139 | const midResponse = await fetch( 140 | `https://api.weibo.com/2/statuses/querymid.json?access_token=${ 141 | this.token!.accessToken 142 | }&id=${json.id}&type=1` 143 | ) 144 | ;({ mid } = await midResponse.json()) 145 | } catch (err) { 146 | /* do nothing */ 147 | } 148 | // fallback to use page 149 | return `https://weibo.com/${json.user.idstr}/${mid || ''}` 150 | } 151 | } 152 | 153 | export default Weibo 154 | 155 | function toRfc3986 (val: string): string { 156 | return encodeURIComponent(val) 157 | .replace(/!/g, '%21') 158 | .replace(/\*/g, '%2A') 159 | .replace(/'/g, '%27') 160 | .replace(/\(/g, '%28') 161 | .replace(/\)/g, '%29') 162 | } 163 | -------------------------------------------------------------------------------- /src/services/weibo/error.json: -------------------------------------------------------------------------------- 1 | { 2 | "10001": "系统错误", 3 | "10002": "服务端资源不可用", 4 | "10003": "远程服务出错", 5 | "10005": "该资源需要appkey拥有更高级的授权", 6 | "10006": "缺少 source参数(appkey)", 7 | "10007": "不支持的 MediaType (%s)", 8 | "10008": "错误:参数错误,请参考API文档", 9 | "10009": "任务过多,系统繁忙", 10 | "10010": "任务超时", 11 | "10011": "RPC错误", 12 | "10012": "非法请求", 13 | "10013": "不合法的微博用户", 14 | "10014": "第三方应用访问api接口权限受限制", 15 | "10016": "错误:缺失必选参数:%s,请参考API文档", 16 | "10017": "错误:分享必须带网址", 17 | "10018": "请求长度超过限制", 18 | "10020": "接口不存在", 19 | "10021": "请求的HTTP METHOD不支持", 20 | "10022": "IP请求超过上限", 21 | "10023": "用户请求超过上限", 22 | "10024": "用户请求接口%s超过上限", 23 | "10025": "内部接口参数错误", 24 | "10026": "该接口已经废弃", 25 | "20001": "IDS参数为空", 26 | "20002": "uid参数为空", 27 | "20003": "用户不存在", 28 | "20005": "不支持的图片类型,仅仅支持JPG,GIF,PNG", 29 | "20006": "图片太大", 30 | "20007": "请确保使用multpart上传了图片", 31 | "20008": "内容为空", 32 | "20009": "id列表太长了", 33 | "20012": "输入文字太长,请确认不超过140个字符", 34 | "20013": "输入文字太长,请确认不超过300个字符", 35 | "20014": "传入参数有误,请再调用一次", 36 | "20016": "发微博太多啦,休息一会儿吧", 37 | "20017": "你刚刚已经发送过相似内容了哦,先休息一会吧", 38 | "20019": "不要太贪心哦,发一次就够啦", 39 | "20023": "很抱歉,此功能暂时无法使用,如需帮助请联系@微博客服 或者致电客服电话400 690 0000", 40 | "20031": "需要弹出验证码", 41 | "20032": "微博发布成功。目前服务器数据同步可能会有延迟,请耐心等待1-2分钟。谢谢", 42 | "20033": "登陆状态异常", 43 | "20038": "您刚才已经发过相似的内容啦,建议您第二天再尝试!", 44 | "20044": "可发表,但是需要弹出蒙层", 45 | "20045": "不可发表,需要弹出蒙层", 46 | "20101": "不存在的微博", 47 | "20102": "不是你发布的微博", 48 | "20103": "不能转发自己的微博", 49 | "20109": "微博 id为空", 50 | "20111": "不能发布相同的微博", 51 | "20112": "由于作者隐私设置,你没有权限查看此微博", 52 | "20114": "标签名太长", 53 | "20115": "标签不存在", 54 | "20116": "标签已存在", 55 | "20117": "最多200个标签", 56 | "20118": "最多5个标签", 57 | "20119": "标签搜索失败", 58 | "20120": "由于作者设置了可见性,你没有权限转发此微博", 59 | "20121": "visible参数非法", 60 | "20122": "应用不存在", 61 | "20123": "最多屏蔽200个应用", 62 | "20124": "最多屏蔽500条微博", 63 | "20125": "没有屏蔽过此应用", 64 | "20126": "不能屏蔽新浪应用", 65 | "20127": "已添加了此屏蔽", 66 | "20128": "删除屏蔽失败", 67 | "20129": "没有屏蔽任何应用", 68 | "20130": "由于作者隐私设置,你没有权限评论此微博", 69 | "20132": "抱歉,该内容暂时无法查看。如需帮助,请联系客服", 70 | "20133": "您不是会员,或者已过期,只有会员才能屏蔽应用", 71 | "20134": "分组不存在", 72 | "20135": "源微博已被删除", 73 | "20136": "非会员发表定向微博,分组成员数最多200", 74 | "20201": "不存在的微博评论", 75 | "20203": "不是你发布的评论", 76 | "20204": "评论ID为空", 77 | "20206": "作者只允许关注用户评论", 78 | "20207": "作者只允许可信用户评论", 79 | "20401": "域名不存在", 80 | "20402": "verifier错误", 81 | "20403": "屏蔽用户列表中存在此uid", 82 | "20404": "屏蔽用户列表中不存在此uid", 83 | "20405": "uid对应用户不是登录用户的好友", 84 | "20406": "屏蔽用户个数超出上限", 85 | "20407": "没有合适的uid", 86 | "20408": "从feed屏蔽列表中,处理用户失败", 87 | "20409": "当前用户不存在置顶微博", 88 | "20410": "设置置顶微博失败", 89 | "20411": "该微博不是你的微博", 90 | "20412": "当前用户已经试用微博置顶功能,不能再试用", 91 | "20413": "此微博不是置顶微博", 92 | "20414": "此微博是当前置顶微博", 93 | "20501": "错误:source_user 或者target_user用户不存在", 94 | "20502": "必须输入目标用户id或者 screen_name", 95 | "20503": "关系错误,user_id必须是你关注的用户", 96 | "20504": "你不能关注自己", 97 | "20505": "加关注请求超过上限", 98 | "20506": "已经关注此用户", 99 | "20507": "需要输入验证码", 100 | "20508": "根据对方的设置,你不能进行此操作", 101 | "20509": "悄悄关注个数到达上限", 102 | "20510": "不是悄悄关注人", 103 | "20511": "已经悄悄关注此用户", 104 | "20512": "你已经把此用户加入黑名单,加关注前请先解除", 105 | "20513": "你的关注人数已达上限", 106 | "20521": "hi超人,你今天已经关注很多喽,接下来的时间想想如何让大家都来关注你吧!", 107 | "20522": "还未关注此用户", 108 | "20523": "还不是粉丝", 109 | "20524": "hi超人,你今天已经取消关注很多喽,接下来的时间想想如何让大家都来关注你吧!", 110 | "20525": "已经是密友了", 111 | "20526": "已经发送过密友邀请", 112 | "20527": "密友数到达上限", 113 | "20528": "不是密友", 114 | "20601": "列表名太长,请确保输入的文本不超过10个字符", 115 | "20602": "列表描叙太长,请确保输入的文本不超过70个字符", 116 | "20603": "列表不存在", 117 | "20604": "不是对象所属者", 118 | "20606": "记录已存在", 119 | "20607": "错误:数据库错误,请联系系统管理员", 120 | "20608": "列表名冲突", 121 | "20610": "目前不支持私有分组", 122 | "20611": "创建list失败", 123 | "20612": "目前只支持私有分组", 124 | "20613": "错误:不能创建更多的列表", 125 | "20614": "已拥有列表上下,请参考API文档", 126 | "20615": "成员上线,请参考API文档", 127 | "20616": "不支持的分组类型", 128 | "20617": "最大返回300条", 129 | "20618": "uid 不在列表中", 130 | "20701": "不能提交相同的标签", 131 | "20702": "最多两个标签", 132 | "20704": "您已经收藏了此微博", 133 | "20705": "此微博不是您的收藏", 134 | "20706": "操作失败", 135 | "20801": "trend_name是空值", 136 | "20802": "trend_id是空值", 137 | "21001": "标签参数为空", 138 | "21002": "标签名太长,请确保每个标签名不超过14个字符", 139 | "21101": "参数domain错误", 140 | "21102": "该手机号已经被使用", 141 | "21103": "该用户已经绑定手机", 142 | "21104": "verifier错误", 143 | "21105": "你的手机号近期频繁绑定过多个帐号,如果想要继续绑定此帐号,请拨打客服电话400 690 0000申请绑定", 144 | "21108": "原始密码错误", 145 | "21109": "新密码错误", 146 | "21110": "此用户暂时没有绑定手机", 147 | "21111": "教育信息过多", 148 | "21112": "学校不存在", 149 | "21113": "教育信息不存在", 150 | "21114": "没有用户有教育信息", 151 | "21115": "职业信息不存在", 152 | "21116": "没有用户有职业信息", 153 | "21117": "此用户没有qq信息", 154 | "21118": "学校已存在", 155 | "21119": "没有合法的uid", 156 | "21120": "此用户没有微号信息", 157 | "21121": "此微号已经存在", 158 | "21122": "用户手机绑定状态为待绑定", 159 | "21123": "用户未绑定手机", 160 | "21124": "邮箱错误", 161 | "21125": "注册邮箱禁止使用新浪邮箱", 162 | "21128": "昵称已存在或非法(昵称只能支持中英文、数字、下划线或减号;昵称禁止为全数字)", 163 | "21129": "密码长度应为6到16位", 164 | "21130": "密码只允许字母,数字,键盘半角字符", 165 | "21131": "发送激活邮件失败", 166 | "21132": "注册邮箱已被占用", 167 | "21133": "注册后激活失败", 168 | "21134": "更改用户type失败", 169 | "21135": "昵称长度应为4到30位", 170 | "21136": "gender参数可选值,m表示男性,f表示女性", 171 | "21137": "参数ip无效", 172 | "21138": "参数key不是有效的无线号段", 173 | "21140": "此用户不是会员用户", 174 | "21141": "有重复的屏蔽词", 175 | "21142": "屏蔽词个数达到当前会员类型上限", 176 | "21301": "认证失败", 177 | "21302": "用户名或密码不正确", 178 | "21303": "用户名密码认证超过请求限制", 179 | "21304": "版本号错误", 180 | "21305": "缺少必要的参数", 181 | "21306": "Oauth参数被拒绝", 182 | "21307": "时间戳不正确", 183 | "21308": "nonce参数已经被使用", 184 | "21309": "签名算法不支持", 185 | "21310": "签名值不合法", 186 | "21311": "consumer_key不存在", 187 | "21312": "consumer_key不合法", 188 | "21313": "consumer_key缺失", 189 | "21314": "Token已经被使用", 190 | "21315": "Token已经过期", 191 | "21316": "Token不合法", 192 | "21317": "Token不合法", 193 | "21318": "Pin码认证失败", 194 | "21319": "授权关系已经被解除", 195 | "21320": "不支持的协议", 196 | "21321": "未审核的应用使用人数超过限制", 197 | "21322": "重定向地址不匹配", 198 | "21323": "请求不合法", 199 | "21324": "client_id或client_secret参数无效", 200 | "21325": "提供的Access Grant是无效的、过期的或已撤销的", 201 | "21326": "客户端没有权限", 202 | "21327": "token过期", 203 | "21328": "不支持的 GrantType", 204 | "21329": "不支持的 ResponseType", 205 | "21330": "用户或授权服务器拒绝授予数据访问权限", 206 | "21331": "服务暂时无法访问", 207 | "21332": "access_token 无效", 208 | "21333": "禁止使用此认证方式", 209 | "21334": "帐号状态不正常", 210 | "21501": "access_token 无效", 211 | "21502": "禁止使用此认证方式", 212 | "21503": "IP是空值", 213 | "21504": "参数url是空值", 214 | "21601": "系统繁忙请重试", 215 | "21602": "找不到模板ID XXX", 216 | "21603": "修改数据报错", 217 | "21604": "已有通知的appkey校验失败", 218 | "21605": "创建模板超过最大限制", 219 | "21610": "授权失败", 220 | "21611": "非法appkey", 221 | "21612": "无效IP", 222 | "21613": "参数错误,需要通知id或者标题内容参数", 223 | "21620": "参数错误,uid参数必须与登录用户一致,授权用户uid %s 参数uid %s", 224 | "21621": "appkey62无效", 225 | "21631": "添加屏蔽达到上限", 226 | "21632": "禁止添加未授权的应用", 227 | "21633": "此应用没有发通知权限,不许屏蔽", 228 | "21634": "已经屏蔽过了", 229 | "21650": "未找到通知id", 230 | "21651": "只有appkey所有人能发通知", 231 | "21652": "通知模板状态不对,不能发送", 232 | "21653": "通知模板与发送通知appkey不一致", 233 | "21654": "通知发送失败,请重试", 234 | "21655": "通知请求非法", 235 | "21656": "通知模板与发送通知变量不匹配", 236 | "21701": "提醒失败,需要权限", 237 | "21702": "无效分类", 238 | "21703": "无效状态码", 239 | "21901": "地理信息接口系统错误", 240 | "21902": "地理信息接口缺少source (ip) 参数", 241 | "21903": "地理信息接口不返回任何数据", 242 | "21904": "地理信息接口ip所对应的城市不存在", 243 | "21905": "地理信息接口ip地址非法", 244 | "21906": "地理信息接口经纬度坐标非法", 245 | "21907": "地理信息接口坐标超出范围", 246 | "21908": "地理信息接口超过最大请求数", 247 | "21909": "地理信息接口远程服务错误", 248 | "21910": "地理信息接口需至少提交一个城市或中心坐标参数", 249 | "21911": "地理信息接口需至少提交一个起点id或起点坐标参数", 250 | "21912": "地理信息接口需至少提交一个终点id或终点坐标参数", 251 | "21913": "地理信息接口起点坐标非法", 252 | "21914": "地理信息接口终点坐标非法", 253 | "21915": "地理信息接口起点id非法", 254 | "21916": "地理信息接口终点id非法", 255 | "21917": "地理信息接口起点和终点在不同的城市", 256 | "21918": "地理信息接口城市代码非法", 257 | "21920": "地理信息接口创建日志目录失败", 258 | "21921": "地理信息接口查询数据不能为空", 259 | "21922": "地理信息接口提交的数据格式不正确", 260 | "21923": "地理信息接口返回结果为空,没有查到相关数据", 261 | "21940": "地理信息接口所有的字段校验不能为空", 262 | "21941": "地理信息接口表单不是post方式提交", 263 | "21942": "地理信息接口数据库出错级别", 264 | "21943": "地理信息接口scrid不存在", 265 | "21944": "地理信息接口参数超出最大小值", 266 | "21945": "地理信息接口参数不是指定类型", 267 | "21951": "地理信息接口图片尺寸超出范围", 268 | "21952": "地理信息接口图片尺寸非法", 269 | "21953": "地理信息接口中心坐标非法", 270 | "21954": "地理信息接口点的名称必须存在", 271 | "21955": "地理信息接口点的图标必须存在", 272 | "21956": "地理信息接口点的名称非法", 273 | "21957": "地理信息接口点的图标非法", 274 | "21958": "地理信息接口点的坐标非法", 275 | "21959": "地理信息接口点的图标非法", 276 | "21960": "地理信息接口需至少提交一个关键字或位置分类参数", 277 | "21961": "地理信息接口页码超出范围", 278 | "21962": "地理信息接口每页的结果数超出范围", 279 | "21963": "地理信息接口需至少提交一个坐标ID或坐标经纬度参数", 280 | "21964": "地理信息接口查询半径超出范围", 281 | "21965": "地理信息接口位置ID非法", 282 | "21966": "地理信息接口缺少参数coordinates", 283 | "21971": "地理信息接口中心坐标超出范围" 284 | } 285 | -------------------------------------------------------------------------------- /scripts/dev-env/webextension-page.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Mainly for tweaking UI 3 | * Since there are still no decent mock suite for Webextension, 4 | * I faked a subset of common apis by hand to mimic the behaviours. 5 | */ 6 | import _ from 'lodash' 7 | 8 | const locales = require('@/_locales/messages.json') 9 | 10 | const platform = 11 | navigator.userAgent.indexOf('Chrome') !== -1 ? 'chrome' : 'firefox' 12 | 13 | // Tabs and runtime share the same message tunnel since we are developing only one page. 14 | // Messages won't be sent back to its own script so we use two tunnels here. 15 | window.msgPageListeners = window.msgPageListeners || [] 16 | window.msgBackgroundListeners = window.msgBackgroundListeners || [] 17 | 18 | window.browser = { 19 | browserAction: { 20 | onClicked: { 21 | addListener () {} 22 | }, 23 | setTitle () {}, 24 | setBadgeText () {}, 25 | getBadgeText (x, cb) { 26 | cb(Date.now().toString()) 27 | }, 28 | setBadgeBackgroundColor () {} 29 | }, 30 | commands: { 31 | onCommand: { 32 | addListener () {}, 33 | hasListener () {}, 34 | removeListener () {} 35 | } 36 | }, 37 | contextMenus: { 38 | onClicked: { 39 | addListener () {}, 40 | hasListener () {}, 41 | removeListener () {} 42 | }, 43 | removeAll () { 44 | return Promise.resolve() 45 | }, 46 | create () { 47 | return Promise.resolve() 48 | } 49 | }, 50 | extension: { 51 | inIncognitoContext: false 52 | }, 53 | i18n: { 54 | getMessage (k) { 55 | return locales[k] ? locales[k].zh_CN : k 56 | }, 57 | getUILanguage () { 58 | return 'en' 59 | } 60 | }, 61 | notifications: { 62 | create: _.partial(console.log, 'create notifications:'), 63 | onClicked: { 64 | addListener () {} 65 | } 66 | }, 67 | storage: genStorageApis(), 68 | tabs: { 69 | create ({ url }) { 70 | window.open(url || '') 71 | return Promise.resolve({ 72 | active: true, 73 | url: url, 74 | id: String(Date.now()) 75 | // ... add other info accordingly 76 | }) 77 | }, 78 | query () { 79 | return Promise.resolve([]) 80 | }, 81 | highlight () { 82 | return Promise.resolve() 83 | }, 84 | // No other tab to receive anyway 85 | sendMessage () { 86 | return Promise.resolve() 87 | } 88 | }, 89 | webRequest: { 90 | onBeforeRequest: { 91 | addListener () {}, 92 | hasListener () {}, 93 | removeListener () {} 94 | }, 95 | onHeadersReceived: { 96 | addListener () {}, 97 | hasListener () {}, 98 | removeListener () {} 99 | } 100 | }, 101 | runtime: { 102 | id: 'mdidnbkkjainbfbcenphabdajogedcnx', 103 | getURL (name) { 104 | return name 105 | }, 106 | getPlatformInfo () { 107 | return Promise.resolve({ os: 'win' }) 108 | }, 109 | getManifest () { 110 | return _.assign( 111 | {}, 112 | require(`../../src/manifest/common.manifest.json`), 113 | require('../../src/manifest/' + platform + '.manifest.json') 114 | ) 115 | }, 116 | reload () { 117 | window.location.reload(true) 118 | }, 119 | onStartup: { 120 | addListener (listener) { 121 | if (!_.isFunction(listener)) { 122 | throw new TypeError('Wrong argument type') 123 | } 124 | // delay startup calls 125 | setTimeout(listener, 0) 126 | } 127 | }, 128 | onInstalled: { 129 | addListener (listener) { 130 | if (!_.isFunction(listener)) { 131 | throw new TypeError('Wrong argument type') 132 | } 133 | listener({ reason: 'install' }) 134 | } 135 | }, 136 | sendMessage, 137 | onMessage: { 138 | addListener (listener) { 139 | if (!_.isFunction(listener)) { 140 | throw new TypeError('Wrong argument type') 141 | } 142 | if (!_.some(window.msgPageListeners, x => x === listener)) { 143 | window.msgPageListeners.push(listener) 144 | } 145 | }, 146 | removeListener (listener) { 147 | if (!_.isFunction(listener)) { 148 | throw new TypeError('Wrong argument type') 149 | } 150 | window.msgPageListeners = _.filter( 151 | window.msgPageListeners, 152 | x => x !== listener 153 | ) 154 | }, 155 | hasListener (listener) { 156 | if (!_.isFunction(listener)) { 157 | throw new TypeError('Wrong argument type') 158 | } 159 | return _.some(window.msgPageListeners, x => x === listener) 160 | } 161 | } 162 | } 163 | } 164 | 165 | function sendMessage (extensionId, message) { 166 | return new Promise((resolve, reject) => { 167 | if (!_.isString(extensionId)) { 168 | message = extensionId 169 | } 170 | try { 171 | message = JSON.parse(JSON.stringify(message)) 172 | } catch (err) { 173 | return reject(new TypeError('Wrong argument type')) 174 | } 175 | 176 | let isClosed = false 177 | let isAsync = false 178 | function sendResponse (response) { 179 | if (isClosed) { 180 | return reject(new Error('Attempt to response a closed channel')) 181 | } 182 | try { 183 | // deep clone & check data 184 | response = response && JSON.parse(JSON.stringify(response)) 185 | } catch (err) { 186 | return reject(new TypeError('Response data not serializable')) 187 | } 188 | resolve(response) 189 | } 190 | 191 | _.each(window.msgBackgroundListeners, listener => { 192 | const hint = listener(message, {}, sendResponse) 193 | // return true or Promise to send a response asynchronously 194 | if (hint === true) { 195 | isAsync = true 196 | } else if (hint && _.isFunction(hint.then)) { 197 | isAsync = true 198 | hint.then(sendResponse) 199 | } 200 | }) 201 | 202 | // close synchronous response 203 | setTimeout(() => { 204 | if (!isAsync) { 205 | isClosed = true 206 | } 207 | }, 0) 208 | }) 209 | } 210 | 211 | /** 212 | * For both sync and local 213 | */ 214 | function genStorageApis () { 215 | const accounts = JSON.parse(process.env.VUE_APP_ACCOUNTS) 216 | Object.keys(accounts).forEach(key => { 217 | accounts[key].enable = true 218 | accounts[key] = btoa(encodeURIComponent(JSON.stringify(accounts[key]))) 219 | }) 220 | /* global storageData */ 221 | window['storageData'] = { 222 | local: accounts, 223 | sync: {}, 224 | listeners: [] 225 | } 226 | 227 | return { 228 | sync: genStorageAreaApis('sync'), 229 | local: genStorageAreaApis('local'), 230 | managed: { 231 | // if you need to use managed area you should feed your own data 232 | get: () => ({}) 233 | }, 234 | onChanged: { 235 | addListener (listener) { 236 | if (!_.isFunction(listener)) { 237 | return Promise.reject(new TypeError('Wrong argument type')) 238 | } 239 | if (!_.some(storageData.listeners, x => x === listener)) { 240 | storageData.listeners.push(listener) 241 | } 242 | }, 243 | removeListener (listener) { 244 | if (!_.isFunction(listener)) { 245 | return Promise.reject(new TypeError('Wrong argument type')) 246 | } 247 | storageData.listeners = _.filter( 248 | storageData.listeners, 249 | x => x !== listener 250 | ) 251 | }, 252 | hasListener (listener) { 253 | if (!_.isFunction(listener)) { 254 | return Promise.reject(new TypeError('Wrong argument type')) 255 | } 256 | return _.some(storageData.listeners, x => x === listener) 257 | } 258 | } 259 | } 260 | 261 | function genStorageAreaApis (area) { 262 | return { 263 | get (keys) { 264 | if (keys == null) { 265 | return Promise.resolve(_.cloneDeep(storageData[area])) 266 | } 267 | if (_.isString(keys)) { 268 | if (!keys) { 269 | return Promise.resolve({}) 270 | } 271 | keys = [keys] 272 | } else if (_.isArray(keys)) { 273 | if (keys.length <= 0) { 274 | return Promise.resolve({}) 275 | } 276 | } else if (_.isObject(keys)) { 277 | keys = Object.keys(keys) 278 | if (keys.length <= 0) { 279 | return Promise.resolve({}) 280 | } 281 | } else { 282 | return Promise.reject(new TypeError('Wrong argument type')) 283 | } 284 | return Promise.resolve(_.pick(_.cloneDeep(storageData[area]), keys)) 285 | }, 286 | set (keys) { 287 | if (!_.isObject(keys)) { 288 | return Promise.reject(new TypeError('Argument 1 should be an object')) 289 | } 290 | try { 291 | // deep clone & check data 292 | keys = JSON.parse(JSON.stringify(keys)) 293 | } catch (err) { 294 | return Promise.reject(new TypeError('Data not serializable')) 295 | } 296 | const newData = _.assign({}, storageData[area], keys) 297 | const changes = Object.keys(keys) 298 | .filter(k => !_.isEqual(newData[k], storageData[area][k])) 299 | .reduce((x, k) => { 300 | x[k] = { 301 | newValue: _.cloneDeep(newData[k]), 302 | oldValue: _.cloneDeep(storageData[area][k]) 303 | } 304 | return x 305 | }, {}) 306 | if (Object.keys(changes).length > 0) { 307 | setTimeout(() => alertListeners(changes, area), 0) 308 | } 309 | storageData[area] = newData 310 | return Promise.resolve() 311 | }, 312 | remove (keys) { 313 | if (_.isString(keys)) { 314 | keys = keys ? [keys] : [] 315 | } else if (!_.isArray(keys)) { 316 | return Promise.reject(new TypeError('Wrong argument type')) 317 | } 318 | const newData = _.omit(storageData[area], keys) 319 | const changes = keys 320 | .filter(k => !_.isUndefined(storageData[area][k])) 321 | .reduce((x, k) => { 322 | x[k] = { 323 | newValue: undefined, 324 | oldValue: _.cloneDeep(storageData[area][k]) 325 | } 326 | return x 327 | }, {}) 328 | if (changes.length > 0) { 329 | setTimeout(() => alertListeners(changes, area), 0) 330 | } 331 | storageData[area] = newData 332 | return Promise.resolve() 333 | }, 334 | clear () { 335 | const changes = Object.keys(storageData[area]) 336 | .filter(k => !_.isUndefined(storageData[area][k])) 337 | .reduce((x, k) => { 338 | x[k] = { 339 | newValue: undefined, 340 | oldValue: _.cloneDeep(storageData[area][k]) 341 | } 342 | return x 343 | }, {}) 344 | if (changes.length > 0) { 345 | setTimeout(() => alertListeners(changes, area), 0) 346 | } 347 | storageData[area] = {} 348 | return Promise.resolve() 349 | }, 350 | getBytesInUse (keys) { 351 | if (_.isNull(keys)) { 352 | return Promise.resolve( 353 | new Blob([JSON.stringify(storageData[area])]).size 354 | ) 355 | } 356 | if (_.isString(keys)) { 357 | keys = keys ? [keys] : [] 358 | } else if (!_.isArray(keys)) { 359 | return Promise.reject(new TypeError('Wrong argument type')) 360 | } 361 | if (keys.length <= 0) { 362 | return Promise.resolve(0) 363 | } 364 | return Promise.resolve( 365 | new Blob([JSON.stringify(_.pick(storageData[area], keys))]).size 366 | ) 367 | } 368 | } 369 | } 370 | 371 | function alertListeners (changes, area) { 372 | storageData.listeners.forEach(listener => 373 | listener(_.cloneDeep(changes), area) 374 | ) 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /src/components/InputBox.vue: -------------------------------------------------------------------------------- 1 | 102 | 103 | 299 | 300 | 301 | 396 | -------------------------------------------------------------------------------- /public/img/symbol-defs.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | cc-license 5 | 6 | 7 | 8 | heart 9 | 10 | 11 | 12 | chevron down 13 | 14 | 15 | 16 | quote 17 | 18 | 19 | 20 | 21 | github 22 | 23 | 24 | 25 | codepen 26 | 27 | 28 | 29 | mail 30 | 31 | 32 | 33 | rss 34 | 35 | 36 | 37 | twitter 38 | 39 | 40 | 41 | sina-weibo 42 | 43 | 44 | 45 | zhihu 46 | 47 | 48 | 49 | bilibili 50 | 51 | 52 | 53 | douban 54 | 55 | 56 | 57 | netease-music 58 | 59 | 60 | 61 | v2ex 62 | 63 | 64 | 65 | fanfou 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | github 74 | 75 | 76 | 77 | 78 | codepen 79 | 80 | 81 | 82 | 83 | mail 84 | 85 | 86 | 87 | 88 | rss 89 | 90 | 91 | 92 | 93 | twitter 94 | 95 | 96 | 97 | 98 | sina-weibo 99 | 100 | 101 | 102 | 103 | zhihu 104 | 105 | 106 | 107 | 108 | bilibili 109 | 110 | 111 | 112 | 113 | douban 114 | 115 | 116 | 117 | 118 | netease-music 119 | 120 | 121 | 122 | 123 | v2ex 124 | 125 | 126 | 127 | 128 | fanfou 129 | 130 | 131 | 132 | 133 | 134 | --------------------------------------------------------------------------------