buyNow!

ふたばちゃんねるのスレッド上で貼られた特定のECサイトのURLからタイトルとあれば価格と画像を取得する

目前为 2023-04-23 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name buyNow!
  3. // @namespace http://2chan.net/
  4. // @version 0.4.4
  5. // @description ふたばちゃんねるのスレッド上で貼られた特定のECサイトのURLからタイトルとあれば価格と画像を取得する
  6. // @author ame-chan
  7. // @match http://*.2chan.net/b/res/*
  8. // @match https://*.2chan.net/b/res/*
  9. // @match https://kako.futakuro.com/futa/*
  10. // @match https://tsumanne.net/si/data/*
  11. // @icon https://www.google.com/s2/favicons?sz=64&domain=2chan.net
  12. // @grant GM_xmlhttpRequest
  13. // @connect amazon.co.jp
  14. // @connect www.amazon.co.jp
  15. // @connect amzn.to
  16. // @connect amzn.asia
  17. // @connect dlsite.com
  18. // @connect img.dlsite.jp
  19. // @connect bookwalker.jp
  20. // @connect c.bookwalker.jp
  21. // @connect store.steampowered.com
  22. // @connect cdn.cloudflare.steamstatic.com
  23. // @connect store.cloudflare.steamstatic.com
  24. // @license MIT
  25. // ==/UserScript==
  26. (function () {
  27. 'use strict';
  28. const WHITE_LIST_DOMAINS = [
  29. 'amazon.co.jp',
  30. 'amzn.to',
  31. 'amzn.asia',
  32. 'dlsite.com',
  33. 'bookwalker.jp',
  34. 'store.steampowered.com',
  35. ];
  36. const WHITE_LIST_SELECTORS = (() => WHITE_LIST_DOMAINS.map((domain) => `a[href*="${domain}"]`).join(','))();
  37. const convertHostname = (path) => new URL(path).hostname;
  38. const isAmazon = (path) => /^(www\.)?amazon.co.jp|amzn\.to|amzn\.asia$/.test(convertHostname(path));
  39. const isDLsite = (path) => /^(www\.)?dlsite\.com$/.test(convertHostname(path));
  40. const isBookwalker = (path) => /^(www\.)?bookwalker.jp$/.test(convertHostname(path));
  41. const isSteam = (path) => /^store\.steampowered\.com$/.test(convertHostname(path));
  42. const isProductPage = (url) =>
  43. /^https?:\/\/(www\.)?amazon\.co\.jp\/.*\/[A-Z0-9]{10}/.test(url) ||
  44. /^https?:\/\/amzn.(asia|to)\//.test(url) ||
  45. /^https?:\/\/(www\.)?dlsite\.com\/.+?\/[A-Z0-9]{8,}\.html/.test(url) ||
  46. /^https?:\/\/(www\.)?bookwalker\.jp\/[a-z0-9]{10}\-[a-z0-9]{4}\-[a-z0-9]{4}\-[a-z0-9]{4}\-[a-z0-9]{12}/.test(url) ||
  47. /^https?:\/\/(www\.)?bookwalker\.jp\/series\/[0-9]+\/list/.test(url) ||
  48. /^https?:\/\/store.steampowered.com\/(agecheck\/)?app\/\d+/.test(url);
  49. const getBrandName = (url) => {
  50. if (isAmazon(url)) {
  51. return 'amazon';
  52. } else if (isDLsite(url)) {
  53. return 'dlsite';
  54. } else if (isBookwalker(url)) {
  55. return 'bookwalker';
  56. } else if (isSteam(url)) {
  57. return 'steam';
  58. }
  59. return '';
  60. };
  61. const getSelectorConditions = {
  62. amazon: {
  63. price: (targetDocument) => {
  64. const priceRange = () => {
  65. const rangeElm = targetDocument.querySelector('.a-price-range');
  66. if (!rangeElm) return 0;
  67. rangeElm.querySelectorAll('.a-offscreen').forEach((el) => el.remove());
  68. return rangeElm.textContent?.replace(/[\s]+/g, '');
  69. };
  70. const price =
  71. targetDocument.querySelector('#twister-plus-price-data-price')?.value ||
  72. targetDocument.querySelector('#kindle-price')?.textContent?.replace(/[\s¥,]+/g, '') ||
  73. targetDocument.querySelector('[name="displayedPrice"]')?.value;
  74. return Number(price) || priceRange() || 0;
  75. },
  76. image: (targetDocument) =>
  77. targetDocument.querySelector('#landingImage')?.src ||
  78. targetDocument.querySelector('.unrolledScrollBox li:first-child img')?.src ||
  79. targetDocument.querySelector('[data-a-image-name]')?.src ||
  80. targetDocument.querySelector('#imgBlkFront')?.src,
  81. },
  82. dlsite: {
  83. price: (targetDocument) => {
  84. const url = targetDocument.querySelector('meta[property="og:url"]')?.content;
  85. const productId = url.split('/').pop()?.replace('.html', '');
  86. const priceElm = targetDocument.querySelector(`[data-product_id="${productId}"][data-price]`);
  87. return parseInt(priceElm?.getAttribute('data-price') || '0', 10);
  88. },
  89. image: (targetDocument) => targetDocument.querySelector('meta[property="og:image"]')?.content,
  90. },
  91. bookwalker: {
  92. price: (targetDocument) => {
  93. const price =
  94. Number(
  95. targetDocument
  96. .querySelector('.m-tile-list .m-tile .m-book-item__price-num')
  97. ?.textContent?.replace(/,/g, ''),
  98. ) || Number(targetDocument.querySelector('#jsprice')?.textContent?.replace(/[円,]/g, ''));
  99. return Number.isInteger(price) && price > 0 ? price : 0;
  100. },
  101. image: (targetDocument) =>
  102. targetDocument.querySelector('.m-tile-list .m-tile img')?.getAttribute('data-original') ||
  103. targetDocument.querySelector('meta[property="og:image"]')?.content,
  104. },
  105. steam: {
  106. price: (targetDocument) => {
  107. const elm =
  108. targetDocument.querySelector('.game_area_purchase_game_wrapper .game_purchase_price.price') ||
  109. targetDocument.querySelector('.game_area_purchase_game .game_purchase_price.price') ||
  110. targetDocument.querySelector('.game_area_purchase_game_wrapper .discount_final_price');
  111. const price = elm?.firstChild?.textContent?.replace(/[¥,\s\t\r\n]+/g, '');
  112. const isComingSoon = targetDocument.querySelector('.game_area_comingsoon');
  113. const isAgeCheck = targetDocument.querySelector('#app_agegate');
  114. const num = Number(price);
  115. if (isAgeCheck) {
  116. return 'ログインか年齢確認が必要です';
  117. } else if (isComingSoon) {
  118. return '近日登場';
  119. } else if (Number.isInteger(num) && num > 0) {
  120. return num;
  121. } else if (typeof price === 'string') {
  122. return price;
  123. }
  124. return 0;
  125. },
  126. image: (targetDocument) => targetDocument.querySelector('meta[property="og:image"]')?.content,
  127. },
  128. };
  129. const addedStyle = `<style id="userjs-buyNow-style">
  130. .userjs-title {
  131. display: flex;
  132. flex-direction: row;
  133. margin: 8px 0 16px;
  134. gap: 16px;
  135. padding: 16px;
  136. line-height: 1.6 !important;
  137. color: #ff3860 !important;
  138. background-color: #fff;
  139. border-radius: 4px;
  140. }
  141. .userjs-title-inner {
  142. width: 400px;
  143. }
  144. .userjs-link {
  145. padding-right: 24px;
  146. background-image: url('data:image/svg+xml;charset=utf8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2038%2038%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20stroke%3D%22%23000%22%3E%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Cg%20transform%3D%22translate(1%201)%22%20stroke-width%3D%222%22%3E%3Ccircle%20stroke-opacity%3D%22.5%22%20cx%3D%2218%22%20cy%3D%2218%22%20r%3D%2218%22%2F%3E%3Cpath%20d%3D%22M36%2018c0-9.94-8.06-18-18-18%22%3E%20%3CanimateTransform%20attributeName%3D%22transform%22%20type%3D%22rotate%22%20from%3D%220%2018%2018%22%20to%3D%22360%2018%2018%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%2F%3E%3C%2Fpath%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E');
  147. background-repeat: no-repeat;
  148. background-position: right center;
  149. }
  150. .userjs-image {
  151. max-width: none !important;
  152. max-height: none !important;
  153. transition: all 0.3s ease-in-out;
  154. border-radius: 4px;
  155. }
  156. .userjs-price {
  157. display: block;
  158. margin-top: 4px;
  159. color: #228b22 !important;
  160. font-weight: 700;
  161. }
  162. [data-id="userjs-loading"] {
  163. margin-left: 4px;
  164. }
  165. </style>`;
  166. if (!document.querySelector('#userjs-buyNow-style')) {
  167. document.head.insertAdjacentHTML('beforeend', addedStyle);
  168. }
  169. class FileReaderEx extends FileReader {
  170. constructor() {
  171. super();
  172. }
  173. #readAs(blob, ctx) {
  174. return new Promise((res, rej) => {
  175. super.addEventListener('load', ({ target }) => target?.result && res(target.result));
  176. super.addEventListener('error', ({ target }) => target?.error && rej(target.error));
  177. super[ctx](blob);
  178. });
  179. }
  180. readAsArrayBuffer(blob) {
  181. return this.#readAs(blob, 'readAsArrayBuffer');
  182. }
  183. readAsDataURL(blob) {
  184. return this.#readAs(blob, 'readAsDataURL');
  185. }
  186. }
  187. const fetchData = (url, responseType) =>
  188. new Promise((resolve) => {
  189. let options = {
  190. method: 'GET',
  191. url,
  192. timeout: 10000,
  193. onload: (result) => {
  194. if (result.status === 200) {
  195. return resolve(result.response);
  196. }
  197. return resolve(false);
  198. },
  199. onerror: () => resolve(false),
  200. ontimeout: () => resolve(false),
  201. };
  202. if (typeof responseType === 'string') {
  203. options = {
  204. ...options,
  205. responseType,
  206. };
  207. }
  208. GM_xmlhttpRequest(options);
  209. });
  210. const setFailedText = (linkElm) => {
  211. linkElm.insertAdjacentHTML('afterend', `<span class="userjs-title">データ取得失敗</span>`);
  212. };
  213. const getPriceText = (price) => {
  214. let priceText = price;
  215. if (!price) return '';
  216. if (typeof price === 'number' && Number.isInteger(price) && price > 0) {
  217. priceText = new Intl.NumberFormat('ja-JP', {
  218. style: 'currency',
  219. currency: 'JPY',
  220. }).format(price);
  221. }
  222. return `<span class="userjs-price">${priceText}</span>`;
  223. };
  224. const setTitleText = ({ targetDocument, selectorCondition, linkElm }) => {
  225. const titleElm = targetDocument.querySelector('title');
  226. if (!titleElm || !titleElm?.textContent) return;
  227. const price = selectorCondition.price(targetDocument);
  228. const priceText = getPriceText(price);
  229. const nextSibling = linkElm.nextElementSibling;
  230. let title = titleElm.textContent;
  231. if (nextSibling && nextSibling instanceof HTMLElement && nextSibling.tagName.toLowerCase() === 'br') {
  232. nextSibling.style.display = 'none';
  233. }
  234. if (title === 'サイトエラー') {
  235. const errorText = targetDocument.querySelector('#error_box')?.textContent;
  236. if (errorText) {
  237. title = errorText;
  238. }
  239. }
  240. linkElm?.insertAdjacentHTML(
  241. 'afterend',
  242. `<div class="userjs-title">
  243. <span class="userjs-title-inner">${title}${priceText}</span>
  244. </div>`,
  245. );
  246. };
  247. const setImageElm = async ({ imagePath, titleTextElm }) => {
  248. const imageMinSize = 150;
  249. const imageMaxSize = 600;
  250. const imageEventHandler = (e) => {
  251. const self = e.currentTarget;
  252. if (!(self instanceof HTMLImageElement)) return;
  253. if (self.width === imageMinSize) {
  254. self.width = imageMaxSize;
  255. } else {
  256. self.width = imageMinSize;
  257. }
  258. };
  259. const blob = await fetchData(imagePath, 'blob');
  260. if (!blob) return;
  261. const dataUrl = await new FileReaderEx().readAsDataURL(blob);
  262. const div = document.createElement('div');
  263. div.classList.add('userjs-imageWrap');
  264. const img = document.createElement('img');
  265. img.src = dataUrl;
  266. img.width = imageMinSize;
  267. img.classList.add('userjs-image');
  268. div.appendChild(img);
  269. img.addEventListener('click', imageEventHandler);
  270. titleTextElm.querySelector('.userjs-title-inner')?.insertAdjacentElement('beforebegin', div);
  271. };
  272. const setLoading = (linkElm) => {
  273. const parentElm = linkElm.parentElement;
  274. if (
  275. parentElm instanceof HTMLFontElement ||
  276. !isProductPage(linkElm.href) ||
  277. parentElm?.querySelector('[data-id="userjs-loading"]')
  278. ) {
  279. return;
  280. }
  281. linkElm.classList.add('userjs-link');
  282. };
  283. const removeLoading = (targetElm) => targetElm.classList.remove('userjs-link');
  284. const isAmazonConfirmAdultPage = (targetDocument) => targetDocument.querySelector('#black-curtain-warning') !== null;
  285. const getAmazonConfirmAdultPageHref = (targetDocument) => {
  286. const yesBtnLinkElm = targetDocument.querySelector('#black-curtain-yes-button a');
  287. if (yesBtnLinkElm instanceof HTMLAnchorElement) {
  288. return `https://www.amazon.co.jp${yesBtnLinkElm.getAttribute('href')}`;
  289. }
  290. return false;
  291. };
  292. const getAmazonAdultDocument = async (targetDocument, linkElm, parser) => {
  293. const newHref = getAmazonConfirmAdultPageHref(targetDocument);
  294. const htmlData = newHref && (await fetchData(newHref));
  295. if (!htmlData) {
  296. setFailedText(linkElm);
  297. removeLoading(linkElm);
  298. return false;
  299. }
  300. return parser.parseFromString(htmlData, 'text/html');
  301. };
  302. const insertURLData = async (linkElm) => {
  303. const parentElm = linkElm.parentElement;
  304. if (parentElm instanceof HTMLFontElement || !isProductPage(linkElm.href)) {
  305. removeLoading(linkElm);
  306. return;
  307. }
  308. const htmlData = await fetchData(linkElm.href);
  309. if (!htmlData) {
  310. setFailedText(linkElm);
  311. removeLoading(linkElm);
  312. return;
  313. }
  314. const parser = new DOMParser();
  315. let targetDocument = parser.parseFromString(htmlData, 'text/html');
  316. // アダルトページ確認画面スキップ
  317. if (isAmazonConfirmAdultPage(targetDocument)) {
  318. const amazonAdultDocument = await getAmazonAdultDocument(targetDocument, linkElm, parser);
  319. if (amazonAdultDocument) {
  320. targetDocument = amazonAdultDocument;
  321. }
  322. }
  323. const brandName = getBrandName(linkElm.href);
  324. if (brandName === '') {
  325. setFailedText(linkElm);
  326. removeLoading(linkElm);
  327. return;
  328. }
  329. const selectorCondition = getSelectorConditions[brandName];
  330. setTitleText({
  331. targetDocument,
  332. selectorCondition,
  333. linkElm,
  334. });
  335. const titleTextElm = linkElm.nextElementSibling;
  336. const imagePath = selectorCondition.image(targetDocument);
  337. if (imagePath && titleTextElm) {
  338. await setImageElm({
  339. imagePath,
  340. titleTextElm,
  341. });
  342. }
  343. removeLoading(linkElm);
  344. };
  345. const replaceDefaultURL = (targetElm) => {
  346. const linkElms = targetElm.querySelectorAll('a[href]');
  347. const replaceUrl = (url) => {
  348. const regex = /http:\/\/www\.dlsite\.com\/(.+?)\/dlaf\/=\/link\/work\/aid\/[a-zA-Z]+\/id\/(RJ[0-9]+)\.html/;
  349. const newUrlFormat = 'https://www.dlsite.com/$1/work/=/product_id/$2.html';
  350. return url.replace(regex, newUrlFormat);
  351. };
  352. for (const linkElm of linkElms) {
  353. const brandName = getBrandName(linkElm.href);
  354. const href = linkElm.getAttribute('href');
  355. if (brandName === 'dlsite') {
  356. linkElm.href = replaceUrl(href.replace('/bin/jump.php?', ''));
  357. } else {
  358. linkElm.href = href.replace('/bin/jump.php?', '');
  359. }
  360. }
  361. };
  362. const searchLinkElements = (targetElm) => {
  363. const linkElms = targetElm.querySelectorAll(WHITE_LIST_SELECTORS);
  364. if (!linkElms.length) return;
  365. for (const linkElm of linkElms) {
  366. if (!(linkElm instanceof HTMLElement)) continue;
  367. setLoading(linkElm);
  368. void insertURLData(linkElm);
  369. }
  370. };
  371. const mutationLinkElements = async (mutations) => {
  372. for (const mutation of mutations) {
  373. for (const addedNode of mutation.addedNodes) {
  374. if (!(addedNode instanceof HTMLElement)) continue;
  375. replaceDefaultURL(addedNode);
  376. searchLinkElements(addedNode);
  377. }
  378. }
  379. };
  380. const threadElm = document.querySelector('.thre');
  381. if (threadElm instanceof HTMLElement) {
  382. replaceDefaultURL(threadElm);
  383. searchLinkElements(threadElm);
  384. const observer = new MutationObserver(mutationLinkElements);
  385. observer.observe(threadElm, {
  386. childList: true,
  387. });
  388. }
  389. })();