media-synopsis 0.1.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2014 Thomas Geraghty
3
+ Copyright (c) 2014-2026 Thomas Geraghty
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,70 +1,75 @@
1
- Media Synopsis
2
- ==============
1
+ # Media Synopsis
3
2
 
4
- Media Synopsis lets you get a synopsis for books, movies, video games, anime and more.
3
+ Media Synopsis returns a short synopsis for a movie, book, anime, video game, or other media title. It reads the lead section of the matching English Wikipedia article and cleans it up for display.
5
4
 
6
- Installation
7
- ------------
5
+ Requires Node.js 20 or newer.
8
6
 
9
- `npm install media-synopsis`
7
+ ## Installation
10
8
 
11
- Usage
12
- -----
9
+ ```bash
10
+ npm install media-synopsis
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ The package is an ES module.
13
16
 
14
17
  ```javascript
15
- var synopsis = require('media-synopsis').synopsis;
16
-
17
- synopsis('beyblade', ['anime', 'Japanese animated'], function(err, text) {
18
- if (err) {
19
- console.log(err);
20
- return;
21
- }
22
- console.log(text.title +
23
- '\n=======================================\n\n' +
24
- text.content);
25
- });
26
- ```
18
+ import { synopsis } from 'media-synopsis';
27
19
 
20
+ const text = await synopsis('beyblade', ['anime', 'Japanese animated']);
21
+ console.log(text.title +
22
+ '\n=======================================\n\n' +
23
+ text.content);
24
+ ```
28
25
 
29
- Prints:
26
+ Prints something like:
30
27
 
31
28
  ```markdown
32
29
  Beyblade
33
30
  =======================================
34
31
 
35
- Beyblade, known in Japan as Explosive Shoot Beyblade ( Bakuten Shūto Beiburēdo?), is a Japanese manga series written and illustrated by Takao Aoki in order to promote sales of spinning tops called "Beyblades". Originally serialized in CoroCoro Comic from January, 2000 to December, 2003, the individual chapters were collected and published in 14 tankōbon by Shogakukan. The series focuses on a group of kids who form teams with which they battle one another using Beyblades.
36
-
37
- The manga is licensed for English language release in North America by Viz Media. An anime adaptation, also titled Beyblade and spanning 51 episodes, aired in Japan on TV Tokyo from January 8, 2001 to December 24, 2001. The second, Beyblade V-Force, ran for another 51 episodes from January 7, 2002 until December 30, 2002. Beyblade G-Revolution, the third and final adaptation, also spanned 52 episodes (the last two episodes were released together as a double-length special in Japan) and aired from January 6, 2003, until its conclusion on December 28, 2003. Nelvana licensed the anime for an English-language release. Takara Tomy also developed the Beyblade toy line.
32
+ Beyblade, known in Japan as Bakuten Shoot Beyblade, is a Japanese manga series written and illustrated by Takao Aoki, created to promote Takara's Beyblade spinning tops. The series focuses on a group of kids who form teams, which battle one another using Beyblades. It was serialized in Shogakukan's children's manga magazine CoroCoro Comic from August 1999 to June 2004, with its chapters collected and published in 14 tankōbon volumes. It was licensed for English release in North America by Viz Media and in Singapore, Australia and New Zealand by Chuang Yi.
38
33
 
39
- Source: [Wikipedia](http://en.wikipedia.org/wiki/Beyblade)
34
+ An anime adaptation by Madhouse aired in Japan on TV Tokyo from January to December 2001 and was followed by two sequel series, Beyblade V-Force and Beyblade G-Revolution, and the 2002 film Beyblade: Fierce Battle all three by Nippon Animedia, a partnership between Nippon Animation, Takara, Agatsuma and Epoch. Nelvana licensed and produced English-language adaptations of the anime series and the film.
40
35
 
36
+ Source: [Wikipedia](https://en.wikipedia.org/wiki/Beyblade_(manga))
41
37
  ```
42
- How it works?
43
- -------------
44
38
 
45
- Media Synopsis takes the first Google result for mediaTitle + first provided mediaType + site:wikipedia.org. So for the example above it gets the first Google result for 'beyblade anime site:wikipedia.org'.
39
+ The wording comes from the current Wikipedia article, so it will change as that article changes.
46
40
 
47
- It then visits the Wikipedia page and attempts to get a synopsis for the media types you provided. Since the Wikipedia page may not be just the media you're looking for, it checks the first 4 paragraphs for your media types. If none of them are found, or you didn't provide any then only the first paragraph is returned. If they are found, then every paragraph from the first to the one where they were found inclusive will be returned.
41
+ ## How it works
48
42
 
49
- This library was written to be used for generating placeholder text on a media collector's site before being replaced with real content. Linking back to Wikipedia as the source makes it nice and legal too so you might mind it useful if you want to try and provide feedback to the user that the title they're typing for a piece of media is correct.
43
+ `synopsis` searches the English Wikipedia API for the media title plus the first media type you passed. For the example above, the search is `beyblade anime`. It takes the first result and reads that page's lead section as plain text.
50
44
 
51
- This library has one other use: Auto-correcting media names based on the title of the Wikipedia article. This may be of limited usefulness but it's incredibly good for getting widely accepted titles for Japanese Anime (e.g. 'mahouka' returns 'The Irregular at Magic High School').
45
+ The lead is split into paragraphs. The first four are checked for any of the media types you provided. If one of those paragraphs mentions a type, every paragraph from the start through that one is kept. If none of them match, or you did not pass any types, only the first paragraph is kept.
52
46
 
53
- API
54
- ---
47
+ The page title is cleaned up for use as a display name. A Wikipedia suffix is removed, `List of X episodes` becomes `X`, and a trailing disambiguator such as `(film)` or `(manga)` is removed. That last part is what turns a loose query such as `mahouka` into `The Irregular at Magic High School`.
55
48
 
56
- ###`synopsis(mediaTitle, [mediaTypes], callback)`
49
+ The synopsis text drops Japanese and Hepburn pronunciation parentheses, and bracketed citation markers such as `[1]`. A Markdown link to the Wikipedia article is appended. Linking back to Wikipedia is what makes the placeholder text reasonable to show while a real description is still missing.
57
50
 
58
- mediaTitle is a string, mediaTypes is an array of strings, or alternatively if there is only one a string can be provided. Callback is a function that should take two params: error and response.
51
+ ## API
59
52
 
60
- Returns an object with a `title` and `content` property. The `title` is a cleaned up Wikipedia title for the page, with things like ' - Wikipedia, the free encyclopedia' and '(film)' removed. The content is the synopsis of the media based on 1 - 4 of the first paragraphs on the media's Wikipedia page. It removes things like links to sources and Asian pronunciations (quite basic right now).
53
+ ### `synopsis(mediaTitle, [mediaTypes])`
61
54
 
62
- Contributing
63
- ------------
55
+ `mediaTitle` is a string.
64
56
 
65
- Only real-world use can help improve the code that cleans up the content removing the irrelevant and making sure the title doesn't have anything extra included. If you spot unexpected output then please create an issue or submit a pull request and I'll try to fix.
57
+ `mediaTypes` is an optional array of strings. Only the first non-empty type is added to the Wikipedia search. All of them are used when choosing paragraphs.
66
58
 
67
- Tests?
68
- ------
59
+ The returned Promise resolves to an object with:
60
+
61
+ - `title` — the cleaned Wikipedia title
62
+ - `content` — the synopsis, including a `Source: [Wikipedia](...)` line
63
+
64
+ The Promise rejects with an `Error` when Wikipedia has no matching page or the request fails.
65
+
66
+ ## Contributing
67
+
68
+ The cleanup rules are deliberately small. If a real title comes back with a pronunciation, citation, or disambiguator still attached, open an issue with the query, the Wikipedia URL, and what the title or synopsis should have been.
69
+
70
+ ```bash
71
+ npm test
72
+ npm run lint
73
+ ```
69
74
 
70
- Eheheheh yeah, this library is like one big side effect. Tests this early for stuff like the content and title clean ups would have to be updated with every change and ultimately not really help all that much. I'm usually super pro-testing so if the library ever goes >150 lines I'll start breaking out functions and writing tests for the pure parts. Right now though linking to source wikipedia page and to the output and explaining what should be different in an issue is good enough.
75
+ Vitest covers title cleanup, paragraph selection, pronunciation stripping, and the Wikipedia client with a stand-in for `fetch`. The tests do not call Wikipedia.
@@ -0,0 +1,8 @@
1
+ import { type Synopsis } from './text.js';
2
+ export type { Synopsis };
3
+ /**
4
+ * Look up a short synopsis for a movie, book, anime, game, or other media.
5
+ * Only the first non-empty media type is added to the Wikipedia search.
6
+ * Every non-empty type is used when choosing lead paragraphs.
7
+ */
8
+ export declare function synopsis(mediaTitle: string, mediaTypes?: readonly string[]): Promise<Synopsis>;
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ import { buildSynopsis } from './text.js';
2
+ import { fetchWikipediaLead } from './wikipedia.js';
3
+ function mediaTypeList(mediaTypes) {
4
+ return [...mediaTypes];
5
+ }
6
+ /**
7
+ * Look up a short synopsis for a movie, book, anime, game, or other media.
8
+ * Only the first non-empty media type is added to the Wikipedia search.
9
+ * Every non-empty type is used when choosing lead paragraphs.
10
+ */
11
+ export async function synopsis(mediaTitle, mediaTypes = []) {
12
+ const types = mediaTypeList(mediaTypes);
13
+ const page = await fetchWikipediaLead(mediaTitle.trim(), types);
14
+ return buildSynopsis(page, types);
15
+ }
package/dist/text.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ export interface Synopsis {
2
+ content: string;
3
+ title: string;
4
+ }
5
+ export interface LeadPage {
6
+ extract: string;
7
+ title: string;
8
+ url: string;
9
+ }
10
+ export declare function getMediaTitle(fullLinkText: string): string;
11
+ export declare function getContentText(text: string, mediaTypes: readonly string[]): string;
12
+ export declare function removeUnwantedChars(text: string, title: string): string;
13
+ export declare function buildSynopsis(page: LeadPage, mediaTypes: readonly string[]): Synopsis;
package/dist/text.js ADDED
@@ -0,0 +1,71 @@
1
+ // Trailing disambiguator, such as " (film)" or " (manga)".
2
+ const TRAILING_DISAMBIGUATION = /\s+\([^)]*\)$/;
3
+ function escapeRegExp(value) {
4
+ // Characters that would otherwise change the meaning of a regular expression.
5
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6
+ }
7
+ export function getMediaTitle(fullLinkText) {
8
+ const withoutSite = fullLinkText.split('- Wikipedia')[0].trim();
9
+ // "List of Naruto episodes" keeps "Naruto".
10
+ const seriesName = /List of (.*) episodes/i.exec(withoutSite)?.[1]?.trim();
11
+ if (seriesName) {
12
+ return seriesName;
13
+ }
14
+ return withoutSite.replace(TRAILING_DISAMBIGUATION, '').trim();
15
+ }
16
+ export function getContentText(text, mediaTypes) {
17
+ const paragraphs = text
18
+ // One or more newlines separate paragraphs.
19
+ .split(/\n+/)
20
+ .map((paragraph) => paragraph.trim())
21
+ .filter(Boolean);
22
+ const opening = paragraphs[0];
23
+ if (opening === undefined) {
24
+ return '';
25
+ }
26
+ const needles = mediaTypes
27
+ .map((type) => type.trim())
28
+ .filter(Boolean);
29
+ if (needles.length === 0) {
30
+ return opening;
31
+ }
32
+ const pattern = needles.map(escapeRegExp).join('|');
33
+ // Case-insensitive whole word or phrase for any supplied media type.
34
+ const mediaTypePattern = new RegExp(`\\b(?:${pattern})\\b`, 'i');
35
+ const limit = Math.min(4, paragraphs.length);
36
+ for (let index = 0; index < limit; index += 1) {
37
+ const paragraph = paragraphs[index];
38
+ if (paragraph !== undefined && mediaTypePattern.test(paragraph)) {
39
+ return paragraphs.slice(0, index + 1).join('\n\n');
40
+ }
41
+ }
42
+ return opening;
43
+ }
44
+ export function removeUnwantedChars(text, title) {
45
+ // Parenthetical immediately after the title. `.` stops at a newline.
46
+ const titlePronunciation = new RegExp(`${escapeRegExp(title)}\\s*\\(.+?\\)`, 'i');
47
+ // Japanese or Hepburn pronunciation, including when it follows the original name. Stays on one line.
48
+ const namedPronunciation = /\s*\([^)\n]*(?:Japanese:|Hepburn:|[\u3040-\u30ff\u3400-\u9fff])[^)\n]*\)/g;
49
+ return text
50
+ .replace(namedPronunciation, '')
51
+ .replace(titlePronunciation, title)
52
+ // Citation markers such as [1].
53
+ .replace(/\[.*?\]/g, '')
54
+ // Runs of spaces or tabs left by the removals above.
55
+ .replace(/[ \t]{2,}/g, ' ')
56
+ // A space before punctuation, such as " ," or " .".
57
+ .replace(/[ \t]+([,.;:])/g, '$1')
58
+ .trim();
59
+ }
60
+ export function buildSynopsis(page, mediaTypes) {
61
+ const title = getMediaTitle(page.title);
62
+ const relevantParagraphs = getContentText(page.extract, mediaTypes);
63
+ const content = removeUnwantedChars(relevantParagraphs, title);
64
+ if (!content) {
65
+ throw new Error('No synopsis text found on the Wikipedia page');
66
+ }
67
+ return {
68
+ content: `${content}\n\nSource: [Wikipedia](${page.url})`,
69
+ title
70
+ };
71
+ }
@@ -0,0 +1,3 @@
1
+ import type { LeadPage } from './text.js';
2
+ export declare function searchQuery(mediaTitle: string, mediaTypes: readonly string[]): string;
3
+ export declare function fetchWikipediaLead(mediaTitle: string, mediaTypes: readonly string[]): Promise<LeadPage>;
@@ -0,0 +1,74 @@
1
+ import pkg from '../package.json' with { type: 'json' };
2
+ const API_URL = 'https://en.wikipedia.org/w/api.php';
3
+ const USER_AGENT = `media-synopsis/${pkg.version} (https://github.com/thethomaseffect/node-media-synopsis; thethomaseffect@gmail.com)`;
4
+ export function searchQuery(mediaTitle, mediaTypes) {
5
+ const hint = mediaTypes
6
+ .map((type) => type.trim())
7
+ .find((type) => type !== '');
8
+ if (!hint) {
9
+ return mediaTitle;
10
+ }
11
+ return `${mediaTitle} ${hint}`;
12
+ }
13
+ function pageUrl(page) {
14
+ if (typeof page.fullurl === 'string' && page.fullurl) {
15
+ return page.fullurl;
16
+ }
17
+ const slug = encodeURIComponent((page.title ?? '').replace(/ /g, '_'));
18
+ return `https://en.wikipedia.org/wiki/${slug}`;
19
+ }
20
+ async function readJson(response) {
21
+ try {
22
+ return await response.json();
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ function isWikiBody(value) {
29
+ return typeof value === 'object' && value !== null;
30
+ }
31
+ function failureMessage(response, body) {
32
+ if (isWikiBody(body) && typeof body.error?.info === 'string') {
33
+ return body.error.info;
34
+ }
35
+ return `Wikipedia request failed with status ${response.status}`;
36
+ }
37
+ export async function fetchWikipediaLead(mediaTitle, mediaTypes) {
38
+ const params = new URLSearchParams({
39
+ action: 'query',
40
+ explaintext: '1',
41
+ exintro: '1',
42
+ format: 'json',
43
+ formatversion: '2',
44
+ generator: 'search',
45
+ gsrlimit: '1',
46
+ gsrsearch: searchQuery(mediaTitle, mediaTypes),
47
+ inprop: 'url',
48
+ prop: 'extracts|info',
49
+ redirects: '1'
50
+ });
51
+ const response = await fetch(`${API_URL}?${params.toString()}`, {
52
+ headers: {
53
+ Accept: 'application/json',
54
+ 'User-Agent': USER_AGENT
55
+ },
56
+ signal: AbortSignal.timeout(15000)
57
+ });
58
+ const body = await readJson(response);
59
+ if (!response.ok || (isWikiBody(body) && body.error)) {
60
+ throw new Error(failureMessage(response, body));
61
+ }
62
+ if (!isWikiBody(body)) {
63
+ throw new Error('Wikipedia returned a response that was not JSON');
64
+ }
65
+ const page = body.query?.pages?.[0];
66
+ if (!page || page.missing === true || typeof page.title !== 'string' || typeof page.extract !== 'string' || page.extract.trim() === '') {
67
+ throw new Error('No Wikipedia results found for that media title and media type');
68
+ }
69
+ return {
70
+ extract: page.extract,
71
+ title: page.title,
72
+ url: pageUrl(page)
73
+ };
74
+ }
package/package.json CHANGED
@@ -1,25 +1,36 @@
1
1
  {
2
2
  "name": "media-synopsis",
3
- "version": "0.1.1",
4
- "description": "Get a synopsis for a Movie, Anime, Book or some other media using Google and Wikipedia.",
5
- "main": "index.js",
6
- "dependencies": {
7
- "google": "~0.3.3",
8
- "request": "~2.37.0",
9
- "unfluff": "~0.5.0"
3
+ "version": "1.0.0",
4
+ "description": "Get a synopsis for a movie, anime, book, or other media from Wikipedia.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "test": "tsc --noEmit && npm run build && vitest run",
24
+ "lint": "eslint .",
25
+ "prepublishOnly": "npm run lint && npm test"
10
26
  },
11
27
  "repository": {
12
28
  "type": "git",
13
- "url": "https://github.com/thethomaseffect/node-media-synopsis"
29
+ "url": "git+https://github.com/thethomaseffect/node-media-synopsis.git"
14
30
  },
15
31
  "bugs": {
16
32
  "url": "https://github.com/thethomaseffect/node-media-synopsis/issues"
17
33
  },
18
- "readmeFilename": "README.md",
19
- "devDependencies": {},
20
- "scripts": {
21
- "test": "echo \"Error: no test specified\" && exit 1"
22
- },
23
34
  "keywords": [
24
35
  "anime",
25
36
  "movie",
@@ -33,5 +44,17 @@
33
44
  "name": "Thomas Geraghty",
34
45
  "email": "thethomaseffect@gmail.com"
35
46
  },
36
- "license": "MIT"
47
+ "license": "MIT",
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "devDependencies": {
52
+ "@eslint/js": "^10.0.1",
53
+ "@types/node": "^26.6.5",
54
+ "eslint": "^10.12.0",
55
+ "jiti": "^2.7.0",
56
+ "typescript": "^6.0.3",
57
+ "typescript-eslint": "^8.71.2",
58
+ "vitest": "^5.0.4"
59
+ }
37
60
  }
package/.npmignore DELETED
@@ -1,25 +0,0 @@
1
- # Logs
2
- logs
3
- *.log
4
-
5
- # Runtime data
6
- pids
7
- *.pid
8
- *.seed
9
-
10
- # Directory for instrumented libs generated by jscoverage/JSCover
11
- lib-cov
12
-
13
- # Coverage directory used by tools like istanbul
14
- coverage
15
-
16
- # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
17
- .grunt
18
-
19
- # Compiled binary addons (http://nodejs.org/api/addons.html)
20
- build/Release
21
-
22
- # Dependency directory
23
- # Deployed apps should consider commenting this line out:
24
- # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git
25
- node_modules
package/index.js DELETED
@@ -1,84 +0,0 @@
1
- var extractor = require('unfluff');
2
- var request = require('request');
3
- var google = require('google');
4
-
5
- exports.synopsis = function(mediaTitle, mediaType, callback) {
6
- if (mediaType === null || mediaType === undefined) {
7
- mediaType = [''];
8
- }
9
- // Allow providing a string for mediaType but convert it to array
10
- // to simplify code
11
- if (typeof mediaType === 'string'){
12
- mediaType = [mediaType];
13
- }
14
- // Only use first media type to keep things simple
15
- google(mediaTitle + ' ' + mediaType[0] + ' site:wikipedia.org',
16
- function(err, next, links){
17
- if (err) {
18
- callback(err);
19
- return;
20
- }
21
- if (!links[0]) {
22
- callback("No Wikipedia Results found for that mediaTitle and mediaType");
23
- return;
24
- }
25
- // Sometimes this will be 'List of x Episodes' for TV shows. Example: 're hamatora'
26
- var title = getMediaTitle(links[0].title.trim());
27
- request(links[0].link, function (error, response, body) {
28
- if (!error && response.statusCode == 200) {
29
- data = extractor(body);
30
- getContentText(data.text, mediaType);
31
- var releventParagraphs = getContentText(data.text, mediaType);
32
- callback(undefined,
33
- {
34
- title: title,
35
- content: removeUnwantedChars(releventParagraphs, title) +
36
- '\n\nSource: [Wikipedia](' + links[0].link + ')'
37
- });
38
- } else {
39
- callback(error);
40
- }
41
- });
42
- });
43
- };
44
-
45
-
46
- function getMediaTitle(fullLinkText) {
47
- var title = fullLinkText.split('- Wikipedia')[0];
48
- var listOfEpsRegex = /.*List of (.*) episodes/.exec(title);
49
- if(listOfEpsRegex) {
50
- return listOfEpsRegex[1];
51
- }
52
- var filmRegex = /(.*) \(film\)/.exec(title);
53
- if(filmRegex) {
54
- return filmRegex[1];
55
- }
56
- return title;
57
- }
58
-
59
- getContentText = function(text, mediaTypes) {
60
- paragraphs = text.split('\n');
61
- // This will build an OR type regex for each media type
62
- var re = new RegExp('\\b' + mediaTypes.join('\\b|\\b') + '\\b', 'i');
63
- // Media type(s) is in first paragraph, return it
64
- if(re.test(paragraphs[0])) {
65
- return paragraphs[0];
66
- }
67
- // Media type(s) in in first 4 parapraphs, return all
68
- // paragraphs until it's mention
69
- for(var i = 1; i < 4; i++) {
70
- if(re.test(paragraphs[i])) {
71
- return paragraphs.slice(0, i + 1).join('\n');
72
- }
73
- }
74
- // Media type not found, return first paragraph
75
- return paragraphs[0];
76
- };
77
-
78
- function removeUnwantedChars(text, title) {
79
- // TODO: Replace [\s\S] with . once issue with unfluff is resolved
80
- var AsianPronunciationsRegex = new RegExp(title + '\\([\\s\\S]*?\\) ', 'i');
81
- return text
82
- .replace(AsianPronunciationsRegex, title) // Removes Asian pronunciations
83
- .replace(/(\[.*?\])/ig, ''); // Removes links to sources
84
- }