├── .gitignore ├── .travis.yml ├── .vscode └── launch.json ├── .yo-rc.json ├── LICENSE ├── README.md ├── __mocks__ └── src │ ├── Dom.ts │ ├── Log.ts │ ├── MALForm.ts │ ├── MockMAL.ts │ └── Util.ts ├── __tests__ ├── MAL.test.ts ├── MALEntry.test.ts └── __snapshots__ │ └── MALEntry.test.ts.snap ├── __testutils__ ├── setupJest.ts └── testData.ts ├── douki.user.js ├── meta.schema.json ├── package-lock.json ├── package.json ├── src ├── Anilist.ts ├── Dom.ts ├── Log.ts ├── MAL.ts ├── MALEntry.ts ├── MALForm.ts ├── Types.ts ├── Util.ts ├── const.ts ├── index.ts ├── meta.json └── tsconfig.json ├── tsconfig.json └── webpack.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # Visual Studio settings 61 | .vs 62 | 63 | # Build Output 64 | dist/ 65 | 66 | notes 67 | 68 | # Vs Code Go tools keep creating this; exclude it 69 | go.mod -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: node 3 | script: npm test && npm run build -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Debug Jest Tests", 11 | "cwd": "${workspaceFolder}", 12 | "args": [ 13 | "--inspect-brk", 14 | "${workspaceRoot}/node_modules/.bin/jest", 15 | "--runInBand" 16 | ], 17 | "console": "integratedTerminal", 18 | "internalConsoleOptions": "neverOpen" 19 | }, 20 | ] 21 | } -------------------------------------------------------------------------------- /.yo-rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "generator-userscript": { 3 | "promptValues": { 4 | "namespace": "http://gilmoreg.com" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Grayson Gilmore 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Doukiscript 2 | 3 | ## Disclaimer 4 | Use this script at your own risk! The author assumes no responsibility for any damages of any kind. It is *strongly* recommended you test this out on a throwaway MAL account before attempting to sync your main account. 5 | 6 | ## About 7 | So you're an Anilist user (perhaps you came over after MAL melted down) but you still want to keep your MAL page up to date for all your friends who still use it. Douki can sync your Anilist lists to MAL with the click of a button. 8 | 9 | Unfortunately, given that MAL shut down its public API over security concerns, the only way to modify a MAL list is from the MAL site itself. Thus, Douki is no longer a standalone web app but a userscript that can run right on MAL and use its API. 10 | 11 | ## Usage 12 | 1. Install a userscript manager ([choose one from this list](https://greasyfork.org/en)) 13 | 2. Install the script [here](https://greasyfork.org/en/scripts/373467-douki) 14 | 3. Visit [the import page on Myanimelist.net](https://myanimelist.net/import.php). *You need to be logged in to MAL.* 15 | 4. Ensure the date setting matches your setting on MAL (US or Euro) 16 | 5. Alternatively, a link to the import page is added to the List dropdown at the top of the main page 17 | 6. Fill in your Anilist username and hit `Import` 18 | 19 | ## Notes 20 | - The most common source of errors are titles that are not yet approved on Myanimelist. These cannot be added even manually. **Before reporting errors, check to see if you can add an item manually. If you can't add it, neither can Douki.** 21 | - All custom scoring formats on Anilist (1-5, 1-100, stars) will be converted to MAL's 1-10 system. The scores will round down (i.e. a 95 will become a 9). This follows an established community practice. 22 | - Custom lists will be imported into the main MAL list. 23 | - Private lists will be ignored. 24 | - Tags and notes will be ignored. Keeping these consistent across the two sites is too difficult for now. I am open to attempting this in the future, but no promises. 25 | - Due to a quirk with MAL's site, changing the number of times you've rewatched a show or reread a manga alone will not trigger an update. You need to also change status, episode/chapter count, or score for the script to pick up on a change and update the entry. 26 | 27 | Please report issues [on the Anilist forum thread](https://anilist.co/forum/thread/2654). All suggestions, feedback, or bug reports are welcome. 28 | -------------------------------------------------------------------------------- /__mocks__/src/Dom.ts: -------------------------------------------------------------------------------- 1 | import { IDomMethods } from "../../src/Dom"; 2 | 3 | export default class FakeDomMethods implements IDomMethods { 4 | dateSetting: string 5 | constructor(dateSetting = 'a') { 6 | this.dateSetting = dateSetting; 7 | } 8 | 9 | addDropDownItem() { } 10 | 11 | addImportForm(syncFn: Function) { 12 | return jest.fn(); 13 | } 14 | 15 | getDateSetting(): string { 16 | return this.dateSetting; 17 | }; 18 | 19 | getDebugSetting(): boolean { 20 | return false; 21 | } 22 | 23 | getCSRFToken(): string { 24 | return 'csrfToken'; 25 | } 26 | 27 | getMALUsername(): string { 28 | return 'malUsername'; 29 | } 30 | 31 | getAnilistUsername(): string { 32 | return 'anilistUsername'; 33 | } 34 | } -------------------------------------------------------------------------------- /__mocks__/src/Log.ts: -------------------------------------------------------------------------------- 1 | import { ILog } from "../../src/Log"; 2 | 3 | export default class FakeLog implements ILog { 4 | clear() { 5 | console.log('clearing console'); 6 | } 7 | error(msg: string) { 8 | console.log('logging error', msg); 9 | } 10 | info(msg: string) { 11 | console.log('logging info', msg); 12 | } 13 | debug(msg: string) { 14 | console.debug('logging debug', msg); 15 | } 16 | addCountLog(op: string, type: string, max: number) { 17 | console.log('creating count log for', op, type, max); 18 | } 19 | updateCountLog(op: string, type: string, ct: number) { 20 | console.log('updating count log for', op, type, ct); 21 | } 22 | } 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /__mocks__/src/MALForm.ts: -------------------------------------------------------------------------------- 1 | import { IMALForm } from '../../src/MALForm'; 2 | 3 | export type FakeFormData = { 4 | priority: string 5 | storageType: string 6 | storageValue: string 7 | numRetailVolumes: string 8 | rewatchValue: string 9 | rereadValue: string 10 | comments: string 11 | discussionSetting: string 12 | SNSSetting: string 13 | } 14 | 15 | const defaultFakeFormData: FakeFormData = { 16 | priority: '0', 17 | storageType: '0', 18 | storageValue: '0', 19 | numRetailVolumes: '0', 20 | rewatchValue: '0', 21 | rereadValue: '0', 22 | comments: 'comments', 23 | discussionSetting: '0', 24 | SNSSetting: '0' 25 | } 26 | 27 | export class FakeMALForm implements IMALForm { 28 | _priority: string 29 | _storageType: string 30 | _storageValue: string 31 | _numRetailVolumes: string 32 | _rewatchValue: string 33 | _rereadValue: string 34 | _comments: string 35 | _discussionSetting: string 36 | _SNSSetting: string 37 | 38 | constructor(data: FakeFormData = defaultFakeFormData) { 39 | this._priority = data.priority; 40 | this._storageType = data.storageType; 41 | this._storageValue = data.storageValue; 42 | this._numRetailVolumes = data.numRetailVolumes; 43 | this._rewatchValue = data.rewatchValue; 44 | this._rereadValue = data.rereadValue; 45 | this._comments = data.comments; 46 | this._discussionSetting = data.discussionSetting; 47 | this._SNSSetting = data.SNSSetting; 48 | } 49 | 50 | get(): Promise { 51 | return Promise.resolve(); 52 | } 53 | get priority(): string { 54 | return this._priority; 55 | } 56 | get storageType(): string { 57 | return this._storageType; 58 | } 59 | get storageValue(): string { 60 | return this._storageValue; 61 | } 62 | get numRetailVolumes(): string { 63 | return this._numRetailVolumes; 64 | } 65 | get rewatchValue(): string { 66 | return this._rewatchValue; 67 | } 68 | get rereadValue(): string { 69 | return this._rereadValue; 70 | } 71 | get comments(): string { 72 | return this._comments; 73 | } 74 | get discussionSetting(): string { 75 | return this._discussionSetting; 76 | } 77 | get SNSSetting(): string { 78 | return this._SNSSetting; 79 | } 80 | } -------------------------------------------------------------------------------- /__mocks__/src/MockMAL.ts: -------------------------------------------------------------------------------- 1 | import * as fetchMock from 'fetch-mock'; 2 | import * as T from '../../src/Types'; 3 | import * as fakes from '../../__testutils__/testData'; 4 | 5 | const defaultAnime: T.MALLoadAnime[] = [fakes.createFakeMALAnime()]; 6 | const defaultManga: T.MALLoadManga[] = [fakes.createFakeMALManga()]; 7 | 8 | // https://myanimelist.net/${type}list/${this.username}/load.json?offset=${offset}&status=7 9 | const loadRegex = /^https:\/\/myanimelist\.net\/(.+)list\/.+\/load\.json\?offset=(.+)&status=7$/; 10 | 11 | // https://myanimelist.net/ownlist/${type}/${id}/edit?hideLayout 12 | const editRegex = /^https:\/\/myanimelist\.net\/ownlist\/(.+)\/(.+)\/edit\?hideLayout$/; 13 | 14 | // https://myanimelist.net/ownlist/${data.type}/add.json 15 | const addRegex = /^https:\/\/myanimelist\.net\/ownlist\/(.+)\/add\.json$/; 16 | 17 | const animeDb: any = { 18 | 1: { 19 | anime_num_episodes: 12, 20 | }, 21 | 3: { 22 | anime_num_episodes: 0, 23 | } 24 | } 25 | 26 | const mangaDb: any = { 27 | 2: { 28 | manga_num_chapters: 12, 29 | manga_num_volumes: 2, 30 | manga_publishing_status: 0, 31 | }, 32 | 4: { 33 | manga_num_chapters: 0, 34 | manga_num_volumes: 0, 35 | manga_publishing_status: 1, 36 | } 37 | } 38 | 39 | const parseFormData = (formData: string) => { 40 | const result: any = {}; 41 | formData.split('&').forEach(i => { 42 | const [key, value] = i.split('='); 43 | const parsedKey = key.replace(/%5B/g, '[').replace(/%5D/g, ']'); 44 | result[parsedKey] = value; 45 | }); 46 | return result; 47 | } 48 | 49 | const createDateString = (listType: string, dateType: string, f: any): string => { 50 | const rawMonth = f[`add_${listType}[${dateType}_date][month]`]; 51 | const rawDay = f[`add_${listType}[${dateType}_date][day]`]; 52 | const rawYear = f[`add_${listType}[${dateType}_date][year]`]; 53 | 54 | const month = `${String(rawMonth).length < 2 ? '0' : ''}${rawMonth}`; 55 | const day = `${String(rawDay).length < 2 ? '0' : ''}${rawDay}`; 56 | const year = `${rawYear ? String(rawYear).slice(-2) : 0}`; 57 | 58 | // Assuming dateSetting = 'a': 59 | return `${month}-${day}-${year}`; 60 | } 61 | 62 | const formDataToLoadAnime = (f: T.MALAnimeFormData, e: T.MALLoadAnime): T.MALLoadAnime => { 63 | const entry: T.MALLoadAnime = e || fakes.createFakeMALAnime(); 64 | return { 65 | anime_id: Number(f.anime_id), 66 | num_watched_episodes: Number(f['add_anime[num_watched_episodes]']), 67 | anime_num_episodes: entry.anime_num_episodes, 68 | anime_airing_status: entry.anime_airing_status, 69 | finish_date_string: createDateString('anime', 'finish', f), 70 | start_date_string: createDateString('anime', 'start', f), 71 | priority_string: `${f['add_anime[priority]']}`, 72 | comments: f['add_anime[comments]'], 73 | score: Number(f['add_anime[score]']), 74 | csrf_token: entry.csrf_token, 75 | status: Number(f['add_anime[status]']), 76 | } 77 | }; 78 | 79 | const formDataToLoadManga = (f: T.MALMangaFormData, e: T.MALLoadManga): T.MALLoadManga => { 80 | const entry: T.MALLoadManga = e || fakes.createFakeMALManga(); 81 | return { 82 | manga_id: Number(f.manga_id), 83 | num_read_chapters: Number(f['add_manga[num_read_chapters]']), 84 | num_read_volumes: Number(f['add_manga[num_read_volumes]']), 85 | manga_num_chapters: entry.manga_num_chapters, 86 | manga_num_volumes: entry.manga_num_volumes, 87 | manga_publishing_status: entry.manga_publishing_status, 88 | finish_date_string: createDateString('manga', 'finish', f), 89 | start_date_string: createDateString('manga', 'start', f), 90 | priority_string: `${f['add_manga[priority]']}`, 91 | comments: f['add_manga[comments]'], 92 | score: Number(f['add_manga[score]']), 93 | csrf_token: entry.csrf_token, 94 | status: Number(f['add_manga[status]']), 95 | }; 96 | } 97 | 98 | class MockMAL { 99 | anime: T.MALLoadAnime[] 100 | manga: T.MALLoadManga[] 101 | 102 | constructor(anime: T.MALLoadAnime[] = [], manga: T.MALLoadManga[] = []) { 103 | this.anime = anime; 104 | this.manga = manga; 105 | 106 | this.load = this.load.bind(this); 107 | this.add = this.add.bind(this); 108 | this.edit = this.edit.bind(this); 109 | 110 | fetchMock.mock(/.+load.json.+/, url => this.load(url)); 111 | fetchMock.mock(/.+add.+/, (url, opts) => this.add(url, opts)); 112 | fetchMock.mock(/.+edit.+/, (url, opts) => this.edit(url, opts)); 113 | } 114 | 115 | load(url: string): T.MALLoadItem[] { 116 | // @ts-ignore 117 | const [_, listType, offset] = loadRegex.exec(url); 118 | if (Number(offset) > 0) return []; 119 | 120 | return listType === 'anime' ? this.anime : this.manga; 121 | } 122 | 123 | add(url: string, opts: any): string { 124 | // @ts-ignore 125 | const [_, type] = addRegex.exec(url); 126 | 127 | const entry: any = JSON.parse(opts.body); 128 | 129 | if (type === 'anime') { 130 | if (!animeDb[entry.anime_id]) { 131 | throw new Error('unknown anime'); 132 | } 133 | const fullEntry = { ...animeDb[entry.anime_id], ...entry }; 134 | this.anime.push(fullEntry); 135 | } else { 136 | if (!mangaDb[entry.manga_id]) { 137 | throw new Error('unknown manga'); 138 | } 139 | const fullEntry = { ...mangaDb[entry.manga_id], ...entry }; 140 | this.manga.push(fullEntry); 141 | } 142 | 143 | return ' Successfully added entry '; 144 | } 145 | 146 | edit(url: string, opts: any): string { 147 | // @ts-ignore 148 | const [_, listType, id] = editRegex.exec(url); 149 | const nid = Number(id); 150 | const entry = listType === 'anime' ? 151 | this.anime.find(i => i.anime_id === nid) : 152 | this.manga.find(i => i.manga_id === nid); 153 | if (!entry) return ' Not found '; 154 | 155 | const formData = parseFormData(opts.body); 156 | 157 | if (listType === 'anime') { 158 | this.anime = this.anime.map(i => { 159 | if (i.anime_id === nid) { 160 | return formDataToLoadAnime(formData, entry as T.MALLoadAnime); 161 | } 162 | return i; 163 | }); 164 | } else { 165 | this.manga = this.manga.map(i => { 166 | if (i.manga_id === nid) { 167 | return formDataToLoadManga(formData, entry as T.MALLoadManga); 168 | } 169 | return i; 170 | }); 171 | } 172 | 173 | return ' Successfully updated entry '; 174 | } 175 | } 176 | 177 | export default MockMAL; -------------------------------------------------------------------------------- /__mocks__/src/Util.ts: -------------------------------------------------------------------------------- 1 | const mockUtil = { 2 | sleep: () => Promise.resolve(), 3 | id: (str: string) => `#${str}`, 4 | getOperationDisplayName: (operation: string) => operation, 5 | } 6 | 7 | export default mockUtil; -------------------------------------------------------------------------------- /__tests__/MAL.test.ts: -------------------------------------------------------------------------------- 1 | import MAL from '../src/MAL'; 2 | import FakeLog from '../__mocks__/src/Log'; 3 | import * as fetchMock from 'fetch-mock'; 4 | import * as fakes from '../__testutils__/testData'; 5 | import MockMAL from '../__mocks__/src/MockMAL'; 6 | 7 | jest.mock('../src/Util') 8 | 9 | const createFakeMAL = () => new MAL('test', 'csrfToken', new FakeLog(), fakes.createFakeDomMethods()); 10 | 11 | describe('syncType()', () => { 12 | beforeAll(() => fetchMock.catch(500)); 13 | afterEach(() => fetchMock.restore()); 14 | 15 | it('should skip sync when items are the same', async () => { 16 | new MockMAL([fakes.createFakeMALAnime()]); 17 | const mal = new MAL('test', 'csrfToken', new FakeLog(), fakes.createFakeDomMethods()); 18 | await mal.syncType('anime', [fakes.createFakeAnilistAnime()]); 19 | // Two calls to load list, no refresh, no calls to edit 20 | expect(fetchMock.calls().length).toBe(2); 21 | }); 22 | 23 | it('should sync when episode count is different', async () => { 24 | const malAnime = fakes.createFakeMALAnime({ status: 1, num_watched_episodes: 1 }); 25 | const alAnime = fakes.createFakeAnilistAnime({ status: 'CURRENT', progress: 2 }); 26 | const mockMAL = new MockMAL([malAnime]); 27 | const mal = createFakeMAL(); 28 | await mal.syncType('anime', [alAnime]); 29 | const [result] = mockMAL.anime; 30 | expect(result.num_watched_episodes).toEqual(2); 31 | }); 32 | 33 | it('should add a new manga', async () => { 34 | const malManga = fakes.createFakeMALManga(); 35 | const alManga = fakes.createFakeAnilistManga(); 36 | const mockMAL = new MockMAL(); 37 | const mal = createFakeMAL(); 38 | await mal.syncType('manga', [alManga]); 39 | const [result] = mockMAL.manga; 40 | expect(result).toEqual(malManga); 41 | }); 42 | 43 | it('should sync a manga when volume count is different', async () => { 44 | const malManga = fakes.createFakeMALManga({ status: 1, manga_num_volumes: 2, num_read_volumes: 1 }); 45 | const alManga = fakes.createFakeAnilistManga({ status: 'CURRENT', progressVolumes: 2 }); 46 | const mockMAL = new MockMAL(undefined, [malManga]); 47 | const mal = createFakeMAL(); 48 | await mal.syncType('manga', [alManga]); 49 | const [result] = mockMAL.manga; 50 | expect(result.num_read_volumes).toEqual(2); 51 | }); 52 | 53 | it('should use MAL episode counts for completed shows when AL is higher', async () => { 54 | // MAL counts this show as having 12 episodes; let's say AL says it has 13 55 | const alAnime = fakes.createFakeAnilistAnime({ progress: 13 }); 56 | const mockMAL = new MockMAL(); 57 | const mal = createFakeMAL(); 58 | await mal.syncType('anime', [alAnime]); 59 | const [result] = mockMAL.anime; 60 | // Should reflect MAL's count, not AL's in the end 61 | expect(result.num_watched_episodes).toEqual(12); 62 | }); 63 | 64 | it('should use MAL chapter counts for completed manga when AL is higher', async () => { 65 | // MAL counts this manga as having 12 chapters; let's say AL says it has 13 66 | const alManga = fakes.createFakeAnilistManga({ progress: 13 }); 67 | const mockMAL = new MockMAL(); 68 | const mal = createFakeMAL(); 69 | await mal.syncType('manga', [alManga]); 70 | const [result] = mockMAL.manga; 71 | // Should reflect MAL's count, not AL's in the end 72 | expect(result.num_read_chapters).toEqual(12); 73 | }); 74 | 75 | it('should use MAL volume counts for completed manga when AL is higher', async () => { 76 | // MAL counts this manga as having 2 volumes; let's say AL says it has 3 77 | const alManga = fakes.createFakeAnilistManga({ progressVolumes: 3 }); 78 | const mockMAL = new MockMAL(); 79 | const mal = createFakeMAL(); 80 | await mal.syncType('manga', [alManga]); 81 | const [result] = mockMAL.manga; 82 | // Should reflect MAL's count, not AL's in the end 83 | expect(result.num_read_volumes).toEqual(2); 84 | }); 85 | 86 | it('should use MAL episode count for completed show when AL is lower', async () => { 87 | // MAL counts this show as having 12 episodes; let's say AL says it has 11 88 | const alAnime = fakes.createFakeAnilistAnime({ status: 'COMPLETED', progress: 11 }); 89 | const mockMAL = new MockMAL(); 90 | const mal = createFakeMAL(); 91 | await mal.syncType('anime', [alAnime]); 92 | const [result] = mockMAL.anime; 93 | // Should reflect MAL's count, not AL's in the end 94 | expect(result.num_watched_episodes).toEqual(12); 95 | }); 96 | 97 | it('should use MAL chapter counts for completed manga when AL is lower', async () => { 98 | // MAL counts this manga as having 12 chapters; let's say AL says it has 11 99 | const alManga = fakes.createFakeAnilistManga({ status: 'COMPLETED', progress: 11 }); 100 | const mockMAL = new MockMAL(); 101 | const mal = createFakeMAL(); 102 | await mal.syncType('manga', [alManga]); 103 | const [result] = mockMAL.manga; 104 | // Should reflect MAL's count, not AL's in the end 105 | expect(result.num_read_chapters).toEqual(12); 106 | }); 107 | 108 | it('should use MAL volume counts for completed manga when AL is lower', async () => { 109 | // MAL counts this manga as having 2 volumes; let's say AL says it has 1 110 | const alManga = fakes.createFakeAnilistManga({ status: 'COMPLETED', progressVolumes: 1 }); 111 | const mockMAL = new MockMAL(); 112 | const mal = createFakeMAL(); 113 | await mal.syncType('manga', [alManga]); 114 | const [result] = mockMAL.manga; 115 | // Should reflect MAL's count, not AL's in the end 116 | expect(result.num_read_volumes).toEqual(2); 117 | }); 118 | 119 | it('should use AL count when MAL has 0 episodes', async () => { 120 | const alAnime = fakes.createFakeAnilistAnime({ id: 3 }); 121 | const mockMAL = new MockMAL(); 122 | const mal = createFakeMAL(); 123 | await mal.syncType('anime', [alAnime]); 124 | const [result] = mockMAL.anime; 125 | expect(result.num_watched_episodes).toEqual(12); 126 | }); 127 | 128 | it('should use AL count when MAL has 0 chapters/volumes', async () => { 129 | const alManga = fakes.createFakeAnilistManga({ id: 4 }); 130 | const mockMAL = new MockMAL(); 131 | const mal = createFakeMAL(); 132 | await mal.syncType('manga', [alManga]); 133 | const [result] = mockMAL.manga; 134 | expect(result.num_read_chapters).toEqual(12); 135 | expect(result.num_read_volumes).toEqual(2); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /__tests__/MALEntry.test.ts: -------------------------------------------------------------------------------- 1 | import { MALEntryAnime, MALEntryManga } from '../src/MALEntry'; 2 | import * as fakes from '../__testutils__/testData'; 3 | 4 | jest.mock('../src/Util') 5 | 6 | const fakeDomMethods = fakes.createFakeDomMethods(); 7 | 8 | const parseFormData = (formData: string) => { 9 | const result: any = {}; 10 | formData.split('&').forEach(i => { 11 | const [key, value] = i.split('='); 12 | result[key] = value; 13 | }); 14 | return result; 15 | } 16 | 17 | describe('shouldUpdate()', () => { 18 | it('should not update if data is the same', () => { 19 | const malAnime = fakes.createFakeMALAnime(); 20 | const alAnime = fakes.createFakeAnilistAnime(); 21 | const malEntry = new MALEntryAnime(alAnime, malAnime, 'csrfToken', fakeDomMethods); 22 | const result = malEntry.shouldUpdate(); 23 | expect(result).toEqual(false); 24 | }); 25 | 26 | it('should update if the start date is different', () => { 27 | const malAnime = fakes.createFakeMALAnime({ start_date_string: '2-2-2002' }); 28 | const alAnime = fakes.createFakeAnilistAnime({ startedAt: { year: 2002, month: 1, day: 1 } }); 29 | const malEntry = new MALEntryAnime(alAnime, malAnime, 'csrfToken', fakeDomMethods); 30 | const result = malEntry.shouldUpdate(); 31 | expect(result).toEqual(true); 32 | }); 33 | 34 | it('should update if the episode count is different and show is incomplete', () => { 35 | const malAnime = fakes.createFakeMALAnime({ status: 1, num_watched_episodes: 1 }); 36 | const alAnime = fakes.createFakeAnilistAnime({ status: 'CURRENT', proress: 2 }); 37 | const malEntry = new MALEntryAnime(alAnime, malAnime, 'csrfToken', fakeDomMethods); 38 | const result = malEntry.shouldUpdate(); 39 | expect(result).toEqual(true); 40 | }); 41 | 42 | it('should not update if episode counts differ but show is complete', () => { 43 | const malAnime = fakes.createFakeMALAnime({ num_watched_episodes: 1 }); 44 | const alAnime = fakes.createFakeAnilistAnime({ progress: 2 }); 45 | const malEntry = new MALEntryAnime(alAnime, malAnime, 'csrfToken', fakeDomMethods); 46 | const result = malEntry.shouldUpdate(); 47 | expect(result).toEqual(false); 48 | }); 49 | }); 50 | 51 | describe('formData()', () => { 52 | it('should return default values of correct types for anime', async () => { 53 | const malAnime = fakes.createFakeMALAnime(); 54 | const alAnime = fakes.createFakeAnilistAnime(); 55 | const malEntry = new MALEntryAnime(alAnime, malAnime, 'csrfToken', fakeDomMethods); 56 | const result = await malEntry.formData(); 57 | const json = parseFormData(result); 58 | expect(json).toMatchSnapshot(); 59 | expect(json['add_anime%5Bis_rewatching%5D']).toBeUndefined(); 60 | }); 61 | 62 | it('should return default values of correct types for manga', async () => { 63 | const malManga = fakes.createFakeMALManga(); 64 | const alManga = fakes.createFakeAnilistManga(); 65 | const malEntry = new MALEntryManga(alManga, malManga, 'csrfToken', fakeDomMethods); 66 | const result = await malEntry.formData(); 67 | const json = parseFormData(result); 68 | expect(json).toMatchSnapshot(); 69 | expect(json['add_manga%5Bis_rewatching%5D']).toBeUndefined(); 70 | }); 71 | 72 | it('should set rewatching for anime', async () => { 73 | const malAnime = fakes.createFakeMALAnime(); 74 | const alAnime = fakes.createFakeAnilistAnime({ status: 'REPEATING' }); 75 | const malEntry = new MALEntryAnime(alAnime, malAnime, 'csrfToken', fakeDomMethods); 76 | const result = await malEntry.formData(); 77 | const json = parseFormData(result); 78 | expect(json['add_anime%5Bis_rewatching%5D']).toEqual('1'); 79 | }); 80 | 81 | it('should set rewatching for manga', async () => { 82 | const malManga = fakes.createFakeMALManga(); 83 | const alManga = fakes.createFakeAnilistManga({ status: 'REPEATING' });; 84 | const malEntry = new MALEntryManga(alManga, malManga, 'csrfToken', fakeDomMethods); 85 | const result = await malEntry.formData(); 86 | const json = parseFormData(result); 87 | expect(json['add_manga%5Bis_rewatching%5D']).toEqual('1'); 88 | }); 89 | }); 90 | 91 | describe('createPostData()', () => { 92 | it('should set ep count to 0 if MAL data is not available for anime', () => { 93 | const alAnime = fakes.createFakeAnilistAnime(); 94 | // @ts-ignore 95 | const malEntry = new MALEntryAnime(alAnime, undefined, 'csrfToken', fakeDomMethods); 96 | expect(malEntry._postData.num_watched_episodes).toEqual(0); 97 | }); 98 | 99 | it('should set ch/vol counts to 0 if MAL data is not available for manga', () => { 100 | const alManga = fakes.createFakeAnilistAnime(); 101 | // @ts-ignore 102 | const malEntry = new MALEntryManga(alManga, undefined, 'csrfToken', fakeDomMethods); 103 | expect(malEntry._postData.num_read_chapters).toEqual(0); 104 | expect(malEntry._postData.num_read_volumes).toEqual(0); 105 | }); 106 | 107 | it('uses MAL count if AL count is higher for completed manga', () => { 108 | const malManga = fakes.createFakeMALManga({ manga_num_chapters: 11, manga_num_volumes: 1 }); 109 | const alManga = fakes.createFakeAnilistManga({ progress: 12, progressVolumes: 2 }); 110 | const malEntry = new MALEntryManga(alManga, malManga, 'csrfToken', fakeDomMethods); 111 | expect(malEntry._postData.num_read_chapters).toEqual(11); 112 | expect(malEntry._postData.num_read_volumes).toEqual(1); 113 | }); 114 | }); -------------------------------------------------------------------------------- /__tests__/__snapshots__/MALEntry.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`formData() should return default values of correct types for anime 1`] = ` 4 | Object { 5 | "add_anime%5Bcomments%5D": "comments", 6 | "add_anime%5Bfinish_date%5D%5Bday%5D": "1", 7 | "add_anime%5Bfinish_date%5D%5Bmonth%5D": "1", 8 | "add_anime%5Bfinish_date%5D%5Byear%5D": "99", 9 | "add_anime%5Bis_asked_to_discuss%5D": "0", 10 | "add_anime%5Bnum_watched_episodes%5D": "12", 11 | "add_anime%5Bnum_watched_times%5D": "1", 12 | "add_anime%5Bpriority%5D": "0", 13 | "add_anime%5Brewatch_value%5D": "0", 14 | "add_anime%5Bscore%5D": "10", 15 | "add_anime%5Bsns_post_type%5D": "0", 16 | "add_anime%5Bstart_date%5D%5Bday%5D": "1", 17 | "add_anime%5Bstart_date%5D%5Bmonth%5D": "1", 18 | "add_anime%5Bstart_date%5D%5Byear%5D": "99", 19 | "add_anime%5Bstatus%5D": "2", 20 | "add_anime%5Bstorage_type%5D": "0", 21 | "add_anime%5Bstorage_value%5D": "0", 22 | "add_anime%5Btags%5D": "", 23 | "aeps": "12", 24 | "anime_id": "1", 25 | "astatus": "2", 26 | "csrf_token": "csrfToken", 27 | "submitIt": "0", 28 | } 29 | `; 30 | 31 | exports[`formData() should return default values of correct types for manga 1`] = ` 32 | Object { 33 | "add_manga%5Bcomments%5D": "comments", 34 | "add_manga%5Bfinish_date%5D%5Bday%5D": "1", 35 | "add_manga%5Bfinish_date%5D%5Bmonth%5D": "1", 36 | "add_manga%5Bfinish_date%5D%5Byear%5D": "99", 37 | "add_manga%5Bis_asked_to_discuss%5D": "0", 38 | "add_manga%5Bnum_read_chapters%5D": "12", 39 | "add_manga%5Bnum_read_times%5D": "1", 40 | "add_manga%5Bnum_read_volumes%5D": "2", 41 | "add_manga%5Bnum_retail_volumes%5D": "0", 42 | "add_manga%5Bpriority%5D": "0", 43 | "add_manga%5Breread_value%5D": "0", 44 | "add_manga%5Bscore%5D": "10", 45 | "add_manga%5Bsns_post_type%5D": "0", 46 | "add_manga%5Bstart_date%5D%5Bday%5D": "1", 47 | "add_manga%5Bstart_date%5D%5Bmonth%5D": "1", 48 | "add_manga%5Bstart_date%5D%5Byear%5D": "99", 49 | "add_manga%5Bstatus%5D": "2", 50 | "add_manga%5Bstorage_type%5D": "0", 51 | "add_manga%5Btags%5D": "", 52 | "csrf_token": "csrfToken", 53 | "entry_id": "0", 54 | "last_completed_vol": "", 55 | "manga_id": "2", 56 | "submitIt": "0", 57 | } 58 | `; 59 | -------------------------------------------------------------------------------- /__testutils__/setupJest.ts: -------------------------------------------------------------------------------- 1 | import * as fetch from 'node-fetch'; 2 | // @ts-ignore 3 | global.fetch = fetch; 4 | 5 | /* 6 | NodeJS.Global.setTimeout: (callback: (...args: any[]) => void, ms: number, ...args: any[]) => NodeJS.Timer 7 | */ 8 | 9 | class FakeDOM { 10 | querySelector(selector: string) { 11 | switch (selector) { 12 | case '#add_anime_comments': 13 | case '#add_manga_comments': 14 | return { 15 | value: 'comments' 16 | } 17 | default: 18 | return { 19 | value: '0' 20 | } 21 | } 22 | } 23 | } 24 | 25 | class FakeXHR { 26 | onload: Function = () => { }; 27 | onerror: Function = () => { }; 28 | responseType: string = 'document'; 29 | open(method: string, url: string) { 30 | console.log(`Making XHR ${method} request to ${url}`); 31 | } 32 | send() { 33 | this.onload(); 34 | } 35 | responseXML: FakeDOM 36 | 37 | constructor() { 38 | this.responseXML = new FakeDOM(); 39 | } 40 | } 41 | // @ts-ignore 42 | global.XMLHttpRequest = FakeXHR; -------------------------------------------------------------------------------- /__testutils__/testData.ts: -------------------------------------------------------------------------------- 1 | import * as Types from '../src/Types'; 2 | import { IDomMethods } from '../src/Dom'; 3 | 4 | const createDate = (year = 0, month = 0, day = 0) => ({ year, month, day }); 5 | 6 | const malItem: Types.BaseMALItem = { 7 | status: 2, 8 | csrf_token: 'csrfToken', 9 | score: 10, 10 | finish_date_string: '01-01-99', 11 | start_date_string: '01-01-99', 12 | priority_string: '0', 13 | comments: 'comments', 14 | }; 15 | 16 | const malAnime = { 17 | ...malItem, 18 | anime_id: 1, 19 | num_watched_episodes: 12, 20 | anime_airing_status: 2, 21 | anime_num_episodes: 12, 22 | } as Types.MALLoadAnime; 23 | 24 | const malManga = { 25 | ...malItem, 26 | manga_id: 2, 27 | manga_num_chapters: 12, 28 | manga_num_volumes: 2, 29 | num_read_chapters: 12, 30 | num_read_volumes: 2, 31 | manga_publishing_status: 0, 32 | } as Types.MALLoadManga; 33 | 34 | export const createFakeMALAnime = (data: any = {}) => 35 | ({ ...malAnime, ...data }); 36 | export const createFakeMALManga = (data: any = {}) => 37 | ({ ...malManga, ...data }); 38 | 39 | const alAnime: Types.FormattedEntry = { 40 | status: 'COMPLETED', 41 | score: 10, 42 | progress: 12, 43 | progressVolumes: 0, 44 | startedAt: createDate(99, 1, 1), 45 | completedAt: createDate(99, 1, 1), 46 | repeat: 1, 47 | id: malAnime.anime_id, 48 | title: 'title', 49 | type: 'anime' 50 | }; 51 | 52 | const alManga: Types.FormattedEntry = { 53 | status: 'COMPLETED', 54 | score: 10, 55 | progress: 12, 56 | progressVolumes: 2, 57 | startedAt: createDate(99, 1, 1), 58 | completedAt: createDate(99, 1, 1), 59 | repeat: 1, 60 | id: malManga.manga_id, 61 | title: 'title', 62 | type: 'manga' 63 | }; 64 | 65 | export const createFakeAnilistAnime = (data: any = {}): Types.FormattedEntry => 66 | ({ ...alAnime, ...data }); 67 | export const createFakeAnilistManga = (data: any = {}): Types.FormattedEntry => 68 | ({ ...alManga, ...data }); 69 | 70 | export const createFakeDomMethods = (dateSetting = 'a'): IDomMethods => ({ 71 | addDropDownItem: jest.fn(), 72 | addImportForm: (syncFn: Function) => jest.fn(), 73 | getDateSetting: jest.fn().mockImplementation().mockReturnValue(dateSetting), 74 | getDebugSetting: jest.fn().mockImplementation().mockReturnValue(false), 75 | getCSRFToken: () => 'csrfToken', 76 | getMALUsername: () => 'malUsername', 77 | getAnilistUsername: () => 'anilistUsername' 78 | }); -------------------------------------------------------------------------------- /douki.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Douki 3 | // @namespace douki-e7d98778-9b83-45eb-a189-456bd1ce2ee1 4 | // @description Import Anime and Manga Lists from Anilist (see https://anilist.co/forum/thread/2654 for more info) 5 | // @version 0.2.5 6 | // @include https://myanimelist.net/* 7 | // ==/UserScript== 8 | 9 | /******/ (() => { // webpackBootstrap 10 | /******/ "use strict"; 11 | /******/ var __webpack_modules__ = ([ 12 | /* 0 */, 13 | /* 1 */ 14 | /***/ ((__unused_webpack_module, exports, __webpack_require__) => { 15 | 16 | 17 | Object.defineProperty(exports, "__esModule", ({ value: true })); 18 | exports.Log = void 0; 19 | const const_1 = __webpack_require__(2); 20 | const Util_1 = __webpack_require__(3); 21 | const getCountLog = (operation, type) => document.querySelector(Util_1.id(`douki-${operation}-${type}-items`)); 22 | class Log { 23 | constructor() { 24 | this.errorLogElement = null; 25 | this.syncLogElement = null; 26 | this.debugLogElement = null; 27 | } 28 | get errorLog() { 29 | if (!this.errorLogElement) { 30 | this.errorLogElement = document.querySelector(Util_1.id(const_1.ERROR_LOG_ID)); 31 | } 32 | return this.errorLogElement; 33 | } 34 | get syncLog() { 35 | if (!this.syncLogElement) { 36 | this.syncLogElement = document.querySelector(Util_1.id(const_1.SYNC_LOG_ID)); 37 | } 38 | return this.syncLogElement; 39 | } 40 | get debugLog() { 41 | if (!this.debugLogElement) { 42 | this.debugLogElement = document.querySelector(Util_1.id(const_1.DEBUG_LOG_ID)); 43 | } 44 | return this.debugLogElement; 45 | } 46 | clearErrorLog() { 47 | if (this.errorLog) { 48 | this.errorLog.innerHTML = ''; 49 | } 50 | } 51 | clearSyncLog() { 52 | if (this.syncLog) { 53 | this.syncLog.innerHTML = ''; 54 | } 55 | } 56 | clearDebugLog() { 57 | if (this.debugLog) { 58 | this.debugLog.innerHTML = ''; 59 | } 60 | } 61 | clear(type = '') { 62 | console.clear(); 63 | if (type !== 'error') 64 | this.clearSyncLog(); 65 | if (type !== 'sync') 66 | this.clearErrorLog(); 67 | this.clearDebugLog(); 68 | } 69 | error(msg) { 70 | if (this.errorLog) { 71 | this.errorLog.innerHTML += `
  • ${msg}
  • `; 72 | } 73 | else { 74 | console.error(msg); 75 | } 76 | } 77 | info(msg) { 78 | if (this.syncLog) { 79 | this.syncLog.innerHTML += `
  • ${msg}
  • `; 80 | } 81 | else { 82 | console.info(msg); 83 | } 84 | } 85 | debug(msg) { 86 | if (this.debugLog) { 87 | this.debugLog.innerHTML += `
  • ${msg}
  • `; 88 | } 89 | else { 90 | console.debug(msg); 91 | } 92 | } 93 | addCountLog(operation, type, max) { 94 | const opName = Util_1.getOperationDisplayName(operation); 95 | const logId = `douki-${operation}-${type}-items`; 96 | this.info(`${opName} 0 of ${max} ${type} items.`); 97 | } 98 | updateCountLog(operation, type, count) { 99 | const countLog = getCountLog(operation, type); 100 | if (!countLog) 101 | return; 102 | countLog.innerHTML = `${count}`; 103 | } 104 | } 105 | exports.Log = Log; 106 | exports.default = new Log(); 107 | 108 | 109 | /***/ }), 110 | /* 2 */ 111 | /***/ ((__unused_webpack_module, exports) => { 112 | 113 | 114 | Object.defineProperty(exports, "__esModule", ({ value: true })); 115 | exports.DEBUG_LOG_ID = exports.DEBUG_SETTING_ID = exports.DROPDOWN_ITEM_ID = exports.DATE_SETTINGS_KEY = exports.SETTINGS_KEY = exports.ANILIST_USERNAME_ID = exports.ERROR_LOG_DIV_ID = exports.ERROR_LOG_TOGGLE_ID = exports.ERROR_LOG_ID = exports.SYNC_LOG_ID = exports.DOUKI_IMPORT_BUTTON_ID = exports.CONTENT_ID = exports.DATE_SETTING_ID = exports.DOUKI_ANILIST_IMPORT_ID = exports.DOUKI_FORM_ID = void 0; 116 | exports.DOUKI_FORM_ID = 'douki-form'; 117 | exports.DOUKI_ANILIST_IMPORT_ID = 'douki-anilist-import'; 118 | exports.DATE_SETTING_ID = 'douki-date_format'; 119 | exports.CONTENT_ID = 'content'; 120 | exports.DOUKI_IMPORT_BUTTON_ID = 'douki-import'; 121 | exports.SYNC_LOG_ID = 'douki-sync-log'; 122 | exports.ERROR_LOG_ID = 'douki-error-log'; 123 | exports.ERROR_LOG_TOGGLE_ID = 'douki-error-log-toggle'; 124 | exports.ERROR_LOG_DIV_ID = 'douki-error-log-div'; 125 | exports.ANILIST_USERNAME_ID = 'douki-anilist-username'; 126 | exports.SETTINGS_KEY = 'douki-settings'; 127 | exports.DATE_SETTINGS_KEY = 'douki-settings-date'; 128 | exports.DROPDOWN_ITEM_ID = 'douki-sync'; 129 | exports.DEBUG_SETTING_ID = 'douki-debug'; 130 | exports.DEBUG_LOG_ID = 'douki-debug-log'; 131 | 132 | 133 | /***/ }), 134 | /* 3 */ 135 | /***/ ((__unused_webpack_module, exports) => { 136 | 137 | 138 | Object.defineProperty(exports, "__esModule", ({ value: true })); 139 | exports.getOperationDisplayName = exports.id = exports.sleep = void 0; 140 | const sleep = (ms) => new Promise(resolve => setTimeout(() => resolve(null), ms)); 141 | exports.sleep = sleep; 142 | const id = (str) => `#${str}`; 143 | exports.id = id; 144 | const getOperationDisplayName = (operation) => { 145 | switch (operation) { 146 | case 'add': 147 | return 'Adding'; 148 | case 'edit': 149 | return 'Updating'; 150 | case 'complete': 151 | return 'Fixing'; 152 | default: 153 | throw new Error('Unknown operation type'); 154 | } 155 | }; 156 | exports.getOperationDisplayName = getOperationDisplayName; 157 | 158 | 159 | /***/ }), 160 | /* 4 */ 161 | /***/ ((__unused_webpack_module, exports, __webpack_require__) => { 162 | 163 | 164 | Object.defineProperty(exports, "__esModule", ({ value: true })); 165 | exports.DomMethods = void 0; 166 | const const_1 = __webpack_require__(2); 167 | const Util_1 = __webpack_require__(3); 168 | const importFormHTML = ` 169 |
    170 |

    Import From Anilist

    171 |
    172 |

    NOTICE: Use this script at your own risk. The author takes no responsibility for any damages of any kind.

    173 |

    It is highly recommended that you try this script out on a test MAL account before importing to your main account.

    174 |

    Visit the Anilist thread for this script to ask questions or report problems.

    175 |

    Please be patient. If the import goes any faster you will be in violation of MyAnimeList's Terms of Service.

    176 |
    177 |
    178 |

    179 |

    180 | 186 | 189 |

    190 |

    191 |
    192 |
    193 | 194 |

    195 | 199 |
    200 |
      201 |
      202 |
      203 | `; 204 | const getLocalStorageSetting = (setting) => { 205 | if (localStorage) { 206 | const value = localStorage.getItem(setting); 207 | if (value) 208 | return JSON.parse(value); 209 | } 210 | return null; 211 | }; 212 | const setLocalStorageSetting = (setting, value) => { 213 | if (localStorage) { 214 | localStorage.setItem(setting, JSON.stringify(value)); 215 | } 216 | }; 217 | class DomMethods { 218 | constructor() { 219 | this.csrfToken = null; 220 | } 221 | addDropDownItem() { 222 | if (document.querySelector(Util_1.id(const_1.DROPDOWN_ITEM_ID))) 223 | return; 224 | const selector = '.header-menu-dropdown > ul > li:last-child'; 225 | const dropdown = document.querySelector(selector); 226 | if (dropdown) { 227 | const html = `
    • Import from Anilist
    • `; 228 | dropdown.insertAdjacentHTML('afterend', html); 229 | const link = document.querySelector(Util_1.id(const_1.DROPDOWN_ITEM_ID)); 230 | link && link.addEventListener('click', function (e) { 231 | e.preventDefault(); 232 | window.location.replace('https://myanimelist.net/import.php'); 233 | }); 234 | } 235 | } 236 | addImportForm(syncFn) { 237 | if (document.querySelector(Util_1.id(const_1.DOUKI_FORM_ID))) 238 | return; 239 | const element = document.querySelector(Util_1.id(const_1.CONTENT_ID)); 240 | if (!element) { 241 | throw new Error('Unable to add form to page'); 242 | } 243 | element.insertAdjacentHTML('afterend', importFormHTML); 244 | this.addImportFormEventListeners(syncFn); 245 | } 246 | // TODO break this up 247 | addImportFormEventListeners(syncFn) { 248 | const importButton = document.querySelector(Util_1.id(const_1.DOUKI_IMPORT_BUTTON_ID)); 249 | importButton && importButton.addEventListener('click', function (e) { 250 | syncFn(e); 251 | }); 252 | const textBox = document.querySelector(Util_1.id(const_1.ANILIST_USERNAME_ID)); 253 | textBox && textBox.addEventListener('change', function (e) { 254 | setLocalStorageSetting(const_1.SETTINGS_KEY, e.target.value); 255 | }); 256 | const username = getLocalStorageSetting(const_1.SETTINGS_KEY); 257 | if (username && textBox) { 258 | textBox.value = username; 259 | } 260 | const dateFormatPicker = document.querySelector(Util_1.id(const_1.DATE_SETTING_ID)); 261 | dateFormatPicker && dateFormatPicker.addEventListener('change', function (e) { 262 | setLocalStorageSetting(const_1.DATE_SETTINGS_KEY, e.target.value); 263 | }); 264 | const dateOption = getLocalStorageSetting(const_1.DATE_SETTINGS_KEY); 265 | if (dateOption && dateFormatPicker) { 266 | dateFormatPicker.value = dateOption; 267 | } 268 | const errorToggle = document.querySelector(Util_1.id(const_1.ERROR_LOG_TOGGLE_ID)); 269 | errorToggle && errorToggle.addEventListener('click', function (e) { 270 | e.preventDefault(); 271 | const errorLog = document.querySelector(Util_1.id(const_1.ERROR_LOG_DIV_ID)); 272 | if (errorLog.style.display === 'none') { 273 | errorLog.style.display = 'block'; 274 | } 275 | else { 276 | errorLog.style.display = 'none'; 277 | } 278 | }); 279 | } 280 | getDateSetting() { 281 | const dateSetting = document.querySelector(Util_1.id(const_1.DATE_SETTING_ID)); 282 | if (!dateSetting || !dateSetting.value) 283 | throw new Error('Unable to get date setting'); 284 | return dateSetting.value; 285 | } 286 | getDebugSetting() { 287 | const debugSetting = document.querySelector(Util_1.id(const_1.DEBUG_SETTING_ID)); 288 | if (!debugSetting) 289 | throw new Error('Unable to get debug setting'); 290 | return debugSetting.checked; 291 | } 292 | getCSRFToken() { 293 | if (this.csrfToken) 294 | return this.csrfToken; 295 | const csrfTokenMeta = document.querySelector('meta[name~="csrf_token"]'); 296 | if (!csrfTokenMeta) 297 | throw new Error('Unable to get CSRF token - no meta element'); 298 | const csrfToken = csrfTokenMeta.getAttribute('content'); 299 | if (!csrfToken) 300 | throw new Error('Unable to get CSRF token - no content attribute'); 301 | this.csrfToken = csrfToken; 302 | return csrfToken; 303 | } 304 | getMALUsername() { 305 | const malUsernameElement = document.querySelector('.header-profile-link'); 306 | if (!malUsernameElement) 307 | return null; 308 | return malUsernameElement.innerText; 309 | } 310 | getAnilistUsername() { 311 | const anilistUserElement = document.querySelector('#douki-anilist-username'); 312 | if (!anilistUserElement) 313 | throw new Error('Unable to get Anilist username'); 314 | return anilistUserElement.value; 315 | } 316 | } 317 | exports.DomMethods = DomMethods; 318 | exports.default = new DomMethods(); 319 | 320 | 321 | /***/ }), 322 | /* 5 */ 323 | /***/ ((__unused_webpack_module, exports, __webpack_require__) => { 324 | 325 | 326 | Object.defineProperty(exports, "__esModule", ({ value: true })); 327 | exports.getAnilistList = void 0; 328 | const Log_1 = __webpack_require__(1); 329 | const flatten = (obj) => 330 | // Outer reduce concats arrays built by inner reduce 331 | Object.keys(obj).reduce((accumulator, list) => 332 | // Inner reduce builds an array out of the lists 333 | accumulator.concat(Object.keys(obj[list]).reduce((acc2, item) => 334 | // @ts-ignore 335 | acc2.concat(obj[list][item]), [])), []); 336 | const uniqify = (arr) => { 337 | const seen = new Set(); 338 | return arr.filter(item => (seen.has(item.media.idMal) ? false : seen.add(item.media.idMal))); 339 | }; 340 | // Anilist Functions 341 | const anilistCall = (query, variables) => fetch('https://graphql.anilist.co', { 342 | method: 'POST', 343 | headers: { 344 | 'Content-Type': 'application/json', 345 | Accept: 'application/json', 346 | }, 347 | body: JSON.stringify({ 348 | query, 349 | variables, 350 | }), 351 | }); 352 | const fetchList = (userName) => anilistCall(` 353 | query ($userName: String) { 354 | anime: MediaListCollection(userName: $userName, type: ANIME) { 355 | lists { 356 | entries { 357 | status 358 | score(format:POINT_10) 359 | progress 360 | startedAt { 361 | year 362 | month 363 | day 364 | } 365 | completedAt { 366 | year 367 | month 368 | day 369 | } 370 | repeat 371 | media { 372 | idMal 373 | title { 374 | romaji 375 | } 376 | } 377 | } 378 | } 379 | }, 380 | manga: MediaListCollection(userName: $userName, type: MANGA) { 381 | lists { 382 | entries { 383 | status 384 | score(format:POINT_10) 385 | progress 386 | progressVolumes 387 | startedAt { 388 | year 389 | month 390 | day 391 | } 392 | completedAt { 393 | year 394 | month 395 | day 396 | } 397 | repeat 398 | media { 399 | idMal 400 | title { 401 | romaji 402 | } 403 | } 404 | } 405 | } 406 | } 407 | } 408 | `, { 409 | userName 410 | }) 411 | .then(res => res.json()) 412 | .then(res => res.data) 413 | .then(res => ({ 414 | anime: uniqify(flatten(res.anime.lists)), 415 | manga: uniqify(flatten(res.manga.lists)), 416 | })); 417 | const sanitize = (item, type) => ({ 418 | type, 419 | progress: item.progress, 420 | progressVolumes: item.progressVolumes, 421 | startedAt: { 422 | year: item.startedAt.year || 0, 423 | month: item.startedAt.month || 0, 424 | day: item.startedAt.day || 0, 425 | }, 426 | completedAt: { 427 | year: item.completedAt.year || 0, 428 | month: item.completedAt.month || 0, 429 | day: item.completedAt.day || 0 430 | }, 431 | repeat: item.repeat, 432 | status: item.status, 433 | score: item.score, 434 | id: item.media.idMal, 435 | title: item.media.title.romaji 436 | }); 437 | const filterNoMalId = (item) => { 438 | if (item.id) 439 | return true; 440 | Log_1.default.error(`${item.type}: ${item.title}`); 441 | return false; 442 | }; 443 | const getAnilistList = (username) => fetchList(username) 444 | .then(lists => ({ 445 | anime: lists.anime 446 | .map(item => sanitize(item, 'anime')) 447 | .filter(item => filterNoMalId(item)), 448 | manga: lists.manga 449 | .map(item => sanitize(item, 'manga')) 450 | .filter(item => filterNoMalId(item)), 451 | })); 452 | exports.getAnilistList = getAnilistList; 453 | 454 | 455 | /***/ }), 456 | /* 6 */ 457 | /***/ ((__unused_webpack_module, exports, __webpack_require__) => { 458 | 459 | 460 | Object.defineProperty(exports, "__esModule", ({ value: true })); 461 | const Util_1 = __webpack_require__(3); 462 | const MALEntry_1 = __webpack_require__(7); 463 | const Log_1 = __webpack_require__(1); 464 | const Dom_1 = __webpack_require__(4); 465 | class MAL { 466 | constructor(username, csrfToken, log = Log_1.default, dom = Dom_1.default) { 467 | this.username = username; 468 | this.csrfToken = csrfToken; 469 | this.Log = log; 470 | this.dom = dom; 471 | } 472 | createMALHashMap(malList, type) { 473 | const hashMap = {}; 474 | malList.forEach(item => { 475 | hashMap[item[`${type}_id`]] = item; 476 | }); 477 | return hashMap; 478 | } 479 | async getMALHashMap(type, list = [], page = 1) { 480 | const offset = (page - 1) * 300; 481 | const nextList = await fetch(`https://myanimelist.net/${type}list/${this.username}/load.json?offset=${offset}&status=7`) 482 | .then(async (res) => { 483 | if (res.status !== 200) { 484 | await Util_1.sleep(2000); 485 | return this.getMALHashMap(type, list, page); 486 | } 487 | return res.json(); 488 | }); 489 | if (nextList && nextList.length) { 490 | await Util_1.sleep(1500); 491 | return this.getMALHashMap(type, [...list, ...nextList], page + 1); 492 | } 493 | this.Log.info(`Fetched MyAnimeList ${type} list.`); 494 | return this.createMALHashMap([...list, ...nextList], type); 495 | } 496 | async getEntriesList(anilistList, type) { 497 | const malHashMap = await this.getMALHashMap(type); 498 | return anilistList.map(entry => MALEntry_1.createMALEntry(entry, malHashMap[entry.id], this.csrfToken, this.dom)); 499 | } 500 | async malEdit(data) { 501 | const { type, id } = data; 502 | const formData = await data.formData(); 503 | return fetch(`https://myanimelist.net/ownlist/${type}/${id}/edit?hideLayout`, { 504 | credentials: 'include', 505 | headers: { 506 | accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', 507 | 'accept-language': 'en-US,en;q=0.9,ja;q=0.8', 508 | 'cache-control': 'max-age=0', 509 | 'content-type': 'application/x-www-form-urlencoded', 510 | 'upgrade-insecure-requests': '1' 511 | }, 512 | referrer: `https://myanimelist.net/ownlist/${type}/${id}/edit?hideLayout`, 513 | referrerPolicy: 'no-referrer-when-downgrade', 514 | body: formData, 515 | method: 'POST', 516 | mode: 'cors' 517 | }).then((res) => { 518 | if (res.status === 200) 519 | return res; 520 | throw new Error(`Error updating ${type} id ${id}`); 521 | }).then((res) => res.text()) 522 | .then((text) => { 523 | if (text.match(/.+Successfully updated entry.+/)) 524 | return; 525 | throw new Error(`Error updating ${type} id ${id}`); 526 | }); 527 | } 528 | malAdd(data) { 529 | return fetch(`https://myanimelist.net/ownlist/${data.type}/add.json`, { 530 | method: 'post', 531 | headers: { 532 | 'Accept': '*/*', 533 | 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 534 | 'x-requested-with': 'XMLHttpRequest' 535 | }, 536 | body: JSON.stringify(data.postData) 537 | }) 538 | .then((res) => { 539 | if (res.status === 200) 540 | return res; 541 | throw new Error(JSON.stringify(data)); 542 | }); 543 | } 544 | async syncList(type, list, operation) { 545 | if (!list || !list.length) { 546 | return; 547 | } 548 | this.Log.addCountLog(operation, type, list.length); 549 | let itemCount = 0; 550 | const fn = operation === 'add' ? this.malAdd : this.malEdit; 551 | for (let item of list) { 552 | await Util_1.sleep(500); 553 | try { 554 | await fn(item); 555 | itemCount++; 556 | this.Log.updateCountLog(operation, type, itemCount); 557 | } 558 | catch (e) { 559 | console.error(e); 560 | this.Log.info(`Error for ${type} ${item.title}. Try adding or updating it manually.`); 561 | } 562 | } 563 | } 564 | async syncType(type, anilistList) { 565 | this.Log.info(`Fetching MyAnimeList ${type} list...`); 566 | let list = await this.getEntriesList(anilistList, type); 567 | const addList = list.filter(entry => entry.shouldAdd()); 568 | await this.syncList(type, addList, 'add'); 569 | // Refresh list to get episode/chapter counts of new completed items 570 | if (addList.length) { 571 | this.Log.info(`Refreshing MyAnimeList ${type} list...`); 572 | list = await this.getEntriesList(anilistList, type); 573 | } 574 | const updateList = list.filter(entry => entry.shouldUpdate()); 575 | await this.syncList(type, updateList, 'edit'); 576 | } 577 | } 578 | exports.default = MAL; 579 | 580 | 581 | /***/ }), 582 | /* 7 */ 583 | /***/ ((__unused_webpack_module, exports, __webpack_require__) => { 584 | 585 | 586 | Object.defineProperty(exports, "__esModule", ({ value: true })); 587 | exports.MALEntryManga = exports.MALEntryAnime = exports.BaseMALEntry = exports.createMALEntry = void 0; 588 | const MALForm_1 = __webpack_require__(8); 589 | const Dom_1 = __webpack_require__(4); 590 | const Log_1 = __webpack_require__(1); 591 | const createMALEntry = (al, mal, csrfToken, dom) => al.type === 'anime' ? 592 | new MALEntryAnime(al, mal, csrfToken, dom) : 593 | new MALEntryManga(al, mal, csrfToken, dom); 594 | exports.createMALEntry = createMALEntry; 595 | const MALStatus = { 596 | Current: 1, 597 | Completed: 2, 598 | Paused: 3, 599 | Dropped: 4, 600 | Planning: 6 601 | }; 602 | const getStatus = (status) => { 603 | // MAL status: 1/watching, 2/completed, 3/onhold, 4/dropped, 6/plantowatch 604 | // MAL handles REPEATING as a boolean, and keeps status as COMPLETE 605 | switch (status.trim()) { 606 | case 'CURRENT': 607 | return MALStatus.Current; 608 | case 'REPEATING': 609 | case 'COMPLETED': 610 | return MALStatus.Completed; 611 | case 'PAUSED': 612 | return MALStatus.Paused; 613 | case 'DROPPED': 614 | return MALStatus.Dropped; 615 | case 'PLANNING': 616 | return MALStatus.Planning; 617 | default: 618 | throw new Error(`unknown status "${status}"`); 619 | } 620 | }; 621 | const createMALFormData = (malData) => { 622 | let formData = ''; 623 | Object.keys(malData).forEach(key => { 624 | formData += `${encodeURIComponent(key)}=${encodeURIComponent(malData[key])}&`; 625 | }); 626 | return formData.replace(/&$/, ''); 627 | }; 628 | class BaseMALEntry { 629 | constructor(al, mal, csrfToken = '', dom = Dom_1.default, log = Log_1.default) { 630 | this.alData = al; 631 | this.malData = mal; 632 | this.csrfToken = csrfToken; 633 | this._postData = this.createPostData(); 634 | this.dom = dom; 635 | this.log = log; 636 | } 637 | createBaseMALPostItem() { 638 | return { 639 | status: getStatus(this.alData.status), 640 | csrf_token: this.csrfToken, 641 | score: this.alData.score || 0, 642 | finish_date: { 643 | year: this.alData.completedAt.year || 0, 644 | month: this.alData.completedAt.month || 0, 645 | day: this.alData.completedAt.day || 0 646 | }, 647 | start_date: { 648 | year: this.alData.startedAt.year || 0, 649 | month: this.alData.startedAt.month || 0, 650 | day: this.alData.startedAt.day || 0 651 | } 652 | }; 653 | } 654 | buildDateString(date) { 655 | if (date.month === 0 && date.day === 0 && date.year === 0) 656 | return null; 657 | const dateSetting = this.dom.getDateSetting(); 658 | const month = `${String(date.month).length < 2 ? '0' : ''}${date.month}`; 659 | const day = `${String(date.day).length < 2 ? '0' : ''}${date.day}`; 660 | const year = `${date.year ? String(date.year).slice(-2) : 0}`; 661 | if (dateSetting === 'a') { 662 | return `${month}-${day}-${year}`; 663 | } 664 | return `${day}-${month}-${year}`; 665 | } 666 | shouldUpdate() { 667 | // If something went wrong or it didn't get added, update will not work 668 | if (!this.malData || !this._postData) { 669 | return false; 670 | } 671 | const debug = this.dom.getDebugSetting(); 672 | return Object.keys(this._postData).some(key => { 673 | switch (key) { 674 | case 'csrf_token': 675 | case 'anime_id': 676 | case 'manga_id': 677 | // This data is not part of the load.json list and so can't be used as update test 678 | case 'num_watched_times': 679 | case 'num_read_times': 680 | return false; 681 | case 'start_date': 682 | case 'finish_date': 683 | { 684 | // @ts-ignore 685 | const dateString = this.buildDateString(this._postData[key]); 686 | if (dateString !== this.malData[`${key}_string`]) { 687 | if (debug) { 688 | this.log.debug(`${this.alData.title}: ${key} differs; MAL ${this.malData[`${key}_string`]} AL ${dateString}`); 689 | } 690 | return true; 691 | } 692 | return false; 693 | } 694 | case 'num_read_chapters': 695 | case 'num_read_volumes': 696 | case 'num_watched_episodes': 697 | // Anlist and MAL have different volume, episode, and chapter counts for some media; 698 | // If the item is marked as completed, ignore differences (Status 2 is COMPLETED) 699 | // EXCEPT when the count is 0, in which case this was newly added without a count and needs 700 | // to be updated now that the count is available 701 | { 702 | if (this.malData.status === MALStatus.Completed && this.malData[key] !== 0) { 703 | return false; 704 | } 705 | if (this._postData[key] !== this.malData[key]) { 706 | if (debug) { 707 | this.log.debug(`${this.alData.title} ${key} differs; MAL ${this.malData[key]} AL ${this._postData[key]}`); 708 | } 709 | return true; 710 | } 711 | return false; 712 | } 713 | default: 714 | { 715 | // Treat falsy values as equivalent (!= doesn't do the trick here) 716 | if (!this._postData[key] && !this.malData[key]) { 717 | return false; 718 | } 719 | if (this._postData[key] !== this.malData[key]) { 720 | if (debug) { 721 | this.log.debug(`${this.alData.title} ${key} differs; MAL ${this.malData[key]} AL ${this._postData[key]}`); 722 | } 723 | return true; 724 | } 725 | return false; 726 | } 727 | } 728 | }); 729 | } 730 | shouldAdd() { 731 | return !this.malData; 732 | } 733 | formData() { 734 | throw new Error("Method not implemented."); 735 | } 736 | createPostData() { 737 | throw new Error("Method not implemented."); 738 | } 739 | get type() { 740 | return this.alData.type; 741 | } 742 | get id() { 743 | return this.alData.id; 744 | } 745 | get title() { 746 | return this.alData.title; 747 | } 748 | get postData() { 749 | return this._postData; 750 | } 751 | } 752 | exports.BaseMALEntry = BaseMALEntry; 753 | class MALEntryAnime extends BaseMALEntry { 754 | constructor(al, mal, csrfToken = '', dom = Dom_1.default) { 755 | super(al, mal, csrfToken, dom); 756 | } 757 | createPostData() { 758 | const result = this.createBaseMALPostItem(); 759 | result.anime_id = this.alData.id; 760 | if (this.alData.repeat) 761 | result.num_watched_times = this.alData.repeat; 762 | /* Setting num_watched_episodes */ 763 | // If this is a new item, malData is undefined, so set count to 0 764 | // When the list refreshes the count will be available and be set then 765 | if (!this.malData) { 766 | result.num_watched_episodes = 0; 767 | return result; 768 | } 769 | // If malData.anime_num_episodes is 0, the show is currently airing; 770 | // We're forced to use AL's count even though that might be wrong 771 | if (this.malData.anime_num_episodes === 0) { 772 | result.num_watched_episodes = this.alData.progress; 773 | return result; 774 | } 775 | // If the show is completed, use MAL's count in case AL's count is different; 776 | // We don't want MAL showing higher or lower than their own count 777 | if (result.status === MALStatus.Completed) { 778 | result.num_watched_episodes = this.malData.anime_num_episodes; 779 | return result; 780 | } 781 | // Othewrise, use MAL's count as a max 782 | result.num_watched_episodes = Math.min(this.alData.progress, this.malData.anime_num_episodes); 783 | return result; 784 | } 785 | async formData() { 786 | const malFormData = new MALForm_1.MALForm(this.alData.type, this.alData.id); 787 | await malFormData.get(); 788 | const formData = { 789 | anime_id: this.malData.anime_id, 790 | aeps: this.malData.anime_num_episodes || 0, 791 | astatus: this.malData.anime_airing_status, 792 | 'add_anime[status]': this._postData.status, 793 | 'add_anime[num_watched_episodes]': this._postData.num_watched_episodes || 0, 794 | 'add_anime[score]': this._postData.score || '', 795 | 'add_anime[start_date][month]': this._postData.start_date && this._postData.start_date.month || '', 796 | 'add_anime[start_date][day]': this._postData.start_date && this._postData.start_date.day || '', 797 | 'add_anime[start_date][year]': this._postData.start_date && this._postData.start_date.year || '', 798 | 'add_anime[finish_date][month]': this._postData.finish_date && this._postData.finish_date.month || '', 799 | 'add_anime[finish_date][day]': this._postData.finish_date && this._postData.finish_date.day || '', 800 | 'add_anime[finish_date][year]': this._postData.finish_date && this._postData.finish_date.year || '', 801 | 'add_anime[tags]': this.malData.tags || '', 802 | 'add_anime[priority]': malFormData.priority, 803 | 'add_anime[storage_type]': malFormData.storageType, 804 | 'add_anime[storage_value]': malFormData.storageValue, 805 | 'add_anime[num_watched_times]': this._postData.num_watched_times || 0, 806 | 'add_anime[rewatch_value]': malFormData.rewatchValue, 807 | 'add_anime[comments]': malFormData.comments, 808 | 'add_anime[is_asked_to_discuss]': malFormData.discussionSetting, 809 | 'add_anime[sns_post_type]': malFormData.SNSSetting, 810 | submitIt: 0, 811 | csrf_token: this.csrfToken, 812 | }; 813 | if (this.alData.status === 'REPEATING') { 814 | formData['add_anime[is_rewatching]'] = 1; 815 | } 816 | return createMALFormData(formData); 817 | } 818 | } 819 | exports.MALEntryAnime = MALEntryAnime; 820 | class MALEntryManga extends BaseMALEntry { 821 | constructor(al, mal, csrfToken = '', dom = Dom_1.default) { 822 | super(al, mal, csrfToken, dom); 823 | } 824 | createPostData() { 825 | const result = this.createBaseMALPostItem(); 826 | result.manga_id = this.alData.id; 827 | if (this.alData.repeat) 828 | result.num_read_times = this.alData.repeat; 829 | /* Setting num_read_chapters and num_read_volumes */ 830 | // If this is a new item, malData is undefined, so set count to 0 831 | // When the list refreshes the count will be available and be set then 832 | if (!this.malData) { 833 | result.num_read_chapters = 0; 834 | result.num_read_volumes = 0; 835 | return result; 836 | } 837 | // If malData.manga_num_chapters is 0, the manga is still publishing; 838 | // We're forced to use AL's count even though that might be wrong 839 | if (this.malData.manga_num_chapters === 0) { 840 | result.num_read_chapters = this.alData.progress; 841 | result.num_read_volumes = this.alData.progressVolumes; 842 | return result; 843 | } 844 | // If the manga is completed, use MAL's count in case AL's count is different; 845 | // We don't want MAL showing higher or lower than their own count 846 | if (result.status === MALStatus.Completed) { 847 | result.num_read_chapters = this.malData.manga_num_chapters; 848 | result.num_read_volumes = this.malData.manga_num_volumes; 849 | return result; 850 | } 851 | // Othewrise, use MAL's count as a max 852 | result.num_read_chapters = Math.min(this.alData.progress, this.malData.manga_num_chapters); 853 | result.num_read_volumes = Math.min(this.alData.progressVolumes, this.malData.manga_num_volumes); 854 | return result; 855 | } 856 | async formData() { 857 | const malFormData = new MALForm_1.MALForm(this.alData.type, this.alData.id); 858 | await malFormData.get(); 859 | const formData = { 860 | entry_id: 0, 861 | manga_id: this.malData.manga_id, 862 | 'add_manga[status]': this._postData.status, 863 | 'add_manga[num_read_volumes]': this._postData.num_read_volumes || 0, 864 | last_completed_vol: '', 865 | 'add_manga[num_read_chapters]': this._postData.num_read_chapters || 0, 866 | 'add_manga[score]': this._postData.score || '', 867 | 'add_manga[start_date][month]': this._postData.start_date && this._postData.start_date.month || '', 868 | 'add_manga[start_date][day]': this._postData.start_date && this._postData.start_date.day || '', 869 | 'add_manga[start_date][year]': this._postData.start_date && this._postData.start_date.year || '', 870 | 'add_manga[finish_date][month]': this._postData.finish_date && this._postData.finish_date.month || '', 871 | 'add_manga[finish_date][day]': this._postData.finish_date && this._postData.finish_date.day || '', 872 | 'add_manga[finish_date][year]': this._postData.finish_date && this._postData.finish_date.year || '', 873 | 'add_manga[tags]': this.malData.tags || '', 874 | 'add_manga[priority]': malFormData.priority, 875 | 'add_manga[storage_type]': malFormData.storageType, 876 | 'add_manga[num_retail_volumes]': malFormData.numRetailVolumes, 877 | 'add_manga[num_read_times]': this._postData.num_read_times || 0, 878 | 'add_manga[reread_value]': malFormData.rereadValue, 879 | 'add_manga[comments]': malFormData.comments, 880 | 'add_manga[is_asked_to_discuss]': malFormData.discussionSetting, 881 | 'add_manga[sns_post_type]': malFormData.SNSSetting, 882 | csrf_token: this.csrfToken, 883 | submitIt: 0 884 | }; 885 | if (this.alData.status === 'REPEATING') { 886 | formData['add_manga[is_rewatching]'] = 1; 887 | } 888 | return createMALFormData(formData); 889 | } 890 | } 891 | exports.MALEntryManga = MALEntryManga; 892 | 893 | 894 | /***/ }), 895 | /* 8 */ 896 | /***/ ((__unused_webpack_module, exports, __webpack_require__) => { 897 | 898 | 899 | Object.defineProperty(exports, "__esModule", ({ value: true })); 900 | exports.MALForm = void 0; 901 | const Util_1 = __webpack_require__(3); 902 | class MALForm { 903 | constructor(type, id) { 904 | this.document = null; 905 | this.type = type; 906 | this.id = id; 907 | } 908 | fetchDocument(type, id) { 909 | return new Promise((resolve, reject) => { 910 | const xhr = new XMLHttpRequest(); 911 | xhr.onload = function () { 912 | return resolve(this.responseXML ? this.responseXML : null); 913 | }; 914 | xhr.onerror = function (e) { 915 | reject(e); 916 | }; 917 | xhr.open('GET', `https://myanimelist.net/ownlist/${type}/${id}/edit`); 918 | xhr.responseType = 'document'; 919 | xhr.send(); 920 | }); 921 | } 922 | getElement(id) { 923 | if (!this.document) 924 | throw new Error('Document not loaded'); 925 | return this.document.querySelector(`#add_${this.type}_${id}`); 926 | } 927 | async get() { 928 | await Util_1.sleep(500); 929 | const document = await this.fetchDocument(this.type, this.id); 930 | if (document) { 931 | this.document = document; 932 | } 933 | else { 934 | throw new Error('Unable to fetch form data'); 935 | } 936 | } 937 | get priority() { 938 | const el = this.getElement('priority'); 939 | if (!el) 940 | throw new Error('Unable to get priority'); 941 | return el.value; 942 | } 943 | get storageType() { 944 | const el = this.getElement('storage_type'); 945 | if (!el) 946 | throw new Error('Unable to get storage type'); 947 | return el.value; 948 | } 949 | get storageValue() { 950 | const el = this.getElement('storage_value'); 951 | if (!el) 952 | return '0'; 953 | return el.value; 954 | } 955 | get numRetailVolumes() { 956 | const el = this.getElement('num_retail_volumes'); 957 | if (!el) 958 | return '0'; 959 | return el.value; 960 | } 961 | get rewatchValue() { 962 | const el = this.getElement('rewatch_value'); 963 | if (!el) 964 | throw new Error('Unable to get rewatch value'); 965 | return el.value; 966 | } 967 | get rereadValue() { 968 | const el = this.getElement('reread_value'); 969 | if (!el) 970 | throw new Error('Unable to get reread value'); 971 | return el.value; 972 | } 973 | get comments() { 974 | const el = this.getElement('comments'); 975 | if (!el) 976 | throw new Error('Unable to get comments'); 977 | return el.value; 978 | } 979 | get discussionSetting() { 980 | const el = this.getElement('is_asked_to_discuss'); 981 | if (!el) 982 | throw new Error('Unable to get discussion value'); 983 | return el.value; 984 | } 985 | get SNSSetting() { 986 | const el = this.getElement('sns_post_type'); 987 | if (!el) 988 | throw new Error('Unable to get SNS setting'); 989 | return el.value; 990 | } 991 | } 992 | exports.MALForm = MALForm; 993 | 994 | 995 | /***/ }) 996 | /******/ ]); 997 | /************************************************************************/ 998 | /******/ // The module cache 999 | /******/ var __webpack_module_cache__ = {}; 1000 | /******/ 1001 | /******/ // The require function 1002 | /******/ function __webpack_require__(moduleId) { 1003 | /******/ // Check if module is in cache 1004 | /******/ var cachedModule = __webpack_module_cache__[moduleId]; 1005 | /******/ if (cachedModule !== undefined) { 1006 | /******/ return cachedModule.exports; 1007 | /******/ } 1008 | /******/ // Create a new module (and put it into the cache) 1009 | /******/ var module = __webpack_module_cache__[moduleId] = { 1010 | /******/ // no module.id needed 1011 | /******/ // no module.loaded needed 1012 | /******/ exports: {} 1013 | /******/ }; 1014 | /******/ 1015 | /******/ // Execute the module function 1016 | /******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); 1017 | /******/ 1018 | /******/ // Return the exports of the module 1019 | /******/ return module.exports; 1020 | /******/ } 1021 | /******/ 1022 | /************************************************************************/ 1023 | var __webpack_exports__ = {}; 1024 | // This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk. 1025 | (() => { 1026 | var exports = __webpack_exports__; 1027 | 1028 | Object.defineProperty(exports, "__esModule", ({ value: true })); 1029 | const Log_1 = __webpack_require__(1); 1030 | const Dom_1 = __webpack_require__(4); 1031 | const Anilist_1 = __webpack_require__(5); 1032 | const MAL_1 = __webpack_require__(6); 1033 | // Main business logic 1034 | const sync = async (e) => { 1035 | e.preventDefault(); 1036 | const anilistUsername = Dom_1.default.getAnilistUsername(); 1037 | if (!anilistUsername) 1038 | return; 1039 | const malUsername = Dom_1.default.getMALUsername(); 1040 | if (!malUsername) { 1041 | Log_1.default.info('You must be logged in!'); 1042 | return; 1043 | } 1044 | const csrfToken = Dom_1.default.getCSRFToken(); 1045 | Log_1.default.clear(); 1046 | Log_1.default.info(`Fetching data from Anilist...`); 1047 | const anilistList = await Anilist_1.getAnilistList(anilistUsername); 1048 | if (!anilistList) { 1049 | Log_1.default.info(`No data found for user ${anilistUsername}.`); 1050 | return; 1051 | } 1052 | Log_1.default.info(`Fetched Anilist data.`); 1053 | const mal = new MAL_1.default(malUsername, csrfToken); 1054 | await mal.syncType('anime', anilistList.anime); 1055 | await mal.syncType('manga', anilistList.manga); 1056 | Log_1.default.info('Import complete.'); 1057 | }; 1058 | // Entrypoint 1059 | (() => { 1060 | 'use strict'; 1061 | Dom_1.default.addDropDownItem(); 1062 | if (window.location.pathname === '/import.php') { 1063 | Dom_1.default.addImportForm(sync); 1064 | } 1065 | })(); 1066 | 1067 | })(); 1068 | 1069 | /******/ })() 1070 | ; -------------------------------------------------------------------------------- /meta.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema", 3 | "definitions": { 4 | "nonEmptyString": { 5 | "type": "string", 6 | "minLength": 1 7 | }, 8 | "multiString": { 9 | "anyOf": [ 10 | { "$ref": "#/definitions/nonEmptyString" }, 11 | { 12 | "type": "array", 13 | "items": { "$ref": "#/definitions/nonEmptyString" }, 14 | "uniqueItems": true 15 | } 16 | ] 17 | } 18 | }, 19 | "type": "object", 20 | "properties": { 21 | "name": { "$ref": "#/definitions/nonEmptyString" }, 22 | "description": { "type": "string" }, 23 | "namespace": { "$ref": "#/definitions/nonEmptyString" }, 24 | "version": { "$ref": "#/definitions/nonEmptyString" }, 25 | "downloadURL": { "$ref": "#/definitions/nonEmptyString" }, 26 | "updateURL": { "$ref": "#/definitions/nonEmptyString" }, 27 | "icon": { "$ref": "#/definitions/nonEmptyString" }, 28 | "include": { "$ref": "#/definitions/multiString" }, 29 | "exclude": { "$ref": "#/definitions/multiString" }, 30 | "match": { "$ref": "#/definitions/multiString" }, 31 | "require": { "$ref": "#/definitions/multiString" }, 32 | "resource": { "$ref": "#/definitions/multiString" }, 33 | "grant": { 34 | "anyOf": [ 35 | { 36 | "type": "array", 37 | "items": { 38 | "type": "string", 39 | "enum": [ 40 | "unsafeWindow", 41 | "GM_getValue", 42 | "GM_setValue", 43 | "GM_listValues", 44 | "GM_deleteValue", 45 | "GM_getResourceText", 46 | "GM_getResourceURL", 47 | "GM_addStyle", 48 | "GM_log", 49 | "GM_openInTab", 50 | "GM_registerMenuCommand", 51 | "GM_setClipboard", 52 | "GM_xmlhttpRequest" 53 | ] 54 | }, 55 | "uniqueItems": true 56 | }, 57 | { 58 | "type": "string", 59 | "enum": [ "none" ] 60 | } 61 | ], 62 | "default": "none" 63 | }, 64 | "run-at": { 65 | "type": "string", 66 | "enum": [ "document-end", "document-start", "document-idle" ], 67 | "default": "document-end" 68 | }, 69 | "noframes": { 70 | "type": "boolean", 71 | "default": false 72 | } 73 | }, 74 | "additionalProperties": { 75 | "anyOf": [ 76 | { "$ref": "#/definitions/multiString" }, 77 | { "type": "boolean" } 78 | ] 79 | }, 80 | "required": ["name", "namespace", "version"] 81 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "doukiscript", 3 | "version": "1.0.0", 4 | "description": "Import Anime and Manga Lists from Anilist (see https://anilist.co/forum/thread/2654 for more info)", 5 | "scripts": { 6 | "build": "webpack --progress", 7 | "test": "jest --bail --runInBand", 8 | "testwatch": "jest --watch --runInBand", 9 | "coverage": "jest --coverage" 10 | }, 11 | "keywords": [ 12 | "userscript" 13 | ], 14 | "author": "", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "@types/fetch-mock": "^7.2.0", 18 | "@types/jest": "^23.3.10", 19 | "@types/node": "^7.0.29", 20 | "@types/node-fetch": "^2.1.4", 21 | "@types/pad": "^1.0.0", 22 | "@types/tapable": "^1.0.4", 23 | "@types/webpack": "^4.4.22", 24 | "css-loader": "^3.0.0", 25 | "fetch-mock": "^7.2.8", 26 | "jest": "^24.8.0", 27 | "jsonschema": "^1.1.1", 28 | "node-fetch": "^2.3.0", 29 | "pad": "^1.1.0", 30 | "style-loader": "^0.18.2", 31 | "ts-jest": "^23.10.5", 32 | "ts-loader": "^5.3.2", 33 | "ts-node": "^7.0.1", 34 | "typescript": "^2.9.2", 35 | "webpack": "^4.28.3", 36 | "webpack-cli": "^3.1.2" 37 | }, 38 | "jest": { 39 | "roots": [ 40 | "" 41 | ], 42 | "transform": { 43 | "^.+\\.ts?$": "ts-jest" 44 | }, 45 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.ts?$", 46 | "moduleFileExtensions": [ 47 | "ts", 48 | "tsx", 49 | "js", 50 | "jsx", 51 | "json", 52 | "node" 53 | ], 54 | "setupFiles": [ 55 | "./__testutils__/setupJest.ts" 56 | ] 57 | }, 58 | "dependencies": {} 59 | } 60 | -------------------------------------------------------------------------------- /src/Anilist.ts: -------------------------------------------------------------------------------- 1 | import Log from './Log'; 2 | import { AnilistEntry, MediaList, FormattedEntry, DoukiAnilistData } from './Types'; 3 | 4 | const flatten = (obj: MediaList) => 5 | // Outer reduce concats arrays built by inner reduce 6 | Object.keys(obj).reduce((accumulator, list) => 7 | // Inner reduce builds an array out of the lists 8 | accumulator.concat(Object.keys(obj[list]).reduce((acc2, item) => 9 | // @ts-ignore 10 | acc2.concat(obj[list][item]), [])), []); 11 | 12 | const uniqify = (arr: Array) => { 13 | const seen = new Set(); 14 | return arr.filter(item => (seen.has(item.media.idMal) ? false : seen.add(item.media.idMal))); 15 | }; 16 | 17 | // Anilist Functions 18 | const anilistCall = (query: string, variables: any): Promise => 19 | fetch('https://graphql.anilist.co', { 20 | method: 'POST', 21 | headers: { 22 | 'Content-Type': 'application/json', 23 | Accept: 'application/json', 24 | }, 25 | body: JSON.stringify({ 26 | query, 27 | variables, 28 | }), 29 | }); 30 | 31 | const fetchList = (userName: string) => 32 | anilistCall(` 33 | query ($userName: String) { 34 | anime: MediaListCollection(userName: $userName, type: ANIME) { 35 | lists { 36 | entries { 37 | status 38 | score(format:POINT_10) 39 | progress 40 | startedAt { 41 | year 42 | month 43 | day 44 | } 45 | completedAt { 46 | year 47 | month 48 | day 49 | } 50 | repeat 51 | media { 52 | idMal 53 | title { 54 | romaji 55 | } 56 | } 57 | } 58 | } 59 | }, 60 | manga: MediaListCollection(userName: $userName, type: MANGA) { 61 | lists { 62 | entries { 63 | status 64 | score(format:POINT_10) 65 | progress 66 | progressVolumes 67 | startedAt { 68 | year 69 | month 70 | day 71 | } 72 | completedAt { 73 | year 74 | month 75 | day 76 | } 77 | repeat 78 | media { 79 | idMal 80 | title { 81 | romaji 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | `, { 89 | userName 90 | }) 91 | .then(res => res.json()) 92 | .then(res => res.data) 93 | .then(res => ({ 94 | anime: uniqify(flatten(res.anime.lists)), 95 | manga: uniqify(flatten(res.manga.lists)), 96 | })); 97 | 98 | const sanitize = (item: AnilistEntry, type: string): FormattedEntry => ({ 99 | type, 100 | progress: item.progress, 101 | progressVolumes: item.progressVolumes, 102 | startedAt: { 103 | year: item.startedAt.year || 0, 104 | month: item.startedAt.month || 0, 105 | day: item.startedAt.day || 0, 106 | }, 107 | completedAt: { 108 | year: item.completedAt.year || 0, 109 | month: item.completedAt.month || 0, 110 | day: item.completedAt.day || 0 111 | }, 112 | repeat: item.repeat, 113 | status: item.status, 114 | score: item.score, 115 | id: item.media.idMal, 116 | title: item.media.title.romaji 117 | }); 118 | 119 | const filterNoMalId = (item: FormattedEntry) => { 120 | if (item.id) return true; 121 | Log.error(`${item.type}: ${item.title}`); 122 | return false; 123 | } 124 | 125 | export const getAnilistList = (username: string): Promise => 126 | fetchList(username) 127 | .then(lists => ({ 128 | anime: lists.anime 129 | .map(item => sanitize(item, 'anime')) 130 | .filter(item => filterNoMalId(item)), 131 | manga: lists.manga 132 | .map(item => sanitize(item, 'manga')) 133 | .filter(item => filterNoMalId(item)), 134 | })); -------------------------------------------------------------------------------- /src/Dom.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DOUKI_FORM_ID, 3 | DOUKI_ANILIST_IMPORT_ID, 4 | DATE_SETTING_ID, 5 | CONTENT_ID, 6 | DOUKI_IMPORT_BUTTON_ID, 7 | SYNC_LOG_ID, 8 | ERROR_LOG_ID, 9 | ERROR_LOG_DIV_ID, 10 | ERROR_LOG_TOGGLE_ID, 11 | ANILIST_USERNAME_ID, 12 | SETTINGS_KEY, 13 | DATE_SETTINGS_KEY, 14 | DROPDOWN_ITEM_ID, 15 | DEBUG_SETTING_ID, 16 | DEBUG_LOG_ID, 17 | } from './const'; 18 | import { id } from './Util'; 19 | 20 | const importFormHTML = ` 21 |
      22 |

      Import From Anilist

      23 |
      24 |

      NOTICE: Use this script at your own risk. The author takes no responsibility for any damages of any kind.

      25 |

      It is highly recommended that you try this script out on a test MAL account before importing to your main account.

      26 |

      Visit the Anilist thread for this script to ask questions or report problems.

      27 |

      Please be patient. If the import goes any faster you will be in violation of MyAnimeList's Terms of Service.

      28 |
      29 |
      30 |

      31 |

      32 | 38 | 41 |

      42 |

      43 |
      44 |
      45 |
        46 |

        47 | 51 |
        52 |
          53 |
          54 |
          55 | `; 56 | 57 | const getLocalStorageSetting = (setting: string): string | null => { 58 | if (localStorage) { 59 | const value = localStorage.getItem(setting); 60 | if (value) return JSON.parse(value); 61 | } 62 | return null; 63 | } 64 | 65 | const setLocalStorageSetting = (setting: string, value: string) => { 66 | if (localStorage) { 67 | localStorage.setItem(setting, JSON.stringify(value)); 68 | } 69 | } 70 | 71 | export interface IDomMethods { 72 | addDropDownItem(): void 73 | addImportForm(syncFn: Function): void 74 | getDateSetting(): string | null 75 | getDebugSetting(): boolean | null 76 | getCSRFToken(): string | null 77 | getMALUsername(): string | null 78 | getAnilistUsername(): string | null 79 | } 80 | 81 | export class DomMethods implements IDomMethods { 82 | csrfToken: string | null = null; 83 | 84 | addDropDownItem() { 85 | if (document.querySelector(id(DROPDOWN_ITEM_ID))) return; 86 | const selector = '.header-menu-dropdown > ul > li:last-child'; 87 | const dropdown = document.querySelector(selector); 88 | if (dropdown) { 89 | const html = `
        • Import from Anilist
        • `; 90 | dropdown.insertAdjacentHTML('afterend', html); 91 | const link = document.querySelector(id(DROPDOWN_ITEM_ID)); 92 | link && link.addEventListener('click', function (e) { 93 | e.preventDefault(); 94 | window.location.replace('https://myanimelist.net/import.php'); 95 | }); 96 | } 97 | } 98 | 99 | addImportForm(syncFn: Function) { 100 | if (document.querySelector(id(DOUKI_FORM_ID))) return; 101 | const element = document.querySelector(id(CONTENT_ID)); 102 | if (!element) { 103 | throw new Error('Unable to add form to page'); 104 | } 105 | element.insertAdjacentHTML('afterend', importFormHTML); 106 | this.addImportFormEventListeners(syncFn); 107 | } 108 | 109 | // TODO break this up 110 | addImportFormEventListeners(syncFn: Function) { 111 | const importButton = document.querySelector(id(DOUKI_IMPORT_BUTTON_ID)); 112 | importButton && importButton.addEventListener('click', function (e) { 113 | syncFn(e); 114 | }); 115 | 116 | const textBox = document.querySelector(id(ANILIST_USERNAME_ID)) as HTMLInputElement; 117 | textBox && textBox.addEventListener('change', function (e: any) { 118 | setLocalStorageSetting(SETTINGS_KEY, e.target.value); 119 | }); 120 | const username = getLocalStorageSetting(SETTINGS_KEY); 121 | if (username && textBox) { 122 | textBox.value = username; 123 | } 124 | 125 | const dateFormatPicker = document.querySelector(id(DATE_SETTING_ID)) as HTMLSelectElement; 126 | dateFormatPicker && dateFormatPicker.addEventListener('change', function (e: any) { 127 | setLocalStorageSetting(DATE_SETTINGS_KEY, e.target.value); 128 | }); 129 | const dateOption = getLocalStorageSetting(DATE_SETTINGS_KEY); 130 | if (dateOption && dateFormatPicker) { 131 | dateFormatPicker.value = dateOption; 132 | } 133 | 134 | const errorToggle = document.querySelector(id(ERROR_LOG_TOGGLE_ID)) as HTMLButtonElement; 135 | errorToggle && errorToggle.addEventListener('click', function (e) { 136 | e.preventDefault(); 137 | const errorLog = document.querySelector(id(ERROR_LOG_DIV_ID)) as HTMLElement; 138 | if (errorLog.style.display === 'none') { 139 | errorLog.style.display = 'block'; 140 | } else { 141 | errorLog.style.display = 'none'; 142 | } 143 | }); 144 | } 145 | 146 | getDateSetting(): string { 147 | const dateSetting = document.querySelector(id(DATE_SETTING_ID)) as HTMLSelectElement; 148 | if (!dateSetting || !dateSetting.value) throw new Error('Unable to get date setting'); 149 | return dateSetting.value; 150 | } 151 | 152 | getDebugSetting(): boolean { 153 | const debugSetting = document.querySelector(id(DEBUG_SETTING_ID)) as HTMLInputElement; 154 | if (!debugSetting) throw new Error('Unable to get debug setting'); 155 | return debugSetting.checked; 156 | } 157 | 158 | getCSRFToken(): string { 159 | if (this.csrfToken) return this.csrfToken; 160 | const csrfTokenMeta = document.querySelector('meta[name~="csrf_token"]'); 161 | if (!csrfTokenMeta) throw new Error('Unable to get CSRF token - no meta element'); 162 | const csrfToken = csrfTokenMeta.getAttribute('content'); 163 | if (!csrfToken) throw new Error('Unable to get CSRF token - no content attribute'); 164 | this.csrfToken = csrfToken; 165 | return csrfToken; 166 | } 167 | 168 | getMALUsername(): string | null { 169 | const malUsernameElement = document.querySelector('.header-profile-link') as HTMLDivElement; 170 | if (!malUsernameElement) return null; 171 | return malUsernameElement.innerText; 172 | } 173 | 174 | getAnilistUsername(): string { 175 | const anilistUserElement = document.querySelector('#douki-anilist-username') as HTMLInputElement; 176 | if (!anilistUserElement) throw new Error('Unable to get Anilist username'); 177 | return anilistUserElement.value; 178 | } 179 | } 180 | 181 | export default new DomMethods(); -------------------------------------------------------------------------------- /src/Log.ts: -------------------------------------------------------------------------------- 1 | import { SYNC_LOG_ID, ERROR_LOG_ID, DEBUG_LOG_ID } from './const'; 2 | import { id, getOperationDisplayName } from './Util'; 3 | 4 | type NullableElement = HTMLElement | null; 5 | 6 | const getCountLog = (operation: string, type: string): NullableElement => 7 | document.querySelector(id(`douki-${operation}-${type}-items`)); 8 | 9 | export interface ILog { 10 | clear(type: string): void 11 | error(msg: string): void 12 | info(msg: string): void 13 | debug(msg: string): void 14 | addCountLog(operation: string, type: string, max: number): void 15 | updateCountLog(operation: string, type: string, count: number): void 16 | } 17 | 18 | export class Log implements ILog { 19 | errorLogElement: NullableElement = null; 20 | syncLogElement: NullableElement = null; 21 | debugLogElement: NullableElement = null; 22 | 23 | get errorLog() { 24 | if (!this.errorLogElement) { 25 | this.errorLogElement = document.querySelector(id(ERROR_LOG_ID)); 26 | } 27 | return this.errorLogElement; 28 | } 29 | 30 | get syncLog() { 31 | if (!this.syncLogElement) { 32 | this.syncLogElement = document.querySelector(id(SYNC_LOG_ID)); 33 | } 34 | return this.syncLogElement; 35 | } 36 | 37 | get debugLog() { 38 | if (!this.debugLogElement) { 39 | this.debugLogElement = document.querySelector(id(DEBUG_LOG_ID)); 40 | } 41 | return this.debugLogElement; 42 | } 43 | 44 | private clearErrorLog() { 45 | if (this.errorLog) { 46 | this.errorLog.innerHTML = ''; 47 | } 48 | } 49 | 50 | private clearSyncLog() { 51 | if (this.syncLog) { 52 | this.syncLog.innerHTML = ''; 53 | } 54 | } 55 | 56 | private clearDebugLog() { 57 | if (this.debugLog) { 58 | this.debugLog.innerHTML = ''; 59 | } 60 | } 61 | 62 | clear(type = '') { 63 | console.clear(); 64 | if (type !== 'error') this.clearSyncLog(); 65 | if (type !== 'sync') this.clearErrorLog(); 66 | this.clearDebugLog(); 67 | } 68 | 69 | error(msg: string) { 70 | if (this.errorLog) { 71 | this.errorLog.innerHTML += `
        • ${msg}
        • `; 72 | } else { 73 | console.error(msg); 74 | } 75 | } 76 | 77 | info(msg: string) { 78 | if (this.syncLog) { 79 | this.syncLog.innerHTML += `
        • ${msg}
        • `; 80 | } else { 81 | console.info(msg); 82 | } 83 | } 84 | 85 | debug(msg: string) { 86 | if (this.debugLog) { 87 | this.debugLog.innerHTML += `
        • ${msg}
        • `; 88 | } else { 89 | console.debug(msg); 90 | } 91 | } 92 | 93 | addCountLog(operation: string, type: string, max: number) { 94 | const opName = getOperationDisplayName(operation); 95 | const logId = `douki-${operation}-${type}-items`; 96 | this.info(`${opName} 0 of ${max} ${type} items.`); 97 | } 98 | 99 | updateCountLog(operation: string, type: string, count: number) { 100 | const countLog = getCountLog(operation, type) as HTMLSpanElement; 101 | if (!countLog) return; 102 | countLog.innerHTML = `${count}`; 103 | } 104 | } 105 | 106 | export default new Log(); -------------------------------------------------------------------------------- /src/MAL.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from './Util'; 2 | import { MALHashMap, FormattedEntry, MALLoadItem } from './Types'; 3 | import { MALEntry, createMALEntry } from './MALEntry'; 4 | import Log, { ILog } from './Log'; 5 | import Dom, { IDomMethods } from "./Dom"; 6 | 7 | export default class MAL { 8 | username: string 9 | csrfToken: string 10 | Log: ILog 11 | dom: IDomMethods 12 | 13 | constructor(username: string, csrfToken: string, log: ILog = Log, dom: IDomMethods = Dom) { 14 | this.username = username; 15 | this.csrfToken = csrfToken; 16 | this.Log = log; 17 | this.dom = dom; 18 | } 19 | 20 | private createMALHashMap(malList: Array, type: string): MALHashMap { 21 | const hashMap: MALHashMap = {}; 22 | malList.forEach(item => { 23 | hashMap[item[`${type}_id`]] = item; 24 | }); 25 | return hashMap; 26 | } 27 | 28 | private async getMALHashMap(type: string, list: Array = [], page = 1): Promise { 29 | const offset = (page - 1) * 300; 30 | const nextList = await fetch(`https://myanimelist.net/${type}list/${this.username}/load.json?offset=${offset}&status=7`) 31 | .then(async res => { 32 | if (res.status !== 200) { 33 | await sleep(2000); 34 | return this.getMALHashMap(type, list, page); 35 | } 36 | return res.json(); 37 | }); 38 | if (nextList && nextList.length) { 39 | await sleep(1500); 40 | return this.getMALHashMap(type, [...list, ...nextList], page + 1); 41 | } 42 | this.Log.info(`Fetched MyAnimeList ${type} list.`); 43 | return this.createMALHashMap([...list, ...nextList], type); 44 | } 45 | 46 | private async getEntriesList(anilistList: Array, type: string): Promise> { 47 | const malHashMap = await this.getMALHashMap(type); 48 | return anilistList.map(entry => createMALEntry(entry, malHashMap[entry.id], this.csrfToken, this.dom)); 49 | } 50 | 51 | private async malEdit(data: MALEntry) { 52 | const { type, id } = data; 53 | const formData = await data.formData(); 54 | return fetch(`https://myanimelist.net/ownlist/${type}/${id}/edit?hideLayout`, 55 | { 56 | credentials: 'include', 57 | headers: { 58 | accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', 59 | 'accept-language': 'en-US,en;q=0.9,ja;q=0.8', 60 | 'cache-control': 'max-age=0', 61 | 'content-type': 'application/x-www-form-urlencoded', 62 | 'upgrade-insecure-requests': '1' 63 | }, 64 | referrer: `https://myanimelist.net/ownlist/${type}/${id}/edit?hideLayout`, 65 | referrerPolicy: 'no-referrer-when-downgrade', 66 | body: formData, 67 | method: 'POST', 68 | mode: 'cors' 69 | }).then((res) => { 70 | if (res.status === 200) return res; 71 | throw new Error(`Error updating ${type} id ${id}`); 72 | }).then((res) => res.text()) 73 | .then((text: string) => { 74 | if (text.match(/.+Successfully updated entry.+/)) return; 75 | throw new Error(`Error updating ${type} id ${id}`); 76 | }); 77 | } 78 | 79 | private malAdd(data: MALEntry) { 80 | return fetch(`https://myanimelist.net/ownlist/${data.type}/add.json`, { 81 | method: 'post', 82 | headers: { 83 | 'Accept': '*/*', 84 | 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 85 | 'x-requested-with': 'XMLHttpRequest' 86 | }, 87 | body: JSON.stringify(data.postData) 88 | }) 89 | .then((res) => { 90 | if (res.status === 200) return res; 91 | throw new Error(JSON.stringify(data)); 92 | }); 93 | } 94 | 95 | private async syncList(type: string, list: Array, operation: string) { 96 | if (!list || !list.length) { 97 | return; 98 | } 99 | this.Log.addCountLog(operation, type, list.length); 100 | let itemCount = 0; 101 | const fn = operation === 'add' ? this.malAdd : this.malEdit; 102 | for (let item of list) { 103 | await sleep(500); 104 | try { 105 | await fn(item); 106 | itemCount++; 107 | this.Log.updateCountLog(operation, type, itemCount); 108 | } catch (e) { 109 | console.error(e); 110 | this.Log.info(`Error for ${type} ${item.title}. Try adding or updating it manually.`); 111 | } 112 | } 113 | } 114 | 115 | async syncType(type: string, anilistList: Array) { 116 | this.Log.info(`Fetching MyAnimeList ${type} list...`); 117 | let list = await this.getEntriesList(anilistList, type); 118 | const addList = list.filter(entry => entry.shouldAdd()); 119 | await this.syncList(type, addList, 'add'); 120 | 121 | // Refresh list to get episode/chapter counts of new completed items 122 | if (addList.length) { 123 | this.Log.info(`Refreshing MyAnimeList ${type} list...`); 124 | list = await this.getEntriesList(anilistList, type); 125 | } 126 | const updateList = list.filter(entry => entry.shouldUpdate()); 127 | await this.syncList(type, updateList, 'edit'); 128 | } 129 | } -------------------------------------------------------------------------------- /src/MALEntry.ts: -------------------------------------------------------------------------------- 1 | import * as T from "./Types"; 2 | import { MALForm } from './MALForm'; 3 | import Dom, { IDomMethods } from "./Dom"; 4 | import Log, { ILog } from './Log'; 5 | 6 | export const createMALEntry = (al: T.FormattedEntry, mal: T.MALLoadItem, csrfToken: string, dom: IDomMethods) => 7 | al.type === 'anime' ? 8 | new MALEntryAnime(al, mal, csrfToken, dom) : 9 | new MALEntryManga(al, mal, csrfToken, dom); 10 | 11 | type StringNumMap = { [key: string]: number } 12 | const MALStatus: StringNumMap = { 13 | Current: 1, 14 | Completed: 2, 15 | Paused: 3, 16 | Dropped: 4, 17 | Planning: 6 18 | } 19 | 20 | const getStatus = (status: string) => { 21 | // MAL status: 1/watching, 2/completed, 3/onhold, 4/dropped, 6/plantowatch 22 | // MAL handles REPEATING as a boolean, and keeps status as COMPLETE 23 | switch (status.trim()) { 24 | case 'CURRENT': 25 | return MALStatus.Current; 26 | case 'REPEATING': 27 | case 'COMPLETED': 28 | return MALStatus.Completed; 29 | case 'PAUSED': 30 | return MALStatus.Paused; 31 | case 'DROPPED': 32 | return MALStatus.Dropped; 33 | case 'PLANNING': 34 | return MALStatus.Planning; 35 | default: 36 | throw new Error(`unknown status "${status}"`); 37 | } 38 | } 39 | 40 | const createMALFormData = (malData: T.MALFormData): string => { 41 | let formData = ''; 42 | Object.keys(malData).forEach(key => { 43 | formData += `${encodeURIComponent(key)}=${encodeURIComponent(malData[key])}&`; 44 | }); 45 | return formData.replace(/&$/, ''); 46 | } 47 | 48 | export interface MALEntry { 49 | shouldUpdate(): boolean 50 | shouldAdd(): boolean 51 | formData(): Promise 52 | type: string 53 | id: number 54 | title: string 55 | postData: T.MALPostItem 56 | } 57 | 58 | export class BaseMALEntry implements MALEntry { 59 | alData: T.FormattedEntry 60 | malData: T.MALLoadItem 61 | _postData: T.MALPostItem 62 | csrfToken: string 63 | dom: IDomMethods 64 | log: ILog 65 | 66 | constructor( 67 | al: T.FormattedEntry, 68 | mal: T.MALLoadItem, 69 | csrfToken: string = '', 70 | dom: IDomMethods = Dom, 71 | log: ILog = Log 72 | ) { 73 | this.alData = al; 74 | this.malData = mal; 75 | this.csrfToken = csrfToken; 76 | this._postData = this.createPostData(); 77 | this.dom = dom; 78 | this.log = log; 79 | } 80 | 81 | protected createBaseMALPostItem(): T.MALPostItem { 82 | return { 83 | status: getStatus(this.alData.status), 84 | csrf_token: this.csrfToken, 85 | score: this.alData.score || 0, 86 | finish_date: { 87 | year: this.alData.completedAt.year || 0, 88 | month: this.alData.completedAt.month || 0, 89 | day: this.alData.completedAt.day || 0 90 | }, 91 | start_date: { 92 | year: this.alData.startedAt.year || 0, 93 | month: this.alData.startedAt.month || 0, 94 | day: this.alData.startedAt.day || 0 95 | } 96 | } as T.MALPostItem; 97 | } 98 | 99 | buildDateString(date: T.MediaDate): string | null { 100 | if (date.month === 0 && date.day === 0 && date.year === 0) return null; 101 | const dateSetting = this.dom.getDateSetting(); 102 | const month = `${String(date.month).length < 2 ? '0' : ''}${date.month}`; 103 | const day = `${String(date.day).length < 2 ? '0' : ''}${date.day}`; 104 | const year = `${date.year ? String(date.year).slice(-2) : 0}`; 105 | if (dateSetting === 'a') { 106 | return `${month}-${day}-${year}`; 107 | } 108 | return `${day}-${month}-${year}`; 109 | } 110 | 111 | shouldUpdate(): boolean { 112 | // If something went wrong or it didn't get added, update will not work 113 | if (!this.malData || !this._postData) { 114 | return false; 115 | } 116 | const debug = this.dom.getDebugSetting(); 117 | return Object.keys(this._postData).some(key => { 118 | switch (key) { 119 | case 'csrf_token': 120 | case 'anime_id': 121 | case 'manga_id': 122 | // This data is not part of the load.json list and so can't be used as update test 123 | case 'num_watched_times': 124 | case 'num_read_times': 125 | return false; 126 | case 'start_date': 127 | case 'finish_date': 128 | { 129 | // @ts-ignore 130 | const dateString = this.buildDateString(this._postData[key]); 131 | if (dateString !== this.malData[`${key}_string`]) { 132 | if (debug) { 133 | this.log.debug(`${this.alData.title}: ${key} differs; MAL ${this.malData[`${key}_string`]} AL ${dateString}`); 134 | } 135 | return true; 136 | } 137 | return false; 138 | } 139 | case 'num_read_chapters': 140 | case 'num_read_volumes': 141 | case 'num_watched_episodes': 142 | // Anlist and MAL have different volume, episode, and chapter counts for some media; 143 | // If the item is marked as completed, ignore differences (Status 2 is COMPLETED) 144 | // EXCEPT when the count is 0, in which case this was newly added without a count and needs 145 | // to be updated now that the count is available 146 | { 147 | if (this.malData.status === MALStatus.Completed && this.malData[key] !== 0) { 148 | return false; 149 | } 150 | if (this._postData[key] !== this.malData[key]) { 151 | if (debug) { 152 | this.log.debug(`${this.alData.title} ${key} differs; MAL ${this.malData[key]} AL ${this._postData[key]}`); 153 | } 154 | return true; 155 | } 156 | return false; 157 | } 158 | default: 159 | { 160 | // Treat falsy values as equivalent (!= doesn't do the trick here) 161 | if (!this._postData[key] && !this.malData[key]) { 162 | return false; 163 | } 164 | if (this._postData[key] !== this.malData[key]) { 165 | if (debug) { 166 | this.log.debug(`${this.alData.title} ${key} differs; MAL ${this.malData[key]} AL ${this._postData[key]}`); 167 | } 168 | return true; 169 | } 170 | return false; 171 | } 172 | } 173 | }); 174 | } 175 | 176 | shouldAdd(): boolean { 177 | return !this.malData; 178 | } 179 | 180 | formData(): Promise { 181 | throw new Error("Method not implemented."); 182 | } 183 | protected createPostData(): T.MALPostItem { 184 | throw new Error("Method not implemented."); 185 | } 186 | 187 | get type(): string { 188 | return this.alData.type; 189 | } 190 | get id(): number { 191 | return this.alData.id; 192 | } 193 | get title(): string { 194 | return this.alData.title; 195 | } 196 | get postData(): T.MALPostItem { 197 | return this._postData; 198 | } 199 | } 200 | 201 | export class MALEntryAnime extends BaseMALEntry { 202 | constructor( 203 | al: T.FormattedEntry, 204 | mal: T.MALLoadItem, 205 | csrfToken: string = '', 206 | dom: IDomMethods = Dom 207 | ) { 208 | super(al, mal, csrfToken, dom); 209 | } 210 | 211 | createPostData(): T.MALPostItem { 212 | const result = this.createBaseMALPostItem(); 213 | result.anime_id = this.alData.id; 214 | 215 | if (this.alData.repeat) result.num_watched_times = this.alData.repeat; 216 | 217 | /* Setting num_watched_episodes */ 218 | 219 | // If this is a new item, malData is undefined, so set count to 0 220 | // When the list refreshes the count will be available and be set then 221 | if (!this.malData) { 222 | result.num_watched_episodes = 0; 223 | return result; 224 | } 225 | 226 | // If malData.anime_num_episodes is 0, the show is currently airing; 227 | // We're forced to use AL's count even though that might be wrong 228 | if (this.malData.anime_num_episodes === 0) { 229 | result.num_watched_episodes = this.alData.progress; 230 | return result; 231 | } 232 | 233 | // If the show is completed, use MAL's count in case AL's count is different; 234 | // We don't want MAL showing higher or lower than their own count 235 | if (result.status === MALStatus.Completed) { 236 | result.num_watched_episodes = this.malData.anime_num_episodes; 237 | return result; 238 | } 239 | 240 | // Othewrise, use MAL's count as a max 241 | result.num_watched_episodes = Math.min(this.alData.progress, this.malData.anime_num_episodes); 242 | return result; 243 | } 244 | 245 | async formData(): Promise { 246 | const malFormData = new MALForm(this.alData.type, this.alData.id); 247 | await malFormData.get(); 248 | const formData = { 249 | anime_id: this.malData.anime_id, 250 | aeps: this.malData.anime_num_episodes || 0, 251 | astatus: this.malData.anime_airing_status, 252 | 'add_anime[status]': this._postData.status, 253 | 'add_anime[num_watched_episodes]': this._postData.num_watched_episodes || 0, 254 | 'add_anime[score]': this._postData.score || '', 255 | 'add_anime[start_date][month]': this._postData.start_date && this._postData.start_date.month || '', 256 | 'add_anime[start_date][day]': this._postData.start_date && this._postData.start_date.day || '', 257 | 'add_anime[start_date][year]': this._postData.start_date && this._postData.start_date.year || '', 258 | 'add_anime[finish_date][month]': this._postData.finish_date && this._postData.finish_date.month || '', 259 | 'add_anime[finish_date][day]': this._postData.finish_date && this._postData.finish_date.day || '', 260 | 'add_anime[finish_date][year]': this._postData.finish_date && this._postData.finish_date.year || '', 261 | 'add_anime[tags]': this.malData.tags || '', 262 | 'add_anime[priority]': malFormData.priority, 263 | 'add_anime[storage_type]': malFormData.storageType, 264 | 'add_anime[storage_value]': malFormData.storageValue, 265 | 'add_anime[num_watched_times]': this._postData.num_watched_times || 0, 266 | 'add_anime[rewatch_value]': malFormData.rewatchValue, 267 | 'add_anime[comments]': malFormData.comments, 268 | 'add_anime[is_asked_to_discuss]': malFormData.discussionSetting, 269 | 'add_anime[sns_post_type]': malFormData.SNSSetting, 270 | submitIt: 0, 271 | csrf_token: this.csrfToken, 272 | } as T.MALFormData; 273 | if (this.alData.status === 'REPEATING') { 274 | formData['add_anime[is_rewatching]'] = 1; 275 | } 276 | return createMALFormData(formData); 277 | } 278 | } 279 | 280 | export class MALEntryManga extends BaseMALEntry { 281 | constructor( 282 | al: T.FormattedEntry, 283 | mal: T.MALLoadItem, 284 | csrfToken: string = '', 285 | dom: IDomMethods = Dom 286 | ) { 287 | super(al, mal, csrfToken, dom); 288 | } 289 | 290 | createPostData(): T.MALPostItem { 291 | const result = this.createBaseMALPostItem(); 292 | result.manga_id = this.alData.id; 293 | 294 | if (this.alData.repeat) result.num_read_times = this.alData.repeat; 295 | 296 | /* Setting num_read_chapters and num_read_volumes */ 297 | 298 | // If this is a new item, malData is undefined, so set count to 0 299 | // When the list refreshes the count will be available and be set then 300 | if (!this.malData) { 301 | result.num_read_chapters = 0; 302 | result.num_read_volumes = 0; 303 | return result; 304 | } 305 | 306 | // If malData.manga_num_chapters is 0, the manga is still publishing; 307 | // We're forced to use AL's count even though that might be wrong 308 | if (this.malData.manga_num_chapters === 0) { 309 | result.num_read_chapters = this.alData.progress; 310 | result.num_read_volumes = this.alData.progressVolumes; 311 | return result; 312 | } 313 | 314 | // If the manga is completed, use MAL's count in case AL's count is different; 315 | // We don't want MAL showing higher or lower than their own count 316 | if (result.status === MALStatus.Completed) { 317 | result.num_read_chapters = this.malData.manga_num_chapters; 318 | result.num_read_volumes = this.malData.manga_num_volumes; 319 | return result; 320 | } 321 | 322 | // Othewrise, use MAL's count as a max 323 | result.num_read_chapters = Math.min(this.alData.progress, this.malData.manga_num_chapters); 324 | result.num_read_volumes = Math.min(this.alData.progressVolumes, this.malData.manga_num_volumes); 325 | return result; 326 | } 327 | 328 | async formData(): Promise { 329 | const malFormData = new MALForm(this.alData.type, this.alData.id); 330 | await malFormData.get(); 331 | const formData = { 332 | entry_id: 0, 333 | manga_id: this.malData.manga_id, 334 | 'add_manga[status]': this._postData.status, 335 | 'add_manga[num_read_volumes]': this._postData.num_read_volumes || 0, 336 | last_completed_vol: '', 337 | 'add_manga[num_read_chapters]': this._postData.num_read_chapters || 0, 338 | 'add_manga[score]': this._postData.score || '', 339 | 'add_manga[start_date][month]': this._postData.start_date && this._postData.start_date.month || '', 340 | 'add_manga[start_date][day]': this._postData.start_date && this._postData.start_date.day || '', 341 | 'add_manga[start_date][year]': this._postData.start_date && this._postData.start_date.year || '', 342 | 'add_manga[finish_date][month]': this._postData.finish_date && this._postData.finish_date.month || '', 343 | 'add_manga[finish_date][day]': this._postData.finish_date && this._postData.finish_date.day || '', 344 | 'add_manga[finish_date][year]': this._postData.finish_date && this._postData.finish_date.year || '', 345 | 'add_manga[tags]': this.malData.tags || '', 346 | 'add_manga[priority]': malFormData.priority, 347 | 'add_manga[storage_type]': malFormData.storageType, 348 | 'add_manga[num_retail_volumes]': malFormData.numRetailVolumes, 349 | 'add_manga[num_read_times]': this._postData.num_read_times || 0, 350 | 'add_manga[reread_value]': malFormData.rereadValue, 351 | 'add_manga[comments]': malFormData.comments, 352 | 'add_manga[is_asked_to_discuss]': malFormData.discussionSetting, 353 | 'add_manga[sns_post_type]': malFormData.SNSSetting, 354 | csrf_token: this.csrfToken, 355 | submitIt: 0 356 | } as T.MALFormData; 357 | if (this.alData.status === 'REPEATING') { 358 | formData['add_manga[is_rewatching]'] = 1; 359 | } 360 | return createMALFormData(formData); 361 | } 362 | } -------------------------------------------------------------------------------- /src/MALForm.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from "./Util"; 2 | 3 | export interface IMALForm { 4 | get(): Promise 5 | priority: string 6 | storageType: string 7 | storageValue: string 8 | numRetailVolumes: string 9 | rewatchValue: string 10 | rereadValue: string 11 | comments: string 12 | discussionSetting: string 13 | SNSSetting: string 14 | } 15 | 16 | export class MALForm implements IMALForm { 17 | type: string 18 | id: number 19 | document: Document | null = null 20 | 21 | constructor(type: string, id: number) { 22 | this.type = type; 23 | this.id = id; 24 | } 25 | 26 | private fetchDocument(type: string, id: number): Promise { 27 | return new Promise((resolve, reject) => { 28 | const xhr = new XMLHttpRequest(); 29 | xhr.onload = function () { 30 | return resolve(this.responseXML ? this.responseXML : null); 31 | } 32 | xhr.onerror = function (e) { 33 | reject(e); 34 | } 35 | xhr.open('GET', `https://myanimelist.net/ownlist/${type}/${id}/edit`); 36 | xhr.responseType = 'document'; 37 | xhr.send(); 38 | }); 39 | } 40 | 41 | private getElement(id: string): HTMLSelectElement | null { 42 | if (!this.document) throw new Error('Document not loaded'); 43 | return this.document.querySelector(`#add_${this.type}_${id}`) as HTMLSelectElement; 44 | } 45 | 46 | async get() { 47 | await sleep(500); 48 | const document = await this.fetchDocument(this.type, this.id); 49 | if (document) { 50 | this.document = document; 51 | } else { 52 | throw new Error('Unable to fetch form data'); 53 | } 54 | } 55 | 56 | get priority(): string { 57 | const el = this.getElement('priority') 58 | if (!el) throw new Error('Unable to get priority'); 59 | return el.value; 60 | } 61 | 62 | get storageType(): string { 63 | const el = this.getElement('storage_type') 64 | if (!el) throw new Error('Unable to get storage type'); 65 | return el.value; 66 | } 67 | 68 | get storageValue(): string { 69 | const el = this.getElement('storage_value'); 70 | if (!el) return '0'; 71 | return el.value; 72 | } 73 | 74 | get numRetailVolumes(): string { 75 | const el = this.getElement('num_retail_volumes'); 76 | if (!el) return '0'; 77 | return el.value; 78 | } 79 | 80 | get rewatchValue(): string { 81 | const el = this.getElement('rewatch_value'); 82 | if (!el) throw new Error('Unable to get rewatch value'); 83 | return el.value; 84 | } 85 | 86 | get rereadValue(): string { 87 | const el = this.getElement('reread_value'); 88 | if (!el) throw new Error('Unable to get reread value'); 89 | return el.value; 90 | } 91 | 92 | get comments(): string { 93 | const el = this.getElement('comments'); 94 | if (!el) throw new Error('Unable to get comments'); 95 | return el.value; 96 | } 97 | 98 | get discussionSetting(): string { 99 | const el = this.getElement('is_asked_to_discuss'); 100 | if (!el) throw new Error('Unable to get discussion value'); 101 | return el.value; 102 | } 103 | 104 | get SNSSetting(): string { 105 | const el = this.getElement('sns_post_type'); 106 | if (!el) throw new Error('Unable to get SNS setting'); 107 | return el.value; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Types.ts: -------------------------------------------------------------------------------- 1 | export type MediaDate = { 2 | year: number 3 | month: number 4 | day: number 5 | } 6 | 7 | export interface AnilistBaseEntry { 8 | status: string 9 | score: number 10 | progress: number 11 | progressVolumes: number 12 | startedAt: MediaDate 13 | completedAt: MediaDate 14 | repeat: number 15 | } 16 | 17 | export interface AnilistEntry extends AnilistBaseEntry { 18 | media: { 19 | idMal: number 20 | title: { 21 | romaji: string 22 | } 23 | } 24 | } 25 | 26 | export interface MediaList { 27 | entries: Array 28 | [key: string]: Array 29 | } 30 | 31 | interface MediaListCollection { 32 | lists: MediaList 33 | } 34 | 35 | export interface AnilistResponse { 36 | anime: MediaListCollection 37 | manga: MediaListCollection 38 | [key: string]: MediaListCollection 39 | } 40 | 41 | export interface FormattedEntry extends AnilistBaseEntry { 42 | type: string 43 | id: number 44 | title: string 45 | [key: string]: any 46 | } 47 | 48 | export interface FullDataEntry extends FormattedEntry { 49 | malData: MALLoadItem, 50 | malPostData: MALPostItem, 51 | } 52 | 53 | export interface DoukiAnilistData { 54 | anime: Array 55 | manga: Array 56 | } 57 | 58 | export interface BaseMALItem { 59 | status: number 60 | csrf_token: string 61 | score: number 62 | [key: string]: any 63 | } 64 | 65 | export interface BaseMALLoadItem extends BaseMALItem { 66 | finish_date_string: string | null 67 | start_date_string: string | null 68 | priority_string: string 69 | comments: string 70 | } 71 | 72 | export interface MALLoadAnime extends BaseMALLoadItem { 73 | anime_id: number 74 | num_watched_episodes: number 75 | anime_num_episodes: number 76 | anime_airing_status: number 77 | } 78 | 79 | export interface MALLoadManga extends BaseMALLoadItem { 80 | manga_id: number 81 | num_read_chapters: number 82 | num_read_volumes: number 83 | manga_num_chapters: number 84 | manga_num_volumes: number 85 | manga_publishing_status: number 86 | } 87 | 88 | export type MALLoadItem = MALLoadAnime | MALLoadManga; 89 | 90 | export interface BaseMALPostItem extends BaseMALItem { 91 | start_date: MediaDate 92 | finish_date: MediaDate 93 | } 94 | 95 | export interface MALPostAnime extends BaseMALPostItem { 96 | anime_id: number 97 | num_watched_times: number 98 | num_watched_episodes: number 99 | } 100 | 101 | export interface MALPostManga extends BaseMALPostItem { 102 | manga_id: number 103 | num_read_times: number 104 | num_read_chapters: number 105 | num_read_volumes: number 106 | } 107 | 108 | export type MALPostItem = MALPostAnime | MALPostManga; 109 | 110 | export interface MALHashMap { 111 | [key: number]: MALLoadItem 112 | } 113 | 114 | export interface MALFormData { 115 | csrf_token: string 116 | submitIt: number // NOT SURE WHAT THIS DOES, seems to be 0 117 | [key: string]: any 118 | } 119 | 120 | export interface MALAnimeFormData extends MALFormData { 121 | 'add_anime[comments]': string 122 | 'add_anime[finish_date][day]': string 123 | 'add_anime[finish_date][month]': string 124 | 'add_anime[finish_date][year]': string 125 | 'add_anime[is_asked_to_discuss]': number 126 | 'add_anime[is_rewatching]'?: number | string 127 | 'add_anime[num_watched_episodes]': number 128 | 'add_anime[num_watched_times]': number 129 | 'add_anime[priority]': number 130 | 'add_anime[rewatch_value]': number | null 131 | 'add_anime[score]': number 132 | 'add_anime[sns_post_type]': number 133 | 'add_anime[start_date][day]': string 134 | 'add_anime[start_date][month]': string 135 | 'add_anime[start_date][year]': string 136 | 'add_anime[status]': number 137 | 'add_anime[storage_type]': number | string 138 | 'add_anime[storage_value]': number 139 | 'add_anime[tags]': string 140 | aeps: number 141 | anime_id: number 142 | astatus: number // AIRING STATUS 143 | } 144 | 145 | export interface MALMangaFormData extends MALFormData { 146 | entry_id: number // NOT SURE WHAT THIS DOES, SEEMS TO BE 0 - could be volume in set 147 | manga_id: number 148 | 'add_manga[status]': number 149 | 'add_manga[num_read_volumes]': number 150 | last_completed_vol: number 151 | 'add_manga[num_read_chapters]': number 152 | 'add_manga[score]': number 153 | 'add_manga[start_date][month]': number 154 | 'add_manga[start_date][day]': number 155 | 'add_manga[start_date][year]': number 156 | 'add_manga[finish_date][month]': number 157 | 'add_manga[finish_date][day]': number 158 | 'add_manga[finish_date][year]': number 159 | 'add_manga[tags]': string 160 | 'add_manga[priority]': number 161 | 'add_manga[storage_type]': number 162 | 'add_manga[num_retail_volumes]': number // UNSURE WHERE TO GET THIS 163 | 'add_manga[num_read_times]': number 164 | 'add_manga[reread_value]': number 165 | 'add_manga[comments]': string 166 | 'add_manga[is_asked_to_discuss]': number 167 | 'add_manga[sns_post_type]': number 168 | } 169 | 170 | export default { 171 | IDocRepository: Symbol('IDocRepository'), 172 | IDomMethods: Symbol('IDomMethods'), 173 | ILog: Symbol('ILog') 174 | } -------------------------------------------------------------------------------- /src/Util.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(() => resolve(null), ms)); 2 | export const id = (str: string) => `#${str}`; 3 | export const getOperationDisplayName = (operation: string) => { 4 | switch (operation) { 5 | case 'add': 6 | return 'Adding'; 7 | case 'edit': 8 | return 'Updating'; 9 | case 'complete': 10 | return 'Fixing'; 11 | default: 12 | throw new Error('Unknown operation type'); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | export const DOUKI_FORM_ID = 'douki-form'; 2 | export const DOUKI_ANILIST_IMPORT_ID = 'douki-anilist-import'; 3 | export const DATE_SETTING_ID = 'douki-date_format'; 4 | export const CONTENT_ID = 'content'; 5 | export const DOUKI_IMPORT_BUTTON_ID = 'douki-import' 6 | export const SYNC_LOG_ID = 'douki-sync-log'; 7 | export const ERROR_LOG_ID = 'douki-error-log'; 8 | export const ERROR_LOG_TOGGLE_ID = 'douki-error-log-toggle'; 9 | export const ERROR_LOG_DIV_ID = 'douki-error-log-div'; 10 | export const ANILIST_USERNAME_ID = 'douki-anilist-username'; 11 | export const SETTINGS_KEY = 'douki-settings'; 12 | export const DATE_SETTINGS_KEY = 'douki-settings-date'; 13 | export const DROPDOWN_ITEM_ID = 'douki-sync'; 14 | export const DEBUG_SETTING_ID = 'douki-debug'; 15 | export const DEBUG_LOG_ID = 'douki-debug-log'; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Log from './Log'; 2 | import Dom from './Dom'; 3 | import { getAnilistList } from './Anilist'; 4 | import MAL from './MAL'; 5 | 6 | // Main business logic 7 | const sync = async (e: Event) => { 8 | e.preventDefault(); 9 | 10 | const anilistUsername = Dom.getAnilistUsername(); 11 | if (!anilistUsername) return; 12 | 13 | const malUsername = Dom.getMALUsername(); 14 | if (!malUsername) { 15 | Log.info('You must be logged in!'); 16 | return; 17 | } 18 | 19 | const csrfToken = Dom.getCSRFToken(); 20 | 21 | Log.clear(); 22 | Log.info(`Fetching data from Anilist...`); 23 | 24 | const anilistList = await getAnilistList(anilistUsername); 25 | if (!anilistList) { 26 | Log.info(`No data found for user ${anilistUsername}.`); 27 | return; 28 | } 29 | Log.info(`Fetched Anilist data.`); 30 | 31 | const mal = new MAL(malUsername, csrfToken); 32 | await mal.syncType('anime', anilistList.anime); 33 | await mal.syncType('manga', anilistList.manga); 34 | Log.info('Import complete.'); 35 | }; 36 | 37 | // Entrypoint 38 | (() => { 39 | 'use strict'; 40 | Dom.addDropDownItem(); 41 | 42 | if (window.location.pathname === '/import.php') { 43 | Dom.addImportForm(sync); 44 | } 45 | })(); 46 | -------------------------------------------------------------------------------- /src/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "file:../meta.schema.json", 3 | "name": "Douki", 4 | "namespace": "douki-e7d98778-9b83-45eb-a189-456bd1ce2ee1", 5 | "description": "Import Anime and Manga Lists from Anilist (see https://anilist.co/forum/thread/2654 for more info)", 6 | "version": "0.2.5", 7 | "include": [ 8 | "https://myanimelist.net/*" 9 | ] 10 | } -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd' or 'es2015'. */ 6 | "lib": [ 7 | "es2015", 8 | "dom", 9 | "es2016" 10 | ], /* Specify library files to be included in the compilation: */ 11 | // "allowJs": true, /* Allow javascript files to be compiled. */ 12 | // "checkJs": true, /* Report errors in .js files. */ 13 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 14 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | /* Strict Type-Checking Options */ 25 | "strict": true, /* Enable all strict type-checking options. */ 26 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 27 | // "strictNullChecks": true, /* Enable strict null checks. */ 28 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 29 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 30 | /* Additional Checks */ 31 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 32 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 33 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 34 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 35 | /* Module Resolution Options */ 36 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 37 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 38 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 39 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 40 | // "typeRoots": [], /* List of folders to include type definitions from. */ 41 | // "types": [], /* Type declaration files to be included in compilation. */ 42 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 43 | /* Source Map Options */ 44 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 45 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 46 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 47 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 48 | /* Experimental Options */ 49 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 50 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 51 | } 52 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd' or 'es2015'. */ 6 | "lib": [ 7 | "es2015", 8 | "dom" 9 | ], /* Specify library files to be included in the compilation: */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | // "outDir": "./", /* Redirect output structure to the directory. */ 17 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 18 | // "removeComments": true, /* Do not emit comments to output. */ 19 | // "noEmit": true, /* Do not emit outputs. */ 20 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 21 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 22 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 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 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 28 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 29 | /* Additional Checks */ 30 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 31 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 32 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 33 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 34 | /* Module Resolution Options */ 35 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 36 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 37 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 38 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 39 | //"typeRoots": [], /* List of folders to include type definitions from. */ 40 | "types": [ 41 | "node", 42 | "jest" 43 | ] /* Type declaration files to be included in compilation. */ 44 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 45 | /* Source Map Options */ 46 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 47 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 48 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 49 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 50 | /* Experimental Options */ 51 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 52 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 53 | } 54 | } -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import * as webpack from 'webpack'; 2 | import { validate } from 'jsonschema'; 3 | import pad = require('pad'); 4 | 5 | const metadataPath = './src/meta.json'; 6 | const metadata = require(metadataPath); 7 | const metadataSchema = require('./meta.schema.json'); 8 | 9 | interface IMetadata { 10 | [key: string]: string | boolean | string[]; 11 | } 12 | 13 | function generateHeader(metadata: IMetadata) { 14 | const validateResult = validate(metadata, metadataSchema); 15 | if (!validateResult.valid) { 16 | throw new Error(`The script metadata at ${metadataPath} is not valid.\n${validateResult}`); 17 | } 18 | 19 | const lines: string[] = []; 20 | const padLength = Math.max(...Object.keys(metadata).map(k => k.length)); 21 | const makeLine = (key: string, value: string) => `// @${pad(key, padLength)} ${value}`; 22 | 23 | lines.push('// ==UserScript=='); 24 | for (let key of Object.keys(metadata)) { 25 | if (key[0] === '$') continue; 26 | const value = metadata[key]; 27 | if (Array.isArray(value)) { 28 | for (let subValue of value) { 29 | lines.push(makeLine(key, subValue)); 30 | } 31 | } else if (typeof (value) === 'string') { 32 | lines.push(makeLine(key, value)); 33 | } else if (typeof (value) === 'boolean' && value) { 34 | lines.push(makeLine(key, '')); 35 | } 36 | } 37 | lines.push('// ==/UserScript==\n'); 38 | 39 | return lines.join('\n'); 40 | } 41 | 42 | export default { 43 | entry: './src/index.ts', 44 | output: { 45 | filename: `../douki.user.js` 46 | }, 47 | resolve: { 48 | extensions: ['.ts', '.tsx', '.js'] 49 | }, 50 | mode: 'none', 51 | module: { 52 | rules: [ 53 | { test: /\.tsx?$/, use: 'ts-loader' } 54 | ] 55 | }, 56 | plugins: [ 57 | new webpack.BannerPlugin({ 58 | banner: generateHeader(metadata), 59 | raw: true, 60 | entryOnly: true 61 | }) 62 | ] 63 | }; --------------------------------------------------------------------------------