このコミットが含まれているのは:
2026-07-11 23:07:51 +09:00
コミット 19bf24432a
5個のファイルの変更70行の追加16行の削除
+7
ファイルの表示
@@ -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']) } } }
+6
ファイルの表示
@@ -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
+23 -5
ファイルの表示
@@ -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
+11 -8
ファイルの表示
@@ -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
+23 -3
ファイルの表示
@@ -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<Props> = ({ user }) => {
const [source, setSource] = useState ('')
const [phase, setPhase] = useState<Phase> ('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<Props> = ({ 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<Props> = ({ user }) => {
mappings,
existing })
setRows (data.rows)
setPhase ('preview')
}
catch
{
@@ -168,6 +180,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
{ rows: targets })
toast ({ title: `登録完了: ${result.created}`,
description: `スキップ ${result.skipped} 件、失敗 ${result.failed}` })
setPhase ('result')
}
catch
{
@@ -186,7 +199,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
<MainArea>
<Helmet><title>{`投稿インポート | ${SITE_TITLE}`}</title></Helmet>
<PageTitle>稿</PageTitle>
{rows.length === 0 ? (
{phase === 'source' ? (
<Form>
<p className="text-sm text-neutral-600 dark:text-neutral-300">
URL CSVTSVJSON
@@ -224,7 +237,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
URL 使CSV
</p>}
<Button type="button" onClick={parseSource} disabled={loading || !(source)}></Button>
</Form>) : parsed && rows.length === 0 ? (
</Form>) : phase === 'mapping' && parsed ? (
<Form>
<PageTitle></PageTitle>
<p>URL 稿</p>
@@ -238,8 +251,10 @@ const PostImportPage: FC<Props> = ({ user }) => {
<option value=""></option>
{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
</select></label>)}
<div className="overflow-x-auto"><table className="text-sm"><thead><tr>{columns.map (column => <th key={column.key}>{column.label}</th>)}</tr></thead><tbody>{parsed.rows.slice (0, 5).map (row => <tr key={row.sourceRow}>{row.values.map ((value, index) => <td key={index}>{value}</td>)}</tr>)}</tbody></table></div>
<Button type="button" variant="outline" onClick={() => setPhase ('source')}></Button>
<Button type="button" onClick={preview} disabled={loading || urlColumn === ''}>稿</Button>
</Form>) : (
</Form>) : phase === 'preview' ? (
<div className="space-y-4">
<div className="flex flex-wrap gap-3 rounded border p-3 dark:border-neutral-700">
<label>URL <select value={urlColumn} onChange={ev => setUrlColumn (ev.target.value)}>
@@ -258,7 +273,12 @@ const PostImportPage: FC<Props> = ({ user }) => {
<div className="space-y-3 md:hidden">{rows.map ((row, index) => <RowCard key={row.sourceRow} row={row} index={index} update={updateAttribute}/>)}</div>
<div className="hidden overflow-x-auto md:block"><table className="w-full text-sm"><thead><tr><th></th><th>URL</th><th></th><th></th><th></th><th></th><th></th><th>稿</th><th></th></tr></thead>
<tbody>{rows.map ((row, index) => <tr key={row.sourceRow} className="border-t dark:border-neutral-700"><td>{row.sourceRow}</td><td><input value={row.url} onChange={ev => updateURL (index, ev.target.value)}/></td><td><input value={row.attributes.title ?? ''} onChange={ev => updateAttribute (index, 'title', ev.target.value)}/></td><td><input value={row.attributes.thumbnailBase ?? ''} onChange={ev => updateAttribute (index, 'thumbnailBase', ev.target.value)}/></td><td><input value={row.attributes.originalCreatedFrom ?? ''} onChange={ev => updateAttribute (index, 'originalCreatedFrom', ev.target.value)}/><input value={row.attributes.originalCreatedBefore ?? ''} onChange={ev => updateAttribute (index, 'originalCreatedBefore', ev.target.value)}/></td><td><input value={String (row.attributes.duration ?? '')} onChange={ev => updateAttribute (index, 'duration', ev.target.value)}/></td><td><input value={row.attributes.tags ?? ''} onChange={ev => updateAttribute (index, 'tags', ev.target.value)}/></td><td><input value={row.attributes.parentPostIds ?? ''} onChange={ev => updateAttribute (index, 'parentPostIds', ev.target.value)}/></td><td>{row.status}<FieldError messages={[...row.warnings, ...Object.values (row.errors).flat ()]}/></td></tr>)}</tbody></table></div>
<Button type="button" variant="outline" onClick={() => setPhase ('mapping')}></Button>
<Button type="button" onClick={submit} disabled={loading}>稿</Button>
</div>) : (
<div className="space-y-3"><p></p>
<Button type="button" onClick={() => setPhase ('preview')}></Button>
<Button type="button" variant="outline" onClick={() => setPhase ('source')}></Button>
</div>)}
</MainArea>)
}