Skip to content

Instantly share code, notes, and snippets.

@mikeshiyan
Created September 24, 2021 20:58
Show Gist options
  • Save mikeshiyan/509e54df94db8213827ff1feef74b467 to your computer and use it in GitHub Desktop.
Save mikeshiyan/509e54df94db8213827ff1feef74b467 to your computer and use it in GitHub Desktop.
#!/usr/bin/node
/**
* @file
* NodeJS/Puppeteer скрипт для экспорта персональных списков с КиноПоиска в CSV.
*
* Команда:
* node kinopoisk-list-export.js url [путь-к-файлу.csv]
*
* Поддерживаемые списки и URL:
* - Оценки + просмотры пользователя. Пример URL для использования в командной
* строке:
* - https://www.kinopoisk.ru/user/1782900/votes/
* - Другие публичные списки фильмов пользователя. Примеры URL:
* - https://www.kinopoisk.ru/user/1782900/movies/
* - https://www.kinopoisk.ru/user/1782900/movies/list/type/6/
* Все перечисленные URL можно получить из адресной строки браузера при переходе
* на страницу того или иного списка без каких-либо манипуляций с ID. Приватные
* списки не поддерживаются, т.к. скрипт работает без авторизации, т.е. как
* анонимный пользователь.
*
* Путь к файлу CSV необязателен. Если не указать, данные будут выведены на
* экран.
*
* Установка:
* - Установить Node.js® https://nodejs.org
* - Скачать/скопировать данный файл и сохранить на своей машине.
* - Открыть терминал в папке с данным файлом.
* - Установить Puppeteer командой:
* npm i puppeteer
* Использование:
* - Открыть сайт КиноПоиска в браузере, перейти в свой профиль, далее на
* вкладку "Оценки" или "Фильмы". В последнем случае можно ещё перейти в
* любой из имеющихся списков ("папок"). Скопировать URL из адресной строки.
* - Запустить скрипт в терминале вышеописанной командой.
*
* Протестировано с версиями:
* - nodejs 16.9.1
* - puppeteer 10.4.0
*/
/**
* @licence
* This is free and unencumbered software released into the public domain.
*
* Anyone is free to copy, modify, publish, use, compile, sell, or
* distribute this software, either in source code form or as a compiled
* binary, for any purpose, commercial or non-commercial, and by any
* means.
*
* In jurisdictions that recognize copyright laws, the author or authors
* of this software dedicate any and all copyright interest in the
* software to the public domain. We make this dedication for the benefit
* of the public at large and to the detriment of our heirs and
* successors. We intend this dedication to be an overt act of
* relinquishment in perpetuity of all present and future rights to this
* software under copyright law.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
* OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
* ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* For more information, please refer to <http://unlicense.org/>
*/
const puppeteer = require('puppeteer');
const fs = require('fs');
(async () => {
const properties = ['dateTime', 'url', 'isSeries', 'name', 'originalName',
'year', 'duration', 'isWatched', 'userVote', 'rating', 'votes',
'ratingImdb', 'votesImdb'];
class Item {
constructor(item) {
properties.forEach(prop => {
this[prop] = item.hasOwnProperty(prop) ? item[prop] : '';
});
}
}
const outputRow = async values => {
const wrap = value => '"' + (value.includes('"') ? value.replace(/"/g, '""') : value) + '"';
let line = '';
if (values instanceof Item) {
const wrappedValues = [];
Object.entries(values).forEach(([key, value]) => {
const toWrap = value !== '' && (
key === 'dateTime' ||
key === 'url' ||
key === 'name' ||
key === 'originalName');
wrappedValues.push(toWrap ? wrap(value) : value);
});
line = wrappedValues.join(',');
}
else if (Array.isArray(values)) {
line = values.map(value => wrap(value)).join(',');
}
else {
line = values;
}
await outputStream.write(line + "\n");
};
const browser = await puppeteer.launch({ 'headless': false });
const page = await browser.newPage();
// Go to URL from the command line.
await page.goto(process.argv[2]);
// Select max items/page for faster running.
const $navigatorPerPage = await page.waitForSelector('select.navigator_per_page');
await $navigatorPerPage.press('PageDown');
const outputStream = process.argv[3]
? fs.createWriteStream(process.argv[3], { 'flags': 'ax' })
: process.stdout;
await outputRow(properties);
await page.waitForNavigation();
// Loop through pages.
while (true) {
let items;
if (await page.$('.profileFilmsList')) {
items = await page.$$eval('.profileFilmsList > .item', rows => rows.map(row => {
const item = {};
item.isWatched = true;
item.dateTime = row.querySelector('.date').textContent;
item.url = row.querySelector('.nameRus a').href;
item.userVote = row.querySelector('.vote').textContent;
const nameRus = row.querySelector('.nameRus a').textContent
.match(/^(?<name>.+) \((?<series>(мини-)?сериал, )?(?<year>\d{4})( – (\d{4}|\.{3}))?\)$/);
item.isSeries = !!nameRus.groups.series;
item.name = nameRus.groups.name;
item.year = nameRus.groups.year;
item.originalName = row.querySelector('.nameEng').textContent.trim();
if (item.originalName === '') item.originalName = item.name;
const $rating = row.querySelector('.rating b');
if ($rating) item.rating = $rating.textContent;
const $votes = row.querySelector('.rating span:first-of-type');
const $duration = row.querySelector('.rating span:last-of-type');
if ($votes) {
const votesMatch = $votes.textContent.match(/^\((.+)\)$/);
if (votesMatch) item.votes = votesMatch[1].replace(/\s+/g, '');
}
if ($duration) {
const durationMatch = $duration.textContent.match(/^(.+) мин\.$/);
if (durationMatch) item.duration = durationMatch[1];
}
return item;
}));
}
else if (await page.$('#itemList')) {
items = await page.$$eval('#itemList > .item', rows => rows.map(row => {
const item = {};
item.url = row.querySelector('.name').href;
item.name = row.querySelector('.name').textContent;
if (item.name.endsWith(' (мини-сериал)') || item.name.endsWith(' (сериал)')) {
item.isSeries = true;
item.name = item.name.substring(0, item.name.lastIndexOf(' '));
}
const $origNameYearDuration = row.querySelector('.name_rating ~ span');
if ($origNameYearDuration) {
const origNameYearDurationMatch = $origNameYearDuration.textContent
.match(/^(?<origName>.*?) (\((?<year>\d{4})( – (\d{4}|\.{3}))?\) )?((?<duration>\d+) мин\.)?$/);
item.originalName = origNameYearDurationMatch.groups.origName;
if (item.originalName === '') item.originalName = item.name;
if (origNameYearDurationMatch.groups.year) item.year = origNameYearDurationMatch.groups.year;
if (origNameYearDurationMatch.groups.duration) item.duration = origNameYearDurationMatch.groups.duration;
}
const $userVote = row.querySelector('.userVote');
if ($userVote || row.querySelector('.userEyeProfile')) item.isWatched = true;
if ($userVote) item.userVote = $userVote.textContent;
const $rating = row.querySelector('.rating');
const $imdb = row.querySelector('.imdb');
if ($rating) {
const ratingMatch = $rating.textContent.match(/^(.+) \((.+)\)$/);
item.rating = ratingMatch[1];
item.votes = ratingMatch[2].replace(/\s+/g, '');
}
if ($imdb) {
const imdbMatch = $imdb.textContent.match(/^IMDb: (.+) \((.+)\)$/);
item.ratingImdb = imdbMatch[1];
item.votesImdb = imdbMatch[2].replace(/\s+/g, '');
}
return item;
}));
}
else {
throw Error('Unknown page: ' + await page.title() + ' ' + page.url());
}
for (const item of items) {
await outputRow(new Item(item));
}
// Go to the next page if there's one.
const next = await page.$('.navigator .list > li.arr:nth-last-child(2) a');
if (!next) {
break;
}
await next.click();
await page.waitForNavigation();
}
await browser.close();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment