Compare commits

..

6 Commits

Author SHA1 Message Date
0e32cc3f17
extract from attrubite 2025-05-06 20:47:10 +03:00
f220f9d9d7
small refactoring 2025-05-06 20:31:02 +03:00
4302176348
extract from field 2025-05-06 19:03:58 +03:00
01c28aead2
extract from field 2025-05-06 19:01:18 +03:00
da86d3b5d2
frontend add new field 2025-05-06 16:51:07 +03:00
ae2cdb4f14
frontend fields refactoring 2025-05-06 16:49:16 +03:00
15 changed files with 424 additions and 158 deletions

View File

@ -0,0 +1,5 @@
export type EnumValue = {
label: string
value: number
}
export type Enum = EnumValue[]

View File

@ -1,20 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import Field from "@/components/Field.vue"; import TextField from "@/components/inputs/TextField.vue";
import {type Field as FieldSpec} from "@/urlmaker/specs";
import {validateOr, validatePreset, validateUrl} from "@/urlmaker/validators.ts";
import Btn from "@/components/Btn.vue"; import Btn from "@/components/Btn.vue";
import {onMounted, onUnmounted, ref, watch} from "vue"; import {onMounted, onUnmounted, ref, watch} from "vue";
import Modal from "@/components/Modal.vue"; import Modal from "@/components/Modal.vue";
import {validatePreset, validateUrl} from "@/urlmaker/validators.ts";
const field: FieldSpec = {
name: '',
input_type: 'url',
label: 'URL of feed or preset',
default: '',
required: true,
validate: validateOr(validateUrl, validatePreset),
}
const visible = defineModel('visible', { const visible = defineModel('visible', {
type: Boolean, type: Boolean,
@ -33,11 +23,10 @@ watch(visible, () => {
const valid = ref(false); const valid = ref(false);
watch(url, (value) => { watch(url, (value) => {
valid.value = field.validate(value).ok; valid.value = validateUrl(value) || validatePreset(value);
}); });
const accept = () => { const accept = () => {
valid.value = field.validate(url.value).ok;
if (valid.value) { if (valid.value) {
emit('update:modelValue', url.value); emit('update:modelValue', url.value);
emit('update:visible', false); emit('update:visible', false);
@ -59,7 +48,13 @@ onUnmounted(() => {
<template> <template>
<Modal v-model="visible"> <Modal v-model="visible">
<Field :field="field" v-model="url" :focused="true"/> <TextField
name="url"
input_type="url"
label="URL of feed or preset"
v-model="url"
:focused="true"
/>
<Btn :active="valid" @click="accept">Edit</Btn> <Btn :active="valid" @click="accept">Edit</Btn>
</Modal> </Modal>
</template> </template>

View File

@ -1,22 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import {fields, type Specs} from '@/urlmaker/specs.ts'; import {fields, InputType, type SpecField} from '@/urlmaker/specs.ts';
import Field from "@/components/Field.vue"; import TextField from "@/components/inputs/TextField.vue";
import RadioButtons from "@/components/inputs/RadioButtons.vue";
import {useWizardStore} from "@/stores/wizard.ts"; import {useWizardStore} from "@/stores/wizard.ts";
const store = useWizardStore(); const store = useWizardStore();
const groups: SpecField[][] = [];
for (const field of fields) {
if(groups.length === 0 || groups[groups.length - 1][0].group != field.group) {
groups.push([field]);
} else {
groups[groups.length - 1].push(field);
}
}
</script> </script>
<template> <template>
<div> <div>
<Field v-for="field in fields" <div class="group" v-for="group in Object.values(groups)">
:field="field" <template v-for="field in group">
<TextField
v-if="field.input_type === InputType.Url || field.input_type === InputType.Text"
v-show="!field.show_if || field.show_if(store.specs)"
:name="field.name"
:label="field.label"
:input_type="field.input_type"
:model-value="store.specs[field.name]" :model-value="store.specs[field.name]"
@update:model-value="event => store.updateSpec(field.name, event)" @update:model-value="event => store.updateSpec(field.name, event)"
></Field> ></TextField>
<RadioButtons
v-if="field.input_type === InputType.Radio"
v-show="!field.show_if || field.show_if(store.specs)"
:name="field.name"
:label="field.label"
:values="field.enum!"
:model-value="store.specs[field.name]"
@update:model-value="event => store.updateSpec(field.name, event)"
></RadioButtons>
</template>
</div>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
div.group {
margin: 20px 0;
}
</style> </style>

View File

@ -0,0 +1,51 @@
<script setup lang="ts">
import {getCurrentInstance} from "vue";
import type {Enum} from "@/common/enum.ts";
const {name, label, values} = defineProps<{
name: string
label: string,
values: Enum,
}>();
const componentId = 'field' + getCurrentInstance()?.uid;
const model = defineModel();
</script>
<template>
<div class="field">
<span class="field-label"><label>{{ label }}</label></span>
<template class="value" v-for="enumValue in values">
<input
type="radio"
:name="name"
:value="enumValue.value"
:id="`${componentId}_${enumValue.value}`"
v-model="model"
/>
<label class="radio-label" :for="`${componentId}_${enumValue.value}`">{{ enumValue.label }}</label>
</template>
</div>
</template>
<style scoped lang="scss">
div.field {
margin: 0 0 8px 0;
}
.field-label {
font-size: 0.9em;
margin-right: 8px;
}
.radio-label {
font-size: 0.9em;
}
input, .radio-label, .field-label {
vertical-align: middle;
}
input {
margin-top: 0;
}
</style>

View File

@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type {Field} from "@/urlmaker/specs.ts";
import {getCurrentInstance, onMounted, useTemplateRef} from "vue"; import {getCurrentInstance, onMounted, useTemplateRef} from "vue";
const {field, focused} = defineProps<{ const {name, label, input_type, focused} = defineProps<{
field: Field, name: string
label: string,
input_type: 'text' | 'url',
focused?: boolean, focused?: boolean,
}>(); }>();
const id = 'field' + getCurrentInstance()?.uid; const id = 'field' + getCurrentInstance()?.uid;
@ -18,9 +19,9 @@ onMounted(() => {
<template> <template>
<div class="field"> <div class="field">
<div class="label"><label :for="id">{{ field.label }}</label></div> <div class="label"><label :for="id">{{ label }}</label></div>
<div class="input"> <div class="input">
<input :type="field.input_type" :name="field.name" :id="id" v-model="model" ref="field"/> <input :type="input_type" :name="name" :id="id" v-model="model" ref="field"/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -19,8 +19,8 @@ watch(existingLink, async (value) => {
if(!value) return; if(!value) return;
existingLink.value = ""; existingLink.value = "";
try { try {
if(validateUrl(value).ok) store.updateSpecs(await decodeUrl(value)); if(validateUrl(value)) store.updateSpecs(await decodeUrl(value));
else if (validatePreset(value).ok) store.updateSpecs(await decodePreset(value)); else if (validatePreset(value)) store.updateSpecs(await decodePreset(value));
} catch (e) { } catch (e) {
console.log(e); console.log(e);
alert(`Decoding error: ${e}`); alert(`Decoding error: ${e}`);
@ -60,7 +60,6 @@ function screenshot() {
<template> <template>
<div class="wrapper"> <div class="wrapper">
<SpecsForm class="specs-form"></SpecsForm> <SpecsForm class="specs-form"></SpecsForm>
<!-- <Btn :active="store.formValid" @click="generateLink">Generate link</Btn>-->
<Btn :active="store.formValid" @click="screenshot">Screenshot</Btn> <Btn :active="store.formValid" @click="screenshot">Screenshot</Btn>
<Btn @click="editModalVisible = true">Edit existing task / import preset</Btn> <Btn @click="editModalVisible = true">Edit existing task / import preset</Btn>
<Btn @click="store.reset">Reset Form</Btn> <Btn @click="store.reset">Reset Form</Btn>

View File

@ -1,5 +1,5 @@
import {defineStore} from "pinia"; import {defineStore} from "pinia";
import {emptySpecs, type Field, type FieldNames, fields, type Specs} from "@/urlmaker/specs.ts"; import {emptySpecs, type SpecField, fields, type Specs, type SpecValue} from "@/urlmaker/specs.ts";
import {computed, reactive} from "vue"; import {computed, reactive} from "vue";
import {debounce} from "es-toolkit"; import {debounce} from "es-toolkit";
@ -14,7 +14,7 @@ export const useWizardStore = defineStore('wizard', () => {
const formValid = computed(() => { const formValid = computed(() => {
return fields.every(field => ( return fields.every(field => (
specs[field.name].length === 0 && !(field as Field).required || field.validate(specs[field.name]).ok !specs[field.name] && !(field as SpecField).required || field.validate(specs[field.name]!)
)); ));
}); });
@ -22,7 +22,7 @@ export const useWizardStore = defineStore('wizard', () => {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(specs)); localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(specs));
}, 100); }, 100);
function updateSpec(fieldName: FieldNames, newValue: string) { function updateSpec(fieldName: keyof Specs, newValue: SpecValue) {
specs[fieldName] = newValue; specs[fieldName] = newValue;
updateLocalStorage(); updateLocalStorage();
} }

View File

@ -5,6 +5,10 @@
* git: https://github.com/thesayyn/protoc-gen-ts */ * git: https://github.com/thesayyn/protoc-gen-ts */
import * as pb_1 from "google-protobuf"; import * as pb_1 from "google-protobuf";
export namespace rssalchemy { export namespace rssalchemy {
export enum ExtractFrom {
InnerText = 0,
Attribute = 1
}
export class Specs extends pb_1.Message { export class Specs extends pb_1.Message {
#one_of_decls: number[][] = []; #one_of_decls: number[][] = [];
constructor(data?: any[] | { constructor(data?: any[] | {
@ -15,6 +19,8 @@ export namespace rssalchemy {
selector_description?: string; selector_description?: string;
selector_author?: string; selector_author?: string;
selector_created?: string; selector_created?: string;
created_extract_from?: ExtractFrom;
created_attribute_name?: string;
selector_content?: string; selector_content?: string;
selector_enclosure?: string; selector_enclosure?: string;
cache_lifetime?: string; cache_lifetime?: string;
@ -43,6 +49,12 @@ export namespace rssalchemy {
if ("selector_created" in data && data.selector_created != undefined) { if ("selector_created" in data && data.selector_created != undefined) {
this.selector_created = data.selector_created; this.selector_created = data.selector_created;
} }
if ("created_extract_from" in data && data.created_extract_from != undefined) {
this.created_extract_from = data.created_extract_from;
}
if ("created_attribute_name" in data && data.created_attribute_name != undefined) {
this.created_attribute_name = data.created_attribute_name;
}
if ("selector_content" in data && data.selector_content != undefined) { if ("selector_content" in data && data.selector_content != undefined) {
this.selector_content = data.selector_content; this.selector_content = data.selector_content;
} }
@ -96,6 +108,18 @@ export namespace rssalchemy {
set selector_created(value: string) { set selector_created(value: string) {
pb_1.Message.setField(this, 7, value); pb_1.Message.setField(this, 7, value);
} }
get created_extract_from() {
return pb_1.Message.getFieldWithDefault(this, 11, ExtractFrom.InnerText) as ExtractFrom;
}
set created_extract_from(value: ExtractFrom) {
pb_1.Message.setField(this, 11, value);
}
get created_attribute_name() {
return pb_1.Message.getFieldWithDefault(this, 12, "") as string;
}
set created_attribute_name(value: string) {
pb_1.Message.setField(this, 12, value);
}
get selector_content() { get selector_content() {
return pb_1.Message.getFieldWithDefault(this, 8, "") as string; return pb_1.Message.getFieldWithDefault(this, 8, "") as string;
} }
@ -122,6 +146,8 @@ export namespace rssalchemy {
selector_description?: string; selector_description?: string;
selector_author?: string; selector_author?: string;
selector_created?: string; selector_created?: string;
created_extract_from?: ExtractFrom;
created_attribute_name?: string;
selector_content?: string; selector_content?: string;
selector_enclosure?: string; selector_enclosure?: string;
cache_lifetime?: string; cache_lifetime?: string;
@ -148,6 +174,12 @@ export namespace rssalchemy {
if (data.selector_created != null) { if (data.selector_created != null) {
message.selector_created = data.selector_created; message.selector_created = data.selector_created;
} }
if (data.created_extract_from != null) {
message.created_extract_from = data.created_extract_from;
}
if (data.created_attribute_name != null) {
message.created_attribute_name = data.created_attribute_name;
}
if (data.selector_content != null) { if (data.selector_content != null) {
message.selector_content = data.selector_content; message.selector_content = data.selector_content;
} }
@ -168,6 +200,8 @@ export namespace rssalchemy {
selector_description?: string; selector_description?: string;
selector_author?: string; selector_author?: string;
selector_created?: string; selector_created?: string;
created_extract_from?: ExtractFrom;
created_attribute_name?: string;
selector_content?: string; selector_content?: string;
selector_enclosure?: string; selector_enclosure?: string;
cache_lifetime?: string; cache_lifetime?: string;
@ -193,6 +227,12 @@ export namespace rssalchemy {
if (this.selector_created != null) { if (this.selector_created != null) {
data.selector_created = this.selector_created; data.selector_created = this.selector_created;
} }
if (this.created_extract_from != null) {
data.created_extract_from = this.created_extract_from;
}
if (this.created_attribute_name != null) {
data.created_attribute_name = this.created_attribute_name;
}
if (this.selector_content != null) { if (this.selector_content != null) {
data.selector_content = this.selector_content; data.selector_content = this.selector_content;
} }
@ -222,6 +262,10 @@ export namespace rssalchemy {
writer.writeString(6, this.selector_author); writer.writeString(6, this.selector_author);
if (this.selector_created.length) if (this.selector_created.length)
writer.writeString(7, this.selector_created); writer.writeString(7, this.selector_created);
if (this.created_extract_from != ExtractFrom.InnerText)
writer.writeEnum(11, this.created_extract_from);
if (this.created_attribute_name.length)
writer.writeString(12, this.created_attribute_name);
if (this.selector_content.length) if (this.selector_content.length)
writer.writeString(8, this.selector_content); writer.writeString(8, this.selector_content);
if (this.selector_enclosure.length) if (this.selector_enclosure.length)
@ -258,6 +302,12 @@ export namespace rssalchemy {
case 7: case 7:
message.selector_created = reader.readString(); message.selector_created = reader.readString();
break; break;
case 11:
message.created_extract_from = reader.readEnum();
break;
case 12:
message.created_attribute_name = reader.readString();
break;
case 8: case 8:
message.selector_content = reader.readString(); message.selector_content = reader.readString();
break; break;

View File

@ -1,23 +1,39 @@
import { import {
validateAttribute,
validateDuration, validateDuration,
validateSelector, validateSelector,
validateUrl, validateUrl,
type validator type validator
} from "@/urlmaker/validators.ts"; } from "@/urlmaker/validators.ts";
import {rssalchemy} from "@/urlmaker/proto/specs.ts";
import type {Enum} from "@/common/enum.ts";
export interface Field { export type SpecKey = ReturnType<rssalchemy.Specs['toObject']>;
name: string export type SpecValue = string | number;
input_type: string export type Specs = {[k in keyof SpecKey]: SpecValue};
label: string
default: string export enum InputType {
validate: validator Url = 'url',
required?: boolean Text = 'text',
Radio = 'radio'
} }
export const fields = [ export interface SpecField {
name: keyof Specs
input_type: InputType
enum?: Enum,
label: string
default: SpecValue
validate: validator
required?: boolean
group?: string
show_if?: (specs: Specs) => boolean
}
export const fields: SpecField[] = [
{ {
name: 'url', name: 'url',
input_type: 'url', input_type: InputType.Url,
label: 'URL of page for converting', label: 'URL of page for converting',
default: '', default: '',
validate: validateUrl, validate: validateUrl,
@ -25,72 +41,94 @@ export const fields = [
}, },
{ {
name: 'selector_post', name: 'selector_post',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for post', label: 'CSS Selector for post',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
}, },
{ {
name: 'selector_title', name: 'selector_title',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for title', label: 'CSS Selector for title',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
}, },
{ {
name: 'selector_link', name: 'selector_link',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for link', label: 'CSS Selector for link',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
}, },
{ {
name: 'selector_description', name: 'selector_description',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for description', label: 'CSS Selector for description',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
}, },
{ {
name: 'selector_author', name: 'selector_author',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for author', label: 'CSS Selector for author',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
}, },
{ {
name: 'selector_created', name: 'selector_created',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for created date', label: 'CSS Selector for created date',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
group: 'created',
}, },
{
name: 'created_extract_from',
input_type: InputType.Radio,
enum: [
{label: 'Inner Text', value: rssalchemy.ExtractFrom.InnerText},
{label: 'Attribute', value: rssalchemy.ExtractFrom.Attribute},
],
label: 'Extract from',
default: rssalchemy.ExtractFrom.InnerText,
validate: value => Object.values(rssalchemy.ExtractFrom).includes(value),
group: 'created',
show_if: specs => !!specs.selector_created,
},
{
name: 'created_attribute_name',
input_type: InputType.Text,
label: 'Attribute name',
default: '',
validate: validateAttribute,
show_if: specs =>
!!specs.selector_created && specs.created_extract_from === rssalchemy.ExtractFrom.Attribute,
group: 'created',
},
{ {
name: 'selector_content', name: 'selector_content',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for content', label: 'CSS Selector for content',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
}, },
{ {
name: 'selector_enclosure', name: 'selector_enclosure',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for enclosure (e.g. image url)', label: 'CSS Selector for enclosure (e.g. image url)',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
}, },
{ {
name: 'cache_lifetime', name: 'cache_lifetime',
input_type: 'text', input_type: InputType.Text,
label: 'Cache lifetime (format examples: 10s, 1m, 2h)', label: 'Cache lifetime (format examples: 10s, 1m, 2h)',
default: '10m', default: '10m',
validate: validateDuration, validate: validateDuration,
}, },
] as const satisfies Field[]; ];
export type FieldNames = (typeof fields)[number]['name'];
export type Specs = {[k in FieldNames]: string};
export const emptySpecs = fields.reduce((o, f) => { export const emptySpecs = fields.reduce((o, f) => {
o[f.name] = f.default; o[f.name] = f.default;

View File

@ -1,54 +1,35 @@
import {presetPrefix} from "@/urlmaker/index.ts"; import {presetPrefix} from "@/urlmaker/index.ts";
import type {SpecValue} from "@/urlmaker/specs.ts";
type validResult = { ok: boolean, error?: string }; export type validator = (v: SpecValue) => boolean;
export type validator = (v: string) => validResult
export function validateUrl(s: string): validResult { export function validateUrl(s: SpecValue): boolean {
let url; let url;
try { try {
url = new URL(s); url = new URL(s as string);
return { return url.protocol === "http:" || url.protocol === "https:"
ok: url.protocol === "http:" || url.protocol === "https:",
error: 'Invalid URL protocol',
};
} catch { } catch {
return {ok: false, error: 'Invalid URL'}; return false;
} }
} }
export function validatePreset(s: string): validResult { export function validatePreset(s: SpecValue): boolean {
if(!s.startsWith(presetPrefix)) { return (s as string).startsWith(presetPrefix);
return {
ok: false,
error: 'Not a preset'
}
}
return {ok: true}
} }
export function validateOr(...validators: validator[]): validator { export function validateSelector(s: SpecValue): boolean {
return function(s: string): validResult {
return validators.reduce<validResult>((res, v) => {
let r = v(s);
if(r.ok) res.ok = true;
else res.error += r.error + '; ';
return res;
}, {ok: false, error: ''});
}
}
export function validateSelector(s: string): validResult {
try { try {
document.createDocumentFragment().querySelector(s); document.createDocumentFragment().querySelector(s as string);
return {ok: true} return true;
} catch { } catch {
return {ok: false, error: 'Invalid selector'}; return false;
} }
} }
export function validateDuration(s: string): validResult { export function validateAttribute(s: SpecValue): boolean {
return { return /([^\t\n\f \/>"'=]+)/.test(s as string);
ok: /^\d+[smh]$/.test(s), }
error: 'Duration must be number and unit (s/m/h), example: 5s = 5 seconds'
} export function validateDuration(s: SpecValue): boolean {
return /^\d+[smh]$/.test(s as string);
} }

View File

@ -75,6 +75,14 @@ func (h *Handler) handleRender(c echo.Context) error {
return echo.NewHTTPError(400, fmt.Errorf("decode specs: %w", err)) return echo.NewHTTPError(400, fmt.Errorf("decode specs: %w", err))
} }
extractFrom, ok := map[pb.ExtractFrom]models.ExtractFrom{
pb.ExtractFrom_InnerText: models.ExtractFrom_InnerText,
pb.ExtractFrom_Attribute: models.ExtractFrom_Attribute,
}[specs.CreatedExtractFrom]
if !ok {
return echo.NewHTTPError(400, "invalid extract from")
}
task := models.Task{ task := models.Task{
TaskType: models.TaskTypeExtract, TaskType: models.TaskTypeExtract,
URL: specs.Url, URL: specs.Url,
@ -84,6 +92,8 @@ func (h *Handler) handleRender(c echo.Context) error {
SelectorDescription: specs.SelectorDescription, SelectorDescription: specs.SelectorDescription,
SelectorAuthor: specs.SelectorAuthor, SelectorAuthor: specs.SelectorAuthor,
SelectorCreated: specs.SelectorCreated, SelectorCreated: specs.SelectorCreated,
CreatedExtractFrom: extractFrom,
CreatedAttributeName: specs.CreatedAttributeName,
SelectorContent: specs.SelectorContent, SelectorContent: specs.SelectorContent,
SelectorEnclosure: specs.SelectorEnclosure, SelectorEnclosure: specs.SelectorEnclosure,
Headers: extractHeaders(c), Headers: extractHeaders(c),

View File

@ -22,6 +22,52 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
) )
type ExtractFrom int32
const (
ExtractFrom_InnerText ExtractFrom = 0
ExtractFrom_Attribute ExtractFrom = 1
)
// Enum value maps for ExtractFrom.
var (
ExtractFrom_name = map[int32]string{
0: "InnerText",
1: "Attribute",
}
ExtractFrom_value = map[string]int32{
"InnerText": 0,
"Attribute": 1,
}
)
func (x ExtractFrom) Enum() *ExtractFrom {
p := new(ExtractFrom)
*p = x
return p
}
func (x ExtractFrom) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (ExtractFrom) Descriptor() protoreflect.EnumDescriptor {
return file_proto_specs_proto_enumTypes[0].Descriptor()
}
func (ExtractFrom) Type() protoreflect.EnumType {
return &file_proto_specs_proto_enumTypes[0]
}
func (x ExtractFrom) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use ExtractFrom.Descriptor instead.
func (ExtractFrom) EnumDescriptor() ([]byte, []int) {
return file_proto_specs_proto_rawDescGZIP(), []int{0}
}
type Specs struct { type Specs struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url" validate:"url"` Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url" validate:"url"`
@ -31,6 +77,8 @@ type Specs struct {
SelectorDescription string `protobuf:"bytes,5,opt,name=selector_description,json=selectorDescription,proto3" json:"selector_description" validate:"omitempty,selector"` SelectorDescription string `protobuf:"bytes,5,opt,name=selector_description,json=selectorDescription,proto3" json:"selector_description" validate:"omitempty,selector"`
SelectorAuthor string `protobuf:"bytes,6,opt,name=selector_author,json=selectorAuthor,proto3" json:"selector_author" validate:"selector"` SelectorAuthor string `protobuf:"bytes,6,opt,name=selector_author,json=selectorAuthor,proto3" json:"selector_author" validate:"selector"`
SelectorCreated string `protobuf:"bytes,7,opt,name=selector_created,json=selectorCreated,proto3" json:"selector_created" validate:"selector"` SelectorCreated string `protobuf:"bytes,7,opt,name=selector_created,json=selectorCreated,proto3" json:"selector_created" validate:"selector"`
CreatedExtractFrom ExtractFrom `protobuf:"varint,11,opt,name=created_extract_from,json=createdExtractFrom,proto3,enum=rssalchemy.ExtractFrom" json:"created_extract_from"`
CreatedAttributeName string `protobuf:"bytes,12,opt,name=created_attribute_name,json=createdAttributeName,proto3" json:"created_attribute_name"`
SelectorContent string `protobuf:"bytes,8,opt,name=selector_content,json=selectorContent,proto3" json:"selector_content" validate:"omitempty,selector"` SelectorContent string `protobuf:"bytes,8,opt,name=selector_content,json=selectorContent,proto3" json:"selector_content" validate:"omitempty,selector"`
SelectorEnclosure string `protobuf:"bytes,9,opt,name=selector_enclosure,json=selectorEnclosure,proto3" json:"selector_enclosure" validate:"selector"` SelectorEnclosure string `protobuf:"bytes,9,opt,name=selector_enclosure,json=selectorEnclosure,proto3" json:"selector_enclosure" validate:"selector"`
CacheLifetime string `protobuf:"bytes,10,opt,name=cache_lifetime,json=cacheLifetime,proto3" json:"cache_lifetime"` CacheLifetime string `protobuf:"bytes,10,opt,name=cache_lifetime,json=cacheLifetime,proto3" json:"cache_lifetime"`
@ -117,6 +165,20 @@ func (x *Specs) GetSelectorCreated() string {
return "" return ""
} }
func (x *Specs) GetCreatedExtractFrom() ExtractFrom {
if x != nil {
return x.CreatedExtractFrom
}
return ExtractFrom_InnerText
}
func (x *Specs) GetCreatedAttributeName() string {
if x != nil {
return x.CreatedAttributeName
}
return ""
}
func (x *Specs) GetSelectorContent() string { func (x *Specs) GetSelectorContent() string {
if x != nil { if x != nil {
return x.SelectorContent return x.SelectorContent
@ -144,7 +206,7 @@ var file_proto_specs_proto_rawDesc = string([]byte{
0x0a, 0x11, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x73, 0x70, 0x65, 0x63, 0x73, 0x2e, 0x70, 0x72, 0x0a, 0x11, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x73, 0x70, 0x65, 0x63, 0x73, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x72, 0x73, 0x73, 0x61, 0x6c, 0x63, 0x68, 0x65, 0x6d, 0x79, 0x1a, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x72, 0x73, 0x73, 0x61, 0x6c, 0x63, 0x68, 0x65, 0x6d, 0x79, 0x1a,
0x13, 0x74, 0x61, 0x67, 0x67, 0x65, 0x72, 0x2f, 0x74, 0x61, 0x67, 0x67, 0x65, 0x72, 0x2e, 0x70, 0x13, 0x74, 0x61, 0x67, 0x67, 0x65, 0x72, 0x2f, 0x74, 0x61, 0x67, 0x67, 0x65, 0x72, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x22, 0xef, 0x06, 0x0a, 0x05, 0x53, 0x70, 0x65, 0x63, 0x73, 0x12, 0x30, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xb6, 0x08, 0x0a, 0x05, 0x53, 0x70, 0x65, 0x63, 0x73, 0x12, 0x30,
0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1e, 0x9a, 0x84, 0x9e, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1e, 0x9a, 0x84, 0x9e,
0x03, 0x19, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x22, 0x75, 0x72, 0x6c, 0x22, 0x20, 0x76, 0x61, 0x6c, 0x03, 0x19, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x22, 0x75, 0x72, 0x6c, 0x22, 0x20, 0x76, 0x61, 0x6c,
0x69, 0x64, 0x61, 0x74, 0x65, 0x3a, 0x22, 0x75, 0x72, 0x6c, 0x22, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x3a, 0x22, 0x75, 0x72, 0x6c, 0x22, 0x52, 0x03, 0x75, 0x72, 0x6c,
@ -182,26 +244,41 @@ var file_proto_specs_proto_rawDesc = string([]byte{
0x6e, 0x3a, 0x22, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x63, 0x72, 0x65, 0x61, 0x6e, 0x3a, 0x22, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x63, 0x72, 0x65, 0x61,
0x74, 0x65, 0x64, 0x22, 0x20, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x3a, 0x22, 0x73, 0x74, 0x65, 0x64, 0x22, 0x20, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x3a, 0x22, 0x73,
0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x22, 0x52, 0x0f, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x22, 0x52, 0x0f, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74,
0x6f, 0x72, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x12, 0x65, 0x0a, 0x10, 0x73, 0x65, 0x6c, 0x6f, 0x72, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x12, 0x6b, 0x0a, 0x14, 0x63, 0x72, 0x65,
0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x08, 0x20, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x65, 0x78, 0x74, 0x72, 0x61, 0x63, 0x74, 0x5f, 0x66, 0x72, 0x6f,
0x01, 0x28, 0x09, 0x42, 0x3a, 0x9a, 0x84, 0x9e, 0x03, 0x35, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x22, 0x6d, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x72, 0x73, 0x73, 0x61, 0x6c, 0x63,
0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x68, 0x65, 0x6d, 0x79, 0x2e, 0x45, 0x78, 0x74, 0x72, 0x61, 0x63, 0x74, 0x46, 0x72, 0x6f, 0x6d,
0x22, 0x20, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x3a, 0x22, 0x6f, 0x6d, 0x69, 0x74, 0x42, 0x20, 0x9a, 0x84, 0x9e, 0x03, 0x1b, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x22, 0x63, 0x72, 0x65,
0x65, 0x6d, 0x70, 0x74, 0x79, 0x2c, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x22, 0x52, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x65, 0x78, 0x74, 0x72, 0x61, 0x63, 0x74, 0x5f, 0x66, 0x72, 0x6f,
0x0f, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x6d, 0x22, 0x52, 0x12, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x45, 0x78, 0x74, 0x72, 0x61,
0x12, 0x61, 0x0a, 0x12, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x65, 0x6e, 0x63, 0x63, 0x74, 0x46, 0x72, 0x6f, 0x6d, 0x12, 0x58, 0x0a, 0x16, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65,
0x6c, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x42, 0x32, 0x9a, 0x84, 0x64, 0x5f, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65,
0x9e, 0x03, 0x2d, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x22, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x42, 0x22, 0x9a, 0x84, 0x9e, 0x03, 0x1d, 0x6a, 0x73, 0x6f,
0x72, 0x5f, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x22, 0x20, 0x76, 0x61, 0x6c, 0x6e, 0x3a, 0x22, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x74, 0x72, 0x69,
0x69, 0x64, 0x61, 0x74, 0x65, 0x3a, 0x22, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x22, 0x62, 0x75, 0x74, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x52, 0x14, 0x63, 0x72, 0x65, 0x61,
0x52, 0x11, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x45, 0x6e, 0x63, 0x6c, 0x6f, 0x73, 0x74, 0x65, 0x64, 0x41, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x4e, 0x61, 0x6d, 0x65,
0x75, 0x72, 0x65, 0x12, 0x41, 0x0a, 0x0e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x6c, 0x69, 0x66, 0x12, 0x65, 0x0a, 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x63, 0x6f, 0x6e,
0x65, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1a, 0x9a, 0x84, 0x9e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x42, 0x3a, 0x9a, 0x84, 0x9e, 0x03,
0x03, 0x15, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x22, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x6c, 0x69, 0x35, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x22, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f,
0x66, 0x65, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x52, 0x0d, 0x63, 0x61, 0x63, 0x68, 0x65, 0x4c, 0x69, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x22, 0x20, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74,
0x66, 0x65, 0x74, 0x69, 0x6d, 0x65, 0x42, 0x16, 0x5a, 0x14, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x65, 0x3a, 0x22, 0x6f, 0x6d, 0x69, 0x74, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2c, 0x73, 0x65, 0x6c,
0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x68, 0x74, 0x74, 0x70, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x22, 0x52, 0x0f, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x61, 0x0a, 0x12, 0x73, 0x65, 0x6c, 0x65, 0x63,
0x74, 0x6f, 0x72, 0x5f, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x18, 0x09, 0x20,
0x01, 0x28, 0x09, 0x42, 0x32, 0x9a, 0x84, 0x9e, 0x03, 0x2d, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x22,
0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x73, 0x75,
0x72, 0x65, 0x22, 0x20, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x3a, 0x22, 0x73, 0x65,
0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x22, 0x52, 0x11, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f,
0x72, 0x45, 0x6e, 0x63, 0x6c, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x12, 0x41, 0x0a, 0x0e, 0x63, 0x61,
0x63, 0x68, 0x65, 0x5f, 0x6c, 0x69, 0x66, 0x65, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x0a, 0x20, 0x01,
0x28, 0x09, 0x42, 0x1a, 0x9a, 0x84, 0x9e, 0x03, 0x15, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x22, 0x63,
0x61, 0x63, 0x68, 0x65, 0x5f, 0x6c, 0x69, 0x66, 0x65, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x52, 0x0d,
0x63, 0x61, 0x63, 0x68, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x74, 0x69, 0x6d, 0x65, 0x2a, 0x2b, 0x0a,
0x0b, 0x45, 0x78, 0x74, 0x72, 0x61, 0x63, 0x74, 0x46, 0x72, 0x6f, 0x6d, 0x12, 0x0d, 0x0a, 0x09,
0x49, 0x6e, 0x6e, 0x65, 0x72, 0x54, 0x65, 0x78, 0x74, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x41,
0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x10, 0x01, 0x42, 0x16, 0x5a, 0x14, 0x69, 0x6e,
0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x68, 0x74, 0x74, 0x70, 0x2f,
0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}) })
var ( var (
@ -216,16 +293,19 @@ func file_proto_specs_proto_rawDescGZIP() []byte {
return file_proto_specs_proto_rawDescData return file_proto_specs_proto_rawDescData
} }
var file_proto_specs_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_proto_specs_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_proto_specs_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
var file_proto_specs_proto_goTypes = []any{ var file_proto_specs_proto_goTypes = []any{
(*Specs)(nil), // 0: rssalchemy.Specs (ExtractFrom)(0), // 0: rssalchemy.ExtractFrom
(*Specs)(nil), // 1: rssalchemy.Specs
} }
var file_proto_specs_proto_depIdxs = []int32{ var file_proto_specs_proto_depIdxs = []int32{
0, // [0:0] is the sub-list for method output_type 0, // 0: rssalchemy.Specs.created_extract_from:type_name -> rssalchemy.ExtractFrom
0, // [0:0] is the sub-list for method input_type 1, // [1:1] is the sub-list for method output_type
0, // [0:0] is the sub-list for extension type_name 1, // [1:1] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension extendee 1, // [1:1] is the sub-list for extension type_name
0, // [0:0] is the sub-list for field type_name 1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
} }
func init() { file_proto_specs_proto_init() } func init() { file_proto_specs_proto_init() }
@ -238,13 +318,14 @@ func file_proto_specs_proto_init() {
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_specs_proto_rawDesc), len(file_proto_specs_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_specs_proto_rawDesc), len(file_proto_specs_proto_rawDesc)),
NumEnums: 0, NumEnums: 1,
NumMessages: 1, NumMessages: 1,
NumExtensions: 0, NumExtensions: 0,
NumServices: 0, NumServices: 0,
}, },
GoTypes: file_proto_specs_proto_goTypes, GoTypes: file_proto_specs_proto_goTypes,
DependencyIndexes: file_proto_specs_proto_depIdxs, DependencyIndexes: file_proto_specs_proto_depIdxs,
EnumInfos: file_proto_specs_proto_enumTypes,
MessageInfos: file_proto_specs_proto_msgTypes, MessageInfos: file_proto_specs_proto_msgTypes,
}.Build() }.Build()
File_proto_specs_proto = out.File File_proto_specs_proto = out.File

View File

@ -114,7 +114,15 @@ func (p *pageParser) extractPost(post playwright.Locator) (models.FeedItem, erro
item.Enclosure = newLocator(post, p.task.SelectorEnclosure).First().GetAttribute("src") item.Enclosure = newLocator(post, p.task.SelectorEnclosure).First().GetAttribute("src")
createdDateStr := newLocator(post, p.task.SelectorCreated).First().InnerText() var createdDateStr string
switch p.task.CreatedExtractFrom {
case models.ExtractFrom_InnerText:
createdDateStr = newLocator(post, p.task.SelectorCreated).First().InnerText()
case models.ExtractFrom_Attribute:
createdDateStr = newLocator(post, p.task.SelectorCreated).First().GetAttribute(p.task.CreatedAttributeName)
default:
return models.FeedItem{}, fmt.Errorf("invalid task.CreatedExtractFrom")
}
log.Debugf("date=%s", createdDateStr) log.Debugf("date=%s", createdDateStr)
createdDate, err := p.dateParser.ParseDate(createdDateStr) createdDate, err := p.dateParser.ParseDate(createdDateStr)
if err != nil { if err != nil {

View File

@ -13,6 +13,13 @@ const (
TaskTypePageScreenshot = "page_screenshot" TaskTypePageScreenshot = "page_screenshot"
) )
type ExtractFrom int
const (
ExtractFrom_InnerText ExtractFrom = 0
ExtractFrom_Attribute ExtractFrom = 1
)
type Task struct { type Task struct {
// While adding new fields, dont forget to alter caching func // While adding new fields, dont forget to alter caching func
TaskType TaskType TaskType TaskType
@ -23,6 +30,8 @@ type Task struct {
SelectorDescription string SelectorDescription string
SelectorAuthor string SelectorAuthor string
SelectorCreated string SelectorCreated string
CreatedExtractFrom ExtractFrom
CreatedAttributeName string
SelectorContent string SelectorContent string
SelectorEnclosure string SelectorEnclosure string
Headers map[string]string Headers map[string]string

View File

@ -6,6 +6,11 @@ import "tagger/tagger.proto";
option go_package = "internal/api/http/pb"; option go_package = "internal/api/http/pb";
enum ExtractFrom {
InnerText = 0;
Attribute = 1;
}
message Specs { message Specs {
string url = 1 [(tagger.tags) = "json:\"url\" validate:\"url\""]; string url = 1 [(tagger.tags) = "json:\"url\" validate:\"url\""];
string selector_post = 2 [(tagger.tags) = "json:\"selector_post\" validate:\"selector\""]; string selector_post = 2 [(tagger.tags) = "json:\"selector_post\" validate:\"selector\""];
@ -13,7 +18,11 @@ message Specs {
string selector_link = 4 [(tagger.tags) = "json:\"selector_link\" validate:\"selector\""]; string selector_link = 4 [(tagger.tags) = "json:\"selector_link\" validate:\"selector\""];
string selector_description = 5 [(tagger.tags) = "json:\"selector_description\" validate:\"omitempty,selector\""]; string selector_description = 5 [(tagger.tags) = "json:\"selector_description\" validate:\"omitempty,selector\""];
string selector_author = 6 [(tagger.tags) = "json:\"selector_author\" validate:\"selector\""]; string selector_author = 6 [(tagger.tags) = "json:\"selector_author\" validate:\"selector\""];
string selector_created = 7 [(tagger.tags) = "json:\"selector_created\" validate:\"selector\""]; string selector_created = 7 [(tagger.tags) = "json:\"selector_created\" validate:\"selector\""];
ExtractFrom created_extract_from = 11 [(tagger.tags) = "json:\"created_extract_from\""];
string created_attribute_name = 12 [(tagger.tags) = "json:\"created_attribute_name\""];
string selector_content = 8 [(tagger.tags) = "json:\"selector_content\" validate:\"omitempty,selector\""]; string selector_content = 8 [(tagger.tags) = "json:\"selector_content\" validate:\"omitempty,selector\""];
string selector_enclosure = 9 [(tagger.tags) = "json:\"selector_enclosure\" validate:\"selector\""]; string selector_enclosure = 9 [(tagger.tags) = "json:\"selector_enclosure\" validate:\"selector\""];
string cache_lifetime = 10 [(tagger.tags) = "json:\"cache_lifetime\""]; string cache_lifetime = 10 [(tagger.tags) = "json:\"cache_lifetime\""];