From 19bf24432a68525f082bdf414d21a96779a1d9eb Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 11 Jul 2026 23:07:51 +0900 Subject: [PATCH] #399 --- .../controllers/post_imports_controller.rb | 7 +++++ backend/app/services/post_import_previewer.rb | 6 ++++ backend/app/services/post_metadata_fetcher.rb | 28 +++++++++++++++---- .../services/preview/known_site_extractor.rb | 19 +++++++------ frontend/src/pages/posts/PostImportPage.tsx | 26 +++++++++++++++-- 5 files changed, 70 insertions(+), 16 deletions(-) diff --git a/backend/app/controllers/post_imports_controller.rb b/backend/app/controllers/post_imports_controller.rb index e6fd0f0..78a11b3 100644 --- a/backend/app/controllers/post_imports_controller.rb +++ b/backend/app/controllers/post_imports_controller.rb @@ -61,6 +61,13 @@ class PostImportsController < ApplicationController columns = Array(value['columns']) rows = Array(value['rows']) raise ArgumentError, '解析結果の形式が不正です.' unless columns.all?(Hash) && rows.all?(Hash) + raise ArgumentError, '取込件数が多すぎます.' if rows.length > PostImportSourceParser::MAX_ROWS + raise ArgumentError, '列数が多すぎます.' if columns.length > 50 + unless rows.all? { |row| Array(row['values']).length <= columns.length && + Array(row['values']).all? { _1.is_a?(String) && _1.bytesize <= PostImportSourceParser::MAX_CELL_BYTES } } + raise ArgumentError, '解析結果のセルが不正です.' + end + raise ArgumentError, '解析結果が大きすぎます.' if rows.sum { Array(_1['values']).sum(&:bytesize) } > PostImportSourceParser::MAX_BYTES { columns: columns.map { |column| column.slice('key', 'label') }, rows: rows.map { |row| { source_row: row['source_row'], values: Array(row['values']) } } } diff --git a/backend/app/services/post_import_previewer.rb b/backend/app/services/post_import_previewer.rb index 107f432..af2e467 100644 --- a/backend/app/services/post_import_previewer.rb +++ b/backend/app/services/post_import_previewer.rb @@ -26,6 +26,12 @@ class PostImportPreviewer should_fetch = fetch_metadata == true || fetch_metadata.to_i == row[:source_row].to_i metadata, fetch_warnings = metadata_for(url, should_fetch, metadata_cache, fetch_warnings) metadata.each do |field, value| + if field == 'tags' && provenance[field] != 'manual' + previous_origin = provenance[field] + attributes[field] = [attributes[field], value].compact.join(' ').split.uniq.join(' ') + provenance[field] = previous_origin == 'automatic' ? 'automatic' : previous_origin + next + end next unless provenance[field] == 'automatic' || attributes[field].blank? attributes[field] = value diff --git a/backend/app/services/post_metadata_fetcher.rb b/backend/app/services/post_metadata_fetcher.rb index f4ec66d..bafc936 100644 --- a/backend/app/services/post_metadata_fetcher.rb +++ b/backend/app/services/post_metadata_fetcher.rb @@ -7,21 +7,39 @@ class PostMetadataFetcher content = -> name { document.at_css("meta[property='#{ name }'], meta[name='#{ name }']")&.[]('content')&.strip.presence } duration = content.call('og:video:duration') || content.call('video:duration') published = content.call('article:published_time') || content.call('date') - published_at = Time.zone.parse(published.to_s) if published.present? + created_range = original_created_range(published) platform_tags = platform_tags(uri) { title: metadata[:title], thumbnail_base: Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url], - original_created_from: published_at&.iso8601, - original_created_before: published_at && (published_at + 1.minute).iso8601, + original_created_from: created_range&.first&.iso8601, + original_created_before: created_range&.last&.iso8601, duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil }, tags: platform_tags.join(' ') } end def self.platform_tags uri - return ['動画', 'YouTube'] if Preview::KnownSiteExtractor.thumbnail_url(uri) + return ['動画', 'YouTube'] if Preview::KnownSiteExtractor.youtube_video_id(uri) return ['動画', 'ニコニコ'] if Preview::KnownSiteExtractor.niconico_video_id(uri) [] end - private_class_method :platform_tags + + def self.original_created_range value + return nil if value.blank? + + raw = value.to_s.strip + from = Time.zone.parse(raw) + before = + case raw + when /\A\d{4}\z/ then from + 1.year + when /\A\d{4}-\d{2}\z/ then from + 1.month + when /\A\d{4}-\d{2}-\d{2}\z/ then from + 1.day + when /:\d{2}(?:Z|[+-]\d{2}:?\d{2})?\z/ then from + 1.second + else from + 1.minute + end + [from, before] + rescue ArgumentError, TypeError + nil + end + private_class_method :platform_tags, :original_created_range end diff --git a/backend/app/services/preview/known_site_extractor.rb b/backend/app/services/preview/known_site_extractor.rb index b36629c..07bb4ba 100644 --- a/backend/app/services/preview/known_site_extractor.rb +++ b/backend/app/services/preview/known_site_extractor.rb @@ -4,6 +4,15 @@ module Preview youtube_thumbnail(uri) end + def self.youtube_video_id(uri) + case uri.host&.downcase + when 'youtu.be' + uri.path.split('/').reject(&:blank?).first + when 'www.youtube.com', 'youtube.com', 'm.youtube.com' + uri.path == '/watch' ? URI.decode_www_form(uri.query.to_s).to_h['v'] : nil + end&.then { _1 if _1.match?(/\A[A-Za-z0-9_-]{6,20}\z/) } + end + def self.niconico_video_id(uri) case uri.host&.downcase when 'www.nicovideo.jp', 'nicovideo.jp' @@ -14,14 +23,8 @@ module Preview end def self.youtube_thumbnail(uri) - id = - case uri.host&.downcase - when 'youtu.be' - uri.path.split('/').reject(&:blank?).first - when 'www.youtube.com', 'youtube.com', 'm.youtube.com' - uri.path == '/watch' ? URI.decode_www_form(uri.query.to_s).to_h['v'] : nil - end - return unless id&.match?(/\A[A-Za-z0-9_-]{6,20}\z/) + id = youtube_video_id(uri) + return unless id "https://i.ytimg.com/vi/#{ id }/hqdefault.jpg" end diff --git a/frontend/src/pages/posts/PostImportPage.tsx b/frontend/src/pages/posts/PostImportPage.tsx index 6043479..9af57d9 100644 --- a/frontend/src/pages/posts/PostImportPage.tsx +++ b/frontend/src/pages/posts/PostImportPage.tsx @@ -30,6 +30,7 @@ type ImportRow = { status: 'ready' | 'warning' | 'error' existing: boolean } type ParsedImport = { columns: Column[], rows: { sourceRow: number, values: string[] }[] } +type Phase = 'source' | 'mapping' | 'preview' | 'result' const detectFormat = (source: string): 'csv' | 'tsv' => @@ -42,6 +43,7 @@ const publicGoogleSheetURL = (source: string): boolean => const PostImportPage: FC = ({ user }) => { const [source, setSource] = useState ('') + const [phase, setPhase] = useState ('source') const [format, setFormat] = useState<'csv' | 'tsv' | 'json' | 'google_sheets'> ('tsv') const [hasHeader, setHasHeader] = useState (false) const [jsonPath, setJsonPath] = useState ('') @@ -69,6 +71,15 @@ const PostImportPage: FC = ({ user }) => { setParsed (data) if (data.columns.length === 1) setUrlColumn ('0') + else + { + const candidate = data.columns.map ((_, index) => ({ index, + count: data.rows.filter (row => /^https?:\/\//.test (row.values[index] ?? '')).length })) + .sort ((a, b) => b.count - a.count)[0] + if (candidate?.count) + setUrlColumn (String (candidate.index)) + } + setPhase ('mapping') } catch { @@ -92,6 +103,7 @@ const PostImportPage: FC = ({ user }) => { mappings, existing }) setRows (data.rows) + setPhase ('preview') } catch { @@ -168,6 +180,7 @@ const PostImportPage: FC = ({ user }) => { { rows: targets }) toast ({ title: `登録完了: ${result.created} 件`, description: `スキップ ${result.skipped} 件、失敗 ${result.failed} 件` }) + setPhase ('result') } catch { @@ -186,7 +199,7 @@ const PostImportPage: FC = ({ user }) => { {`投稿インポート | ${SITE_TITLE}`} 投稿インポート - {rows.length === 0 ? ( + {phase === 'source' ? (

URL を一行に一件ずつ貼り付けるか、CSV・TSV・JSON を入力してください。 @@ -224,7 +237,7 @@ const PostImportPage: FC = ({ user }) => { 非公開シートには対応していません。ウェブ公開 URL を使用するか、CSV を保存して選択、又は内容を貼り付けてください。

} -
) : parsed && rows.length === 0 ? ( + ) : phase === 'mapping' && parsed ? (
列の割当て

URL 列と補助列を指定してから、投稿情報を取得します。

@@ -238,8 +251,10 @@ const PostImportPage: FC = ({ user }) => { {columns.map ((column, index) => )} )} +
{columns.map (column => )}{parsed.rows.slice (0, 5).map (row => {row.values.map ((value, index) => )})}
{column.label}
{value}
+ - ) : ( + ) : phase === 'preview' ? (
+ +
) : ( +

インポート処理が完了しました。

+ +
)}
) }